@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.
- package/README.md +38 -0
- package/bin/myelin.js +365 -0
- 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
|
+
}
|