@myelinbridge/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +38 -0
  2. package/bin/myelin.js +365 -0
  3. package/package.json +26 -0
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @myelinbridge/cli
2
+
3
+ Push R&D data deliveries into [Myelin](https://myelinbridge.com) from a pipeline —
4
+ preflight against the client's published quality rules, resumable upload, submit,
5
+ and track review outcomes. Full API reference: https://myelinbridge.com/developers.
6
+
7
+ ## Quick start (~10 minutes from key to first submit)
8
+
9
+ ```bash
10
+ export MYELIN_API_KEY=myl_live_… # created by your bridge owner in Bridge → API
11
+
12
+ npx @myelinbridge/cli ping # verifies auth, prints your projects
13
+ npx @myelinbridge/cli datasets # what you can deliver to, and whose move it is
14
+ npx @myelinbridge/cli check ./run_042 --dataset onco1-wes # validate BEFORE uploading a byte
15
+ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
16
+ ```
17
+
18
+ - **Resume = re-run.** `push` is idempotent: already-uploaded files are skipped
19
+ (path + size), interrupted uploads resume mid-file over TUS.
20
+ - **`check` costs nothing.** It evaluates your local manifest against the dataset's
21
+ quality checks server-side — same engine, same verdicts as submit — without
22
+ uploading. Exit code 2 means a blocking rule fails.
23
+ - **The fix loop is machine-readable.** On `changes_requested`,
24
+ `myelin status <batch> --json` returns the failed files, reviewer comments, and
25
+ rule remediation hints; fix, re-`push --submit`, unchanged files keep their
26
+ review votes.
27
+
28
+ ## Machine mode
29
+
30
+ Every command takes `--json`. Exit codes: `0` ok · `1` error · `2` blocked
31
+ (blocking preflight failure, locked delivery, blocked submit).
32
+
33
+ ## Webhooks instead of polling
34
+
35
+ Register an HTTPS endpoint (portal Bridge → API, or `POST /v1/webhook-endpoints`)
36
+ to receive signed events (`batch.validated`, `batch.changes_requested`,
37
+ `batch.transferred`, …). Verification snippets: https://myelinbridge.com/developers.
38
+ Polling fallback: `GET /v1/events?cursor=…`.
package/bin/myelin.js ADDED
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env node
2
+ // @myelinbridge/cli — Partner Ingestion CLI (Partner API Phase 5, design §3.3).
3
+ //
4
+ // export MYELIN_API_KEY=myl_live_…
5
+ // npx @myelinbridge/cli ping
6
+ // npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
7
+ //
8
+ // Exit codes: 0 ok · 1 error · 2 blocked (failing checks / locked delivery).
9
+ // Every command accepts --json for machine-readable output.
10
+
11
+ import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
12
+ import { resolve, join, relative, sep, basename } from 'node:path'
13
+ import process from 'node:process'
14
+ import * as tus from 'tus-js-client'
15
+
16
+ const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
17
+ const KEY = process.env.MYELIN_API_KEY
18
+
19
+ const argv = process.argv.slice(2)
20
+ const JSON_MODE = argv.includes('--json')
21
+ const args = argv.filter((a) => a !== '--json')
22
+ const command = args[0]
23
+
24
+ const out = (line) => { if (!JSON_MODE) console.log(line) }
25
+ const emit = (obj) => { if (JSON_MODE) console.log(JSON.stringify(obj, null, 2)) }
26
+ const die = (message, code = 1) => {
27
+ if (JSON_MODE) console.log(JSON.stringify({ error: message }))
28
+ else console.error(`✗ ${message}`)
29
+ process.exit(code)
30
+ }
31
+
32
+ function flag(name) {
33
+ return args.includes(`--${name}`)
34
+ }
35
+ function opt(name) {
36
+ const i = args.indexOf(`--${name}`)
37
+ return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : null
38
+ }
39
+
40
+ async function api(method, path, body, raw) {
41
+ const res = await fetch(API_URL + path, {
42
+ method,
43
+ headers: {
44
+ Authorization: `Bearer ${KEY}`,
45
+ ...(body && !raw ? { 'Content-Type': 'application/json' } : {}),
46
+ },
47
+ body: raw ? body : body ? JSON.stringify(body) : undefined,
48
+ }).catch((err) => die(`Cannot reach ${API_URL}: ${err.message}`))
49
+ let json = null
50
+ try { json = await res.json() } catch { /* non-JSON body */ }
51
+ return { status: res.status, json }
52
+ }
53
+
54
+ function expectOk(r, context) {
55
+ if (r.status >= 200 && r.status < 300) return r.json
56
+ const detail = r.json?.detail ?? `HTTP ${r.status}`
57
+ const code = r.json?.code ?? 'error'
58
+ const blocked = ['delivery_locked', 'submit_blocked', 'dataset_not_active', 'api_disabled', 'bridge_paused'].includes(code)
59
+ die(`${context}: [${code}] ${detail}`, blocked ? 2 : 1)
60
+ }
61
+
62
+ // ——— dataset resolution (id or slug) ———
63
+ async function resolveDataset(ref) {
64
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
65
+ const r = await api('GET', `/datasets/${ref}`)
66
+ if (r.status === 200) return r.json.dataset
67
+ die(`Dataset ${ref} not found in this key's scope`)
68
+ }
69
+ const me = expectOk(await api('GET', '/me'), 'auth')
70
+ for (const p of me.projects) {
71
+ const r = expectOk(await api('GET', `/projects/${p.id}/datasets`), 'datasets')
72
+ const hit = r.datasets.find((d) => d.slug === ref || d.name === ref)
73
+ if (hit) return hit
74
+ }
75
+ die(`No dataset with slug or name "${ref}" in this key's scope`)
76
+ }
77
+
78
+ // ——— local manifest ———
79
+ function walkDir(root) {
80
+ const files = []
81
+ const walk = (dir) => {
82
+ for (const entry of readdirSync(dir)) {
83
+ const full = join(dir, entry)
84
+ const st = statSync(full)
85
+ if (st.isDirectory()) walk(full)
86
+ else files.push({ abs: full, path: '/' + relative(root, full).split(sep).join('/'), size: st.size })
87
+ }
88
+ }
89
+ walk(root)
90
+ return files
91
+ }
92
+
93
+ function fmtBytes(n) {
94
+ if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB'
95
+ if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'
96
+ if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'
97
+ return n + ' B'
98
+ }
99
+
100
+ function tusUpload(absPath, size, storagePath, upload) {
101
+ return new Promise((resolveP, rejectP) => {
102
+ new tus.Upload(createReadStream(absPath), {
103
+ endpoint: upload.endpoint,
104
+ chunkSize: upload.chunk_size_bytes,
105
+ uploadSize: size,
106
+ retryDelays: [0, 1000, 5000],
107
+ headers: { authorization: `Bearer ${upload.token}`, apikey: upload.apikey, ...upload.headers },
108
+ metadata: {
109
+ bucketName: upload.bucket,
110
+ objectName: storagePath,
111
+ contentType: 'application/octet-stream',
112
+ cacheControl: '3600',
113
+ },
114
+ onError: rejectP,
115
+ onSuccess: () => resolveP(),
116
+ }).start()
117
+ })
118
+ }
119
+
120
+ // ——— commands ———
121
+
122
+ async function cmdPing() {
123
+ const j = expectOk(await api('GET', '/ping'), 'ping')
124
+ emit(j)
125
+ out(`✓ key "${j.key.name}" (${j.key.prefix}…) · bridge ${j.bridge.name} · ${j.projects.length} project(s)`)
126
+ }
127
+
128
+ async function cmdProjects() {
129
+ const j = expectOk(await api('GET', '/projects'), 'projects')
130
+ emit(j)
131
+ for (const p of j.projects) out(`${p.code.padEnd(18)} ${p.status.padEnd(10)} ${p.title}`)
132
+ }
133
+
134
+ async function cmdDatasets() {
135
+ const me = expectOk(await api('GET', '/me'), 'auth')
136
+ const rows = []
137
+ for (const p of me.projects) {
138
+ const r = expectOk(await api('GET', `/projects/${p.id}/datasets`), 'datasets')
139
+ for (const d of r.datasets) {
140
+ rows.push({
141
+ project: p.code,
142
+ dataset: d.slug,
143
+ status: d.lifecycle_status,
144
+ quality_version: d.quality_check_version,
145
+ open_draft: d.open_draft_batch_id,
146
+ id: d.id,
147
+ })
148
+ }
149
+ }
150
+ emit({ datasets: rows })
151
+ if (!JSON_MODE) {
152
+ out('PROJECT'.padEnd(18) + 'DATASET'.padEnd(22) + 'STATUS'.padEnd(10) + 'YOUR MOVE?')
153
+ for (const r of rows) {
154
+ out(
155
+ r.project.padEnd(18) +
156
+ r.dataset.padEnd(22) +
157
+ r.status.padEnd(10) +
158
+ (r.open_draft ? 'yes — open draft to finish' : '—'),
159
+ )
160
+ }
161
+ }
162
+ }
163
+
164
+ async function cmdCheck() {
165
+ const dir = args[1]
166
+ const dsRef = opt('dataset')
167
+ if (!dir || !dsRef) die('Usage: myelin check <dir> --dataset <slug|id>')
168
+ const root = resolve(dir)
169
+ const files = walkDir(root)
170
+ if (files.length === 0) die(`No files under ${root}`)
171
+ const dataset = await resolveDataset(dsRef)
172
+
173
+ out(`Evaluating ${files.length} files (${fmtBytes(files.reduce((s, f) => s + f.size, 0))}) against quality checks v${dataset.quality_check_version ?? '—'}…`)
174
+ const j = expectOk(
175
+ await api('POST', `/datasets/${dataset.id}/preflight`, {
176
+ files: files.map((f) => ({ path: f.path, size_bytes: f.size })),
177
+ }),
178
+ 'preflight',
179
+ )
180
+ emit(j)
181
+ if (!JSON_MODE) {
182
+ for (const c of j.evaluated) {
183
+ const mark = c.verdict === 'passed' ? '✓' : c.verdict === 'flagged' ? '⚠' : '✗'
184
+ out(`${mark} ${c.check_type.padEnd(26)} ${c.verdict}${c.severity === 'blocking' && c.verdict === 'failed' ? ' — BLOCKING' : ''}`)
185
+ if (c.verdict !== 'passed' && c.remediation) out(` hint: ${c.remediation}`)
186
+ }
187
+ for (const d of j.deferred) out(`… ${d.check_type.padEnd(26)} ${d.reason}`)
188
+ for (const m of j.manual) out(`○ ${m.name.padEnd(26)} ${m.reason}`)
189
+ }
190
+ if (j.blocking_failures > 0) {
191
+ out(`${j.blocking_failures} blocking issue(s). Fix before pushing to avoid a review round-trip.`)
192
+ process.exit(2)
193
+ }
194
+ out('All manifest-evaluable checks pass.')
195
+ }
196
+
197
+ async function cmdPush() {
198
+ const dir = args[1]
199
+ const dsRef = opt('dataset')
200
+ if (!dir || !dsRef) die('Usage: myelin push <dir> --dataset <slug|id> [--submit] [--replace]')
201
+ const root = resolve(dir)
202
+ const files = walkDir(root)
203
+ if (files.length === 0) die(`No files under ${root}`)
204
+ const dataset = await resolveDataset(dsRef)
205
+
206
+ // 1. create or resume the draft
207
+ const created = expectOk(await api('POST', `/datasets/${dataset.id}/batches`), 'create batch')
208
+ const batchId = created.batch_id
209
+ out(`Draft ${created.resumed ? 'resumed' : 'created'} (${batchId.slice(0, 8)}…).`)
210
+
211
+ // 2. declare in chunks of 500 — idempotent, so re-running skips what's done
212
+ const declared = []
213
+ let upload = null
214
+ for (let i = 0; i < files.length; i += 500) {
215
+ const slice = files.slice(i, i + 500)
216
+ const r = await api('POST', `/batches/${batchId}/files`, {
217
+ files: slice.map((f) => ({ path: f.path, size_bytes: f.size })),
218
+ replace: flag('replace'),
219
+ })
220
+ if (r.status !== 200 && r.status !== 409) expectOk(r, 'declare')
221
+ const conflicts = r.json.files.filter((f) => f.error)
222
+ if (conflicts.length > 0) {
223
+ for (const c of conflicts) out(`✗ ${c.path}: ${c.error}`)
224
+ die(`${conflicts.length} path conflict(s) — pass --replace to overwrite`, 2)
225
+ }
226
+ declared.push(...r.json.files)
227
+ upload = r.json.upload
228
+ }
229
+
230
+ // 3. upload the delta (anything not already 'uploaded'), 4 in parallel
231
+ const byPath = new Map(files.map((f) => [f.path, f]))
232
+ const pending = declared.filter((d) => d.upload_state !== 'uploaded')
233
+ out(`Uploading ${pending.length}/${declared.length} files (${fmtBytes(pending.reduce((s, d) => s + (byPath.get(d.path)?.size ?? 0), 0))}) · resumable`)
234
+ let done = 0
235
+ const queue = [...pending]
236
+ const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
237
+ for (;;) {
238
+ const d = queue.shift()
239
+ if (!d) return
240
+ const local = byPath.get(d.path)
241
+ await tusUpload(local.abs, local.size, d.storage_path, upload)
242
+ const c = await api('POST', `/files/${d.file_id}/confirm`)
243
+ if (c.status !== 200) die(`confirm ${d.path}: [${c.json?.code}] ${c.json?.detail ?? c.status}`)
244
+ done++
245
+ out(` ✓ ${d.path} (${done}/${pending.length})`)
246
+ }
247
+ })
248
+ await Promise.all(workers)
249
+ out('All files confirmed.')
250
+
251
+ // 4. optional submit
252
+ if (flag('submit')) {
253
+ const r = await api('POST', `/batches/${batchId}/submit`)
254
+ if (r.status !== 200) {
255
+ const code = r.json?.code ?? 'error'
256
+ die(`submit: [${code}] ${r.json?.detail ?? r.status}`, code === 'submit_blocked' ? 2 : 1)
257
+ }
258
+ const a = r.json.auto_checks
259
+ emit(r.json)
260
+ out(`Submitting… auto-checks: ${a.passed} passed, ${a.flagged} flagged, ${a.failed} failed.`)
261
+ out(`✓ Batch submitted for review. Track: myelin status ${batchId} --watch`)
262
+ } else {
263
+ emit({ batch_id: batchId, files: declared.length, submitted: false })
264
+ out(`Draft ready (not submitted). Submit with: myelin push ${dir} --dataset ${dsRef} --submit`)
265
+ }
266
+ }
267
+
268
+ async function cmdStatus() {
269
+ const ref = args[1]
270
+ if (!ref) die('Usage: myelin status <batch-id|display-name> [--watch]')
271
+
272
+ const findBatch = async () => {
273
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
274
+ const r = await api('GET', `/batches/${ref}`)
275
+ if (r.status === 200) return r.json.batch
276
+ die(`Batch ${ref} not found in this key's scope`)
277
+ }
278
+ const list = expectOk(await api('GET', '/batches?limit=100'), 'batches')
279
+ const hit = list.batches.find((b) => b.display_name === ref)
280
+ if (!hit) die(`No batch named "${ref}" in this key's scope`)
281
+ return expectOk(await api('GET', `/batches/${hit.id}`), 'batch').batch
282
+ }
283
+
284
+ const print = async (batch) => {
285
+ emit({ batch })
286
+ out(`${batch.display_name}: ${batch.status} — ${batch.court === 'partner' ? 'your move.' : batch.court === 'reviewer' ? "reviewer's move." : batch.court === 'system' ? 'transferring…' : 'done.'}`)
287
+ if (batch.status === 'changes_requested') {
288
+ const f = expectOk(await api('GET', `/batches/${batch.id}/findings`), 'findings')
289
+ if (f.request_changes_comment) out(` reviewer: "${f.request_changes_comment}"`)
290
+ for (const fr of f.file_reviews.filter((x) => x.verdict === 'failed')) {
291
+ out(` ✗ ${fr.path} (${fr.reviewer ?? 'reviewer'}: ${fr.comment ?? 'failed'})`)
292
+ }
293
+ for (const qf of f.quality_findings.filter((x) => x.verdict === 'failed')) {
294
+ out(` ✗ ${qf.name ?? qf.rule_key}${qf.hint ? `: ${qf.hint}` : ''}`)
295
+ }
296
+ }
297
+ return batch
298
+ }
299
+
300
+ let batch = await print(await findBatch())
301
+ if (flag('watch')) {
302
+ const terminal = ['transferred', 'rejected', 'changes_requested', 'transfer_failed']
303
+ while (!terminal.includes(batch.status)) {
304
+ await new Promise((r) => setTimeout(r, 20_000))
305
+ const next = await findBatch()
306
+ if (next.status !== batch.status) batch = await print(next)
307
+ }
308
+ }
309
+ }
310
+
311
+ async function cmdSandbox() {
312
+ const file = args[1]
313
+ if (!file) die('Usage: myelin sandbox <file>')
314
+ const abs = resolve(file)
315
+ const size = statSync(abs).size
316
+ if (size > 4 * 1024 * 1024) die('Sandbox files should stay under ~4 MB — this verifies connectivity, not throughput.')
317
+
318
+ const me = expectOk(await api('GET', '/me'), 'auth')
319
+ const form = new FormData()
320
+ form.set('file', new File([readFileSync(abs)], basename(abs)))
321
+ const r = await api('POST', `/bridges/${me.bridge.id}/sandbox`, form, true)
322
+ const j = expectOk(r, 'sandbox')
323
+ emit(j)
324
+ out(`✓ Sandbox upload succeeded — the partner-side test transfer checklist item is satisfied for bridge "${me.bridge.name}".`)
325
+ }
326
+
327
+ function cmdHelp() {
328
+ console.log(`myelin — Partner Ingestion CLI
329
+
330
+ Setup:
331
+ export MYELIN_API_KEY=myl_live_… (create in Bridge → API)
332
+ export MYELIN_API_URL=… (optional; default https://myelinbridge.com/api/v1)
333
+
334
+ Commands:
335
+ ping verify the key; show bridge + projects
336
+ projects list scoped projects
337
+ datasets list datasets with "your move" hints
338
+ check <dir> --dataset <slug|id> preflight local files against quality rules (no upload)
339
+ push <dir> --dataset <slug|id> create/resume a delivery and upload (resumable; re-run to resume)
340
+ [--submit] [--replace]
341
+ status <batch|name> [--watch] review status + fix-loop findings
342
+ sandbox <file> partner-side test transfer (bridge activation)
343
+
344
+ Every command accepts --json. Exit codes: 0 ok · 1 error · 2 blocked.`)
345
+ }
346
+
347
+ // ——— main ———
348
+ if (!command || command === 'help' || command === '--help') {
349
+ cmdHelp()
350
+ process.exit(0)
351
+ }
352
+ if (!KEY) die('Set MYELIN_API_KEY (create a key in Bridge → API).')
353
+
354
+ const commands = {
355
+ ping: cmdPing,
356
+ projects: cmdProjects,
357
+ datasets: cmdDatasets,
358
+ check: cmdCheck,
359
+ push: cmdPush,
360
+ status: cmdStatus,
361
+ sandbox: cmdSandbox,
362
+ }
363
+ const fn = commands[command]
364
+ if (!fn) die(`Unknown command "${command}" — run: myelin help`)
365
+ await fn()
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@myelinbridge/cli",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "myelin": "./bin/myelin.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "dependencies": {
17
+ "tus-js-client": "^4.1.0"
18
+ },
19
+ "keywords": [
20
+ "myelin",
21
+ "pharma",
22
+ "ingestion",
23
+ "tus"
24
+ ],
25
+ "license": "UNLICENSED"
26
+ }