@myelinbridge/cli 0.6.1 → 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 +21 -4
  2. package/bin/myelin.js +192 -52
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -22,15 +22,26 @@ 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.
41
+ - **Every file is fingerprinted.** Since 0.7.0, `push` computes an MD5 of each
42
+ file while it uploads and records it with the delivery. If the client's
43
+ quality contract includes a checksum-manifest check, your delivery verifies
44
+ against your own `md5_manifest.csv` instead of reading as "unverifiable".
34
45
  - **Read the contract before you build the delivery.** `contract` prints what the
35
46
  client expects — every check as one plain sentence, grouped by what it answers
36
47
  (completeness, structure, validity, consistency, integrity, privacy), and marked
@@ -61,7 +72,7 @@ partner's data, scope it to that partner's projects.
61
72
  ```bash
62
73
  export MYELIN_API_KEY=myl_live_…
63
74
 
64
- # What has landed, newest first
75
+ # What has landed, newest first (page with --cursor from the printed next_cursor)
65
76
  myelin deliveries --dataset <dataset-id>
66
77
 
67
78
  # What is this object, exactly?
@@ -90,6 +101,12 @@ A partner key calling these gets `403 wrong_key_side`, and vice versa.
90
101
  Every command takes `--json`. Exit codes: `0` ok · `1` error · `2` blocked
91
102
  (blocking preflight failure, locked delivery, blocked submit, locked sample depth).
92
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
+
93
110
  ## Environment
94
111
 
95
112
  | Variable | |
package/bin/myelin.js CHANGED
@@ -10,10 +10,12 @@
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(/\/$/, '')
16
17
  const KEY = process.env.MYELIN_API_KEY
18
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
17
19
 
18
20
  // Extra headers sent with every API call — newline-separated "Name: value"
19
21
  // pairs in MYELIN_API_HEADER. For deployments fronted by something that
@@ -48,13 +50,19 @@ class CliExit extends Error {}
48
50
  // assertion on Windows, which printed a C stack trace after a perfectly good
49
51
  // error message and replaced the documented exit code with 127. Node exits on
50
52
  // 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}`)
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
+ }
54
60
  process.exitCode = code
55
61
  throw new CliExit(message)
56
62
  }
57
63
 
64
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
65
+
58
66
  // Column widths come from the content. padEnd() alone silently ran a long
59
67
  // value into the next column — a 28-character dataset slug swallowed the
60
68
  // STATUS header's gutter. Pass headers = null for an unheadered list.
@@ -79,27 +87,61 @@ function opt(name) {
79
87
  return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : null
80
88
  }
81
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.
82
94
  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
+ 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
+ }
95
116
  }
96
117
 
97
- function expectOk(r, context) {
98
- 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) {
99
125
  const detail = r.json?.detail ?? `HTTP ${r.status}`
100
126
  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)
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)
103
145
  }
104
146
 
105
147
  // ——— dataset resolution (id or slug) ———
@@ -122,9 +164,15 @@ async function resolveDataset(ref) {
122
164
  function walkDir(root) {
123
165
  const files = []
124
166
  const walk = (dir) => {
125
- 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) {
126
170
  const full = join(dir, entry)
127
- 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)`) }
128
176
  if (st.isDirectory()) walk(full)
129
177
  else files.push({ abs: full, path: '/' + relative(root, full).split(sep).join('/'), size: st.size })
130
178
  }
@@ -158,6 +206,27 @@ async function readPart(absPath, start, end) {
158
206
  return Buffer.concat(chunks)
159
207
  }
160
208
 
209
+ // Whole-file MD5, lowercase hex — the exact format md5sum-style manifests use
210
+ // and the only one the platform's manifest_checksums_match check can verify.
211
+ // A separate sequential read (parts upload 4-wide, out of order — a streaming
212
+ // hash cannot ride along), overlapped with the upload by the caller. Returns
213
+ // null on read failure: a hash miss must degrade the delivery to
214
+ // "unverifiable", never kill the push.
215
+ async function md5File(absPath) {
216
+ try {
217
+ const hash = createHash('md5')
218
+ await new Promise((res, rej) => {
219
+ const s = createReadStream(absPath)
220
+ s.on('data', (c) => hash.update(c))
221
+ s.on('end', res)
222
+ s.on('error', rej)
223
+ })
224
+ return hash.digest('hex')
225
+ } catch {
226
+ return null
227
+ }
228
+ }
229
+
161
230
  async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone) {
162
231
  const numParts = Math.max(1, Math.ceil(size / partSize))
163
232
  const allParts = Array.from({ length: numParts }, (_, i) => i + 1)
@@ -172,12 +241,18 @@ async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone)
172
241
  const urlByPart = new Map(j.urls.map((u) => [u.part_number, u.url]))
173
242
 
174
243
  // 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
244
  const missing = window.filter((n) => !etags.has(n))
178
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.
179
251
  const resign = async (partNo) => {
180
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 }
181
256
  const fresh = j.urls.find((u) => u.part_number === partNo)?.url
182
257
  if (!fresh) die(`upload part ${partNo}: could not re-sign (already finalized?)`)
183
258
  urlByPart.set(partNo, fresh)
@@ -190,14 +265,29 @@ async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone)
190
265
  const start = (partNo - 1) * partSize
191
266
  const end = Math.min(start + partSize, size) - 1
192
267
  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}`))
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)
196
290
  }
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
291
  if (onPartDone) onPartDone()
202
292
  }
203
293
  })
@@ -352,8 +442,27 @@ async function cmdPush() {
352
442
  if (files.length === 0) die(`No files under ${root}`)
353
443
  const dataset = await resolveDataset(dsRef)
354
444
 
355
- // 1. create or resume the draft
356
- 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')
357
466
  const batchId = created.batch_id
358
467
  out(`Draft ${created.resumed ? 'resumed' : 'created'} (${batchId.slice(0, 8)}…).`)
359
468
 
@@ -367,6 +476,7 @@ async function cmdPush() {
367
476
  replace: flag('replace'),
368
477
  })
369
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
370
480
  const conflicts = r.json.files.filter((f) => f.error)
371
481
  if (conflicts.length > 0) {
372
482
  for (const c of conflicts) out(`✗ ${c.path}: ${c.error}`)
@@ -389,9 +499,20 @@ async function cmdPush() {
389
499
  const d = queue.shift()
390
500
  if (!d) return
391
501
  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}`)
502
+ // Hash and upload overlap two independent reads of the same file. The
503
+ // checksum rides on confirm so files.checksum is populated for CLI
504
+ // pushes (0.7.0): without it every CLI delivery reads as "unverifiable"
505
+ // to the manifest_checksums_match quality check.
506
+ const [{ upload_id, parts }, checksum] = await Promise.all([
507
+ uploadFileMultipart(local.abs, local.size, d.file_id, partSize),
508
+ md5File(local.abs),
509
+ ])
510
+ const c = await api('POST', `/files/${d.file_id}/confirm`, {
511
+ upload_id,
512
+ parts,
513
+ ...(checksum ? { checksum, checksum_algorithm: 'md5' } : {}),
514
+ })
515
+ if (c.status !== 200) apiDie(`confirm ${d.path}`, c)
395
516
  done++
396
517
  out(` ✓ ${d.path} (${done}/${pending.length})`)
397
518
  }
@@ -401,15 +522,17 @@ async function cmdPush() {
401
522
 
402
523
  // 4. optional submit
403
524
  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)
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`)
408
535
  }
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
536
  } else {
414
537
  emit({ batch_id: batchId, files: declared.length, submitted: false })
415
538
  out(`Draft ready (not submitted). Submit with: myelin push ${dir} --dataset ${dsRef} --submit`)
@@ -423,7 +546,7 @@ async function cmdStatus() {
423
546
  const findBatch = async () => {
424
547
  if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
425
548
  const r = await api('GET', `/batches/${ref}`)
426
- if (r.status === 200) return r.json.batch
549
+ if (r.status === 200) return expectOk(r, 'batch').batch
427
550
  die(`Batch ${ref} not found in this key's scope`)
428
551
  }
429
552
  const list = expectOk(await api('GET', '/batches?limit=100'), 'batches')
@@ -463,7 +586,8 @@ async function cmdSandbox() {
463
586
  const file = args[1]
464
587
  if (!file) die('Usage: myelin sandbox <file>')
465
588
  const abs = resolve(file)
466
- const size = statSync(abs).size
589
+ let size
590
+ try { size = statSync(abs).size } catch (err) { die(`Cannot read ${abs}: ${err.message}`) }
467
591
  if (size > 4 * 1024 * 1024) die('Sandbox files should stay under ~4 MB — this verifies connectivity, not throughput.')
468
592
 
469
593
  const me = expectOk(await api('GET', '/me'), 'auth')
@@ -523,22 +647,26 @@ Commands:
523
647
 
524
648
  Client-side commands (need a CLIENT key — Organisation → API keys):
525
649
  deliveries [--project <id>] what landed in your destination bucket
526
- [--dataset <id>] [--since <iso>] [--limit <n>]
650
+ [--dataset <id>] [--since <iso>] [--limit <n>] [--cursor <ts>]
527
651
  resolve <path|prefix|id> what a path in your bucket actually is —
528
652
  project, dataset, batch, file
529
653
 
530
- 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.`)
531
658
  }
532
659
 
533
660
  // ——— client-side commands (client keys only) ———
534
661
  //
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.
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.
538
666
 
539
667
  async function cmdDeliveries() {
540
668
  const qs = new URLSearchParams()
541
- 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' }
542
670
  for (const [flagName, param] of Object.entries(map)) {
543
671
  const v = opt(flagName)
544
672
  if (v) qs.set(param, v)
@@ -559,7 +687,15 @@ async function cmdDeliveries() {
559
687
  d.destination_root ?? '—',
560
688
  ])
561
689
  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}`)
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
+ }
563
699
  }
564
700
 
565
701
  async function cmdResolve() {
@@ -590,6 +726,10 @@ if (!command || command === 'help' || command === '--help') {
590
726
  cmdHelp()
591
727
  process.exit(0)
592
728
  }
729
+ if (command === 'version' || command === '--version' || command === '-v') {
730
+ console.log(JSON_MODE ? JSON.stringify({ version: VERSION }) : VERSION)
731
+ process.exit(0)
732
+ }
593
733
 
594
734
  const commands = {
595
735
  ping: cmdPing,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myelinbridge/cli",
3
- "version": "0.6.1",
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": {