@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.
- package/README.md +17 -4
- package/bin/myelin.js +157 -50
- 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
|
|
26
|
-
batch leaves draft**, so that verdicts stay comparable across
|
|
27
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
569
|
-
//
|
|
570
|
-
// so — the two sides deliberately do not silently degrade
|
|
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)
|
|
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.
|
|
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": {
|