@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.
- package/README.md +21 -4
- package/bin/myelin.js +192 -52
- 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
|
|
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.
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
536
|
-
//
|
|
537
|
-
// 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.
|
|
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)
|
|
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.
|
|
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": {
|