@myelinbridge/cli 0.4.0 → 0.6.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 +106 -59
  2. package/bin/myelin.js +616 -496
  3. package/package.json +23 -23
package/bin/myelin.js CHANGED
@@ -1,496 +1,616 @@
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
-
15
- const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
16
- const KEY = process.env.MYELIN_API_KEY
17
-
18
- const argv = process.argv.slice(2)
19
- const JSON_MODE = argv.includes('--json')
20
- const args = argv.filter((a) => a !== '--json')
21
- const command = args[0]
22
-
23
- const out = (line) => { if (!JSON_MODE) console.log(line) }
24
- const emit = (obj) => { if (JSON_MODE) console.log(JSON.stringify(obj, null, 2)) }
25
- const die = (message, code = 1) => {
26
- if (JSON_MODE) console.log(JSON.stringify({ error: message }))
27
- else console.error(`✗ ${message}`)
28
- process.exit(code)
29
- }
30
-
31
- function flag(name) {
32
- return args.includes(`--${name}`)
33
- }
34
- function opt(name) {
35
- const i = args.indexOf(`--${name}`)
36
- return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : null
37
- }
38
-
39
- async function api(method, path, body, raw) {
40
- const res = await fetch(API_URL + path, {
41
- method,
42
- headers: {
43
- Authorization: `Bearer ${KEY}`,
44
- ...(body && !raw ? { 'Content-Type': 'application/json' } : {}),
45
- },
46
- body: raw ? body : body ? JSON.stringify(body) : undefined,
47
- }).catch((err) => die(`Cannot reach ${API_URL}: ${err.message}`))
48
- let json = null
49
- try { json = await res.json() } catch { /* non-JSON body */ }
50
- return { status: res.status, json }
51
- }
52
-
53
- function expectOk(r, context) {
54
- if (r.status >= 200 && r.status < 300) return r.json
55
- const detail = r.json?.detail ?? `HTTP ${r.status}`
56
- const code = r.json?.code ?? 'error'
57
- const blocked = ['delivery_locked', 'submit_blocked', 'dataset_not_active', 'api_disabled', 'bridge_paused', 'sample_depth_locked'].includes(code)
58
- die(`${context}: [${code}] ${detail}`, blocked ? 2 : 1)
59
- }
60
-
61
- // ——— dataset resolution (id or slug) ———
62
- async function resolveDataset(ref) {
63
- if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
64
- const r = await api('GET', `/datasets/${ref}`)
65
- if (r.status === 200) return r.json.dataset
66
- die(`Dataset ${ref} not found in this key's scope`)
67
- }
68
- const me = expectOk(await api('GET', '/me'), 'auth')
69
- for (const p of me.projects) {
70
- const r = expectOk(await api('GET', `/projects/${p.id}/datasets`), 'datasets')
71
- const hit = r.datasets.find((d) => d.slug === ref || d.name === ref)
72
- if (hit) return hit
73
- }
74
- die(`No dataset with slug or name "${ref}" in this key's scope`)
75
- }
76
-
77
- // ——— local manifest ———
78
- function walkDir(root) {
79
- const files = []
80
- const walk = (dir) => {
81
- for (const entry of readdirSync(dir)) {
82
- const full = join(dir, entry)
83
- const st = statSync(full)
84
- if (st.isDirectory()) walk(full)
85
- else files.push({ abs: full, path: '/' + relative(root, full).split(sep).join('/'), size: st.size })
86
- }
87
- }
88
- walk(root)
89
- return files
90
- }
91
-
92
- function fmtBytes(n) {
93
- if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB'
94
- if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'
95
- if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'
96
- return n + ' B'
97
- }
98
-
99
- // Upload a file via S3 presigned multipart (SEC-TOKEN-01: no bearer token ever
100
- // reaches the client declare tells us the part size, and each part gets a
101
- // short-lived presigned PUT URL scoped to exactly that one part of one object).
102
- // Resumable: the sign step reports parts that already landed, so a re-run skips
103
- // them. Returns { upload_id, parts: [{part_number, etag}] } for confirm.
104
- const SIGN_WINDOW = 1000 // ≤ the server's max_parts_per_sign
105
-
106
- async function readPart(absPath, start, end) {
107
- const chunks = []
108
- await new Promise((res, rej) => {
109
- const s = createReadStream(absPath, { start, end }) // end inclusive
110
- s.on('data', (c) => chunks.push(c))
111
- s.on('end', res)
112
- s.on('error', rej)
113
- })
114
- return Buffer.concat(chunks)
115
- }
116
-
117
- async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone) {
118
- const numParts = Math.max(1, Math.ceil(size / partSize))
119
- const allParts = Array.from({ length: numParts }, (_, i) => i + 1)
120
- const etags = new Map() // part_number -> etag
121
- let uploadId = null
122
-
123
- for (let i = 0; i < allParts.length; i += SIGN_WINDOW) {
124
- const window = allParts.slice(i, i + SIGN_WINDOW)
125
- const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: window }), 'sign upload parts')
126
- uploadId = j.upload_id
127
- for (const p of j.uploaded_parts) etags.set(p.part_number, p.etag) // already landed → skip
128
- const urlByPart = new Map(j.urls.map((u) => [u.part_number, u.url]))
129
-
130
- // upload the still-missing parts in this window, up to 4 concurrent.
131
- // Presigned URLs expire (~30 min); on a rejection we re-sign that one part
132
- // and retry once — cheap, since upload-parts is idempotent.
133
- const missing = window.filter((n) => !etags.has(n))
134
- const queue = [...missing]
135
- const resign = async (partNo) => {
136
- const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: [partNo] }), 're-sign upload part')
137
- const fresh = j.urls.find((u) => u.part_number === partNo)?.url
138
- if (!fresh) die(`upload part ${partNo}: could not re-sign (already finalized?)`)
139
- urlByPart.set(partNo, fresh)
140
- return fresh
141
- }
142
- const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
143
- for (;;) {
144
- const partNo = queue.shift()
145
- if (partNo === undefined) return
146
- const start = (partNo - 1) * partSize
147
- const end = Math.min(start + partSize, size) - 1
148
- const bytes = await readPart(absPath, start, end)
149
- let put = await fetch(urlByPart.get(partNo), { method: 'PUT', body: bytes }).catch(() => null)
150
- if (!put || put.status === 403 || put.status === 401) {
151
- put = await fetch(await resign(partNo), { method: 'PUT', body: bytes }).catch((e) => die(`upload part ${partNo}: ${e.message}`))
152
- }
153
- if (put.status !== 200) die(`upload part ${partNo}: HTTP ${put.status}`)
154
- const etag = put.headers.get('etag')
155
- if (!etag) die(`upload part ${partNo}: storage returned no ETag`)
156
- etags.set(partNo, etag)
157
- if (onPartDone) onPartDone()
158
- }
159
- })
160
- await Promise.all(workers)
161
- }
162
-
163
- return { upload_id: uploadId, parts: allParts.map((n) => ({ part_number: n, etag: etags.get(n) })) }
164
- }
165
-
166
- // ——— commands ———
167
-
168
- async function cmdPing() {
169
- const j = expectOk(await api('GET', '/ping'), 'ping')
170
- emit(j)
171
- out(`✓ key "${j.key.name}" (${j.key.prefix}…) · bridge ${j.bridge.name} · ${j.projects.length} project(s)`)
172
- }
173
-
174
- async function cmdProjects() {
175
- const j = expectOk(await api('GET', '/projects'), 'projects')
176
- emit(j)
177
- for (const p of j.projects) out(`${p.code.padEnd(18)} ${p.status.padEnd(10)} ${p.title}`)
178
- }
179
-
180
- async function cmdDatasets() {
181
- const me = expectOk(await api('GET', '/me'), 'auth')
182
- const rows = []
183
- for (const p of me.projects) {
184
- const r = expectOk(await api('GET', `/projects/${p.id}/datasets`), 'datasets')
185
- for (const d of r.datasets) {
186
- rows.push({
187
- project: p.code,
188
- dataset: d.slug,
189
- status: d.lifecycle_status,
190
- quality_version: d.quality_check_version,
191
- open_draft: d.open_draft_batch_id,
192
- id: d.id,
193
- })
194
- }
195
- }
196
- emit({ datasets: rows })
197
- if (!JSON_MODE) {
198
- out('PROJECT'.padEnd(18) + 'DATASET'.padEnd(22) + 'STATUS'.padEnd(10) + 'YOUR MOVE?')
199
- for (const r of rows) {
200
- out(
201
- r.project.padEnd(18) +
202
- r.dataset.padEnd(22) +
203
- r.status.padEnd(10) +
204
- (r.open_draft ? 'yes — open draft to finish' : '—'),
205
- )
206
- }
207
- }
208
- }
209
-
210
- // The delivery contract, before you deliver anything. Answers "what is expected
211
- // of me?" — which used to be discoverable only by submitting and being rejected.
212
- async function cmdContract() {
213
- const dsRef = opt('dataset')
214
- if (!dsRef) die('Usage: myelin contract --dataset <slug|id>')
215
- const dataset = await resolveDataset(dsRef)
216
- const j = expectOk(
217
- await api('GET', `/datasets/${dataset.id}/quality-checks`),
218
- 'quality-checks',
219
- )
220
- emit(j)
221
- if (JSON_MODE) return
222
-
223
- if (!j.version) {
224
- out(`${dataset.name}: no quality contract published yet — nothing is enforced.`)
225
- return
226
- }
227
-
228
- const s = j.summary
229
- out(`${dataset.name} delivery contract v${j.version}`)
230
- out(
231
- `${s.total} checks · ${s.blocking} block validation · ` +
232
- `${s.checkable_before_upload} checkable before upload` +
233
- (s.needs_reviewer ? ` · ${s.needs_reviewer} reviewed by a person` : ''),
234
- )
235
- out('')
236
-
237
- // Grouped by dimension so the contract reads as questions, not a flat list.
238
- const byDim = new Map()
239
- for (const c of j.checks) {
240
- const key = c.dimension ?? 'review'
241
- if (!byDim.has(key)) byDim.set(key, [])
242
- byDim.get(key).push(c)
243
- }
244
- for (const d of s.dimensions) {
245
- const items = byDim.get(d.key) ?? []
246
- if (items.length === 0) continue
247
- out(`${d.label.toUpperCase()} — ${d.question}`)
248
- for (const c of items) {
249
- const gate = c.severity === 'blocking' ? 'must' : 'should'
250
- const when = c.runs_at === 'preflight' ? '' : ' (checked at submission)'
251
- out(` ${gate === 'must' ? '!' : '·'} ${c.assertion ?? c.name}${when}`)
252
- }
253
- out('')
254
- }
255
- const manual = byDim.get('review') ?? []
256
- if (manual.length > 0) {
257
- out('REVIEWER JUDGEMENT — decided by a person, not the engine')
258
- for (const c of manual) out(` · ${c.name}`)
259
- out('')
260
- }
261
- out(`Run "myelin check <dir> --dataset ${dsRef}" to test ${s.checkable_before_upload} of these locally.`)
262
- }
263
-
264
- async function cmdCheck() {
265
- const dir = args[1]
266
- const dsRef = opt('dataset')
267
- if (!dir || !dsRef) die('Usage: myelin check <dir> --dataset <slug|id>')
268
- const root = resolve(dir)
269
- const files = walkDir(root)
270
- if (files.length === 0) die(`No files under ${root}`)
271
- const dataset = await resolveDataset(dsRef)
272
-
273
- out(`Evaluating ${files.length} files (${fmtBytes(files.reduce((s, f) => s + f.size, 0))}) against quality checks v${dataset.quality_check_version ?? ''}…`)
274
- const j = expectOk(
275
- await api('POST', `/datasets/${dataset.id}/preflight`, {
276
- files: files.map((f) => ({ path: f.path, size_bytes: f.size })),
277
- }),
278
- 'preflight',
279
- )
280
- emit(j)
281
- if (!JSON_MODE) {
282
- for (const c of j.evaluated) {
283
- const mark = c.verdict === 'passed' ? '✓' : c.verdict === 'flagged' ? '⚠' : '✗'
284
- out(`${mark} ${c.check_type.padEnd(26)} ${c.verdict}${c.severity === 'blocking' && c.verdict === 'failed' ? ' — BLOCKING' : ''}`)
285
- if (c.verdict !== 'passed' && c.remediation) out(` hint: ${c.remediation}`)
286
- }
287
- for (const d of j.deferred) out(`… ${d.check_type.padEnd(26)} ${d.reason}`)
288
- for (const m of j.manual) out(`○ ${m.name.padEnd(26)} ${m.reason}`)
289
- }
290
- if (j.blocking_failures > 0) {
291
- out(`${j.blocking_failures} blocking issue(s). Fix before pushing to avoid a review round-trip.`)
292
- process.exit(2)
293
- }
294
- out('All manifest-evaluable checks pass.')
295
- }
296
-
297
- async function cmdPush() {
298
- const dir = args[1]
299
- const dsRef = opt('dataset')
300
- if (!dir || !dsRef) die('Usage: myelin push <dir> --dataset <slug|id> [--submit] [--replace]')
301
- const root = resolve(dir)
302
- const files = walkDir(root)
303
- if (files.length === 0) die(`No files under ${root}`)
304
- const dataset = await resolveDataset(dsRef)
305
-
306
- // 1. create or resume the draft
307
- const created = expectOk(await api('POST', `/datasets/${dataset.id}/batches`), 'create batch')
308
- const batchId = created.batch_id
309
- out(`Draft ${created.resumed ? 'resumed' : 'created'} (${batchId.slice(0, 8)}…).`)
310
-
311
- // 2. declare in chunks of 500 — idempotent, so re-running skips what's done
312
- const declared = []
313
- let upload = null
314
- for (let i = 0; i < files.length; i += 500) {
315
- const slice = files.slice(i, i + 500)
316
- const r = await api('POST', `/batches/${batchId}/files`, {
317
- files: slice.map((f) => ({ path: f.path, size_bytes: f.size })),
318
- replace: flag('replace'),
319
- })
320
- if (r.status !== 200 && r.status !== 409) expectOk(r, 'declare')
321
- const conflicts = r.json.files.filter((f) => f.error)
322
- if (conflicts.length > 0) {
323
- for (const c of conflicts) out(`✗ ${c.path}: ${c.error}`)
324
- die(`${conflicts.length} path conflict(s) — pass --replace to overwrite`, 2)
325
- }
326
- declared.push(...r.json.files)
327
- upload = r.json.upload
328
- }
329
- const partSize = upload?.part_size ?? 64 * 1024 * 1024
330
-
331
- // 3. upload the delta (anything not already 'uploaded') via S3 multipart,
332
- // 2 files in parallel (each file uploads its parts 4-wide internally)
333
- const byPath = new Map(files.map((f) => [f.path, f]))
334
- const pending = declared.filter((d) => d.upload_state !== 'uploaded')
335
- out(`Uploading ${pending.length}/${declared.length} files (${fmtBytes(pending.reduce((s, d) => s + (byPath.get(d.path)?.size ?? 0), 0))}) · resumable`)
336
- let done = 0
337
- const queue = [...pending]
338
- const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
339
- for (;;) {
340
- const d = queue.shift()
341
- if (!d) return
342
- const local = byPath.get(d.path)
343
- const { upload_id, parts } = await uploadFileMultipart(local.abs, local.size, d.file_id, partSize)
344
- const c = await api('POST', `/files/${d.file_id}/confirm`, { upload_id, parts })
345
- if (c.status !== 200) die(`confirm ${d.path}: [${c.json?.code}] ${c.json?.detail ?? c.status}`)
346
- done++
347
- out(` ${d.path} (${done}/${pending.length})`)
348
- }
349
- })
350
- await Promise.all(workers)
351
- out('All files confirmed.')
352
-
353
- // 4. optional submit
354
- if (flag('submit')) {
355
- const r = await api('POST', `/batches/${batchId}/submit`)
356
- if (r.status !== 200) {
357
- const code = r.json?.code ?? 'error'
358
- die(`submit: [${code}] ${r.json?.detail ?? r.status}`, code === 'submit_blocked' ? 2 : 1)
359
- }
360
- const a = r.json.auto_checks
361
- emit(r.json)
362
- out(`Submitting… auto-checks: ${a.passed} passed, ${a.flagged} flagged, ${a.failed} failed.`)
363
- out(`✓ Batch submitted for review. Track: myelin status ${batchId} --watch`)
364
- } else {
365
- emit({ batch_id: batchId, files: declared.length, submitted: false })
366
- out(`Draft ready (not submitted). Submit with: myelin push ${dir} --dataset ${dsRef} --submit`)
367
- }
368
- }
369
-
370
- async function cmdStatus() {
371
- const ref = args[1]
372
- if (!ref) die('Usage: myelin status <batch-id|display-name> [--watch]')
373
-
374
- const findBatch = async () => {
375
- if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
376
- const r = await api('GET', `/batches/${ref}`)
377
- if (r.status === 200) return r.json.batch
378
- die(`Batch ${ref} not found in this key's scope`)
379
- }
380
- const list = expectOk(await api('GET', '/batches?limit=100'), 'batches')
381
- const hit = list.batches.find((b) => b.display_name === ref)
382
- if (!hit) die(`No batch named "${ref}" in this key's scope`)
383
- return expectOk(await api('GET', `/batches/${hit.id}`), 'batch').batch
384
- }
385
-
386
- const print = async (batch) => {
387
- emit({ batch })
388
- out(`${batch.display_name}: ${batch.status} — ${batch.court === 'partner' ? 'your move.' : batch.court === 'reviewer' ? "reviewer's move." : batch.court === 'system' ? 'transferring…' : 'done.'}`)
389
- if (batch.status === 'changes_requested') {
390
- const f = expectOk(await api('GET', `/batches/${batch.id}/findings`), 'findings')
391
- if (f.request_changes_comment) out(` reviewer: "${f.request_changes_comment}"`)
392
- for (const fr of f.file_reviews.filter((x) => x.verdict === 'failed')) {
393
- out(` ✗ ${fr.path} (${fr.reviewer ?? 'reviewer'}: ${fr.comment ?? 'failed'})`)
394
- }
395
- for (const qf of f.quality_findings.filter((x) => x.verdict === 'failed')) {
396
- out(` ${qf.name ?? qf.rule_key}${qf.hint ? `: ${qf.hint}` : ''}`)
397
- }
398
- }
399
- return batch
400
- }
401
-
402
- let batch = await print(await findBatch())
403
- if (flag('watch')) {
404
- const terminal = ['transferred', 'rejected', 'changes_requested', 'transfer_failed']
405
- while (!terminal.includes(batch.status)) {
406
- await new Promise((r) => setTimeout(r, 20_000))
407
- const next = await findBatch()
408
- if (next.status !== batch.status) batch = await print(next)
409
- }
410
- }
411
- }
412
-
413
- async function cmdSandbox() {
414
- const file = args[1]
415
- if (!file) die('Usage: myelin sandbox <file>')
416
- const abs = resolve(file)
417
- const size = statSync(abs).size
418
- if (size > 4 * 1024 * 1024) die('Sandbox files should stay under ~4 MB — this verifies connectivity, not throughput.')
419
-
420
- const me = expectOk(await api('GET', '/me'), 'auth')
421
- const form = new FormData()
422
- form.set('file', new File([readFileSync(abs)], basename(abs)))
423
- const r = await api('POST', `/bridges/${me.bridge.id}/sandbox`, form, true)
424
- const j = expectOk(r, 'sandbox')
425
- emit(j)
426
- out(`✓ Sandbox upload succeeded — the partner-side test transfer checklist item is satisfied for bridge "${me.bridge.name}".`)
427
- }
428
-
429
- async function cmdSampleDepth() {
430
- const dsRef = opt('dataset')
431
- const raw = args[1]
432
- if (!dsRef || raw === undefined) {
433
- die('Usage: myelin sample-depth <0-5> --dataset <slug|id>')
434
- }
435
- const depth = Number(raw)
436
- if (!Number.isInteger(depth) || depth < 0 || depth > 5) {
437
- die('sample-depth must be an integer between 0 and 5')
438
- }
439
- const dataset = await resolveDataset(dsRef)
440
- const j = expectOk(
441
- await api('POST', `/datasets/${dataset.id}/sample-depth`, { sample_depth: depth }),
442
- 'sample-depth',
443
- )
444
- emit(j)
445
- if (!JSON_MODE) {
446
- out(
447
- j.changed
448
- ? `Sample depth for ${dataset.name} set to ${j.sample_depth}.`
449
- : `Sample depth for ${dataset.name} already ${j.sample_depth} — nothing to change.`,
450
- )
451
- }
452
- }
453
-
454
- function cmdHelp() {
455
- console.log(`myelin Partner Ingestion CLI
456
-
457
- Setup:
458
- export MYELIN_API_KEY=myl_live_… (create in Bridge → API)
459
- export MYELIN_API_URL=… (optional; default https://myelinbridge.com/api/v1)
460
-
461
- Commands:
462
- ping verify the key; show bridge + projects
463
- projects list scoped projects
464
- datasets list datasets with "your move" hints
465
- sample-depth <0-5> --dataset <slug|id> declare the folder depth a sample sits at (locks after 1st submit)
466
- contract --dataset <slug|id> what this dataset expects of your delivery
467
- check <dir> --dataset <slug|id> preflight local files against quality rules (no upload)
468
- push <dir> --dataset <slug|id> create/resume a delivery and upload (resumable; re-run to resume)
469
- [--submit] [--replace]
470
- status <batch|name> [--watch] review status + fix-loop findings
471
- sandbox <file> partner-side test transfer (bridge activation)
472
-
473
- Every command accepts --json. Exit codes: 0 ok · 1 error · 2 blocked.`)
474
- }
475
-
476
- // ——— main ———
477
- if (!command || command === 'help' || command === '--help') {
478
- cmdHelp()
479
- process.exit(0)
480
- }
481
- if (!KEY) die('Set MYELIN_API_KEY (create a key in Bridge → API).')
482
-
483
- const commands = {
484
- ping: cmdPing,
485
- projects: cmdProjects,
486
- datasets: cmdDatasets,
487
- 'sample-depth': cmdSampleDepth,
488
- contract: cmdContract,
489
- check: cmdCheck,
490
- push: cmdPush,
491
- status: cmdStatus,
492
- sandbox: cmdSandbox,
493
- }
494
- const fn = commands[command]
495
- if (!fn) die(`Unknown command "${command}" — run: myelin help`)
496
- await fn()
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
+
15
+ const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
16
+ const KEY = process.env.MYELIN_API_KEY
17
+
18
+ // Extra headers sent with every API call — newline-separated "Name: value"
19
+ // pairs in MYELIN_API_HEADER. For deployments fronted by something that
20
+ // authenticates before Myelin does (a corporate gateway, an SSO-protected
21
+ // preview environment). Never overrides Authorization.
22
+ const EXTRA_HEADERS = Object.fromEntries(
23
+ (process.env.MYELIN_API_HEADER ?? '')
24
+ .split('\n')
25
+ .map((line) => line.trim())
26
+ .filter(Boolean)
27
+ .map((line) => {
28
+ const i = line.indexOf(':')
29
+ if (i < 1) return null
30
+ return [line.slice(0, i).trim(), line.slice(i + 1).trim()]
31
+ })
32
+ .filter(Boolean),
33
+ )
34
+
35
+ const argv = process.argv.slice(2)
36
+ const JSON_MODE = argv.includes('--json')
37
+ const args = argv.filter((a) => a !== '--json')
38
+ const command = args[0]
39
+
40
+ const out = (line) => { if (!JSON_MODE) console.log(line) }
41
+ const emit = (obj) => { if (JSON_MODE) console.log(JSON.stringify(obj, null, 2)) }
42
+
43
+ /** Thrown by die() to unwind to main; never reaches the user. */
44
+ class CliExit extends Error {}
45
+
46
+ // die() sets the exit code and unwinds rather than calling process.exit():
47
+ // killing the process while an HTTP socket is still closing trips a libuv
48
+ // assertion on Windows, which printed a C stack trace after a perfectly good
49
+ // error message and replaced the documented exit code with 127. Node exits on
50
+ // 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}`)
54
+ process.exitCode = code
55
+ throw new CliExit(message)
56
+ }
57
+
58
+ // Column widths come from the content. padEnd() alone silently ran a long
59
+ // value into the next column — a 28-character dataset slug swallowed the
60
+ // STATUS header's gutter. Pass headers = null for an unheadered list.
61
+ function table(headers, rows) {
62
+ const cols = headers?.length ?? rows[0]?.length ?? 0
63
+ const widths = Array.from({ length: cols }, (_, i) =>
64
+ Math.max(headers?.[i]?.length ?? 0, 0, ...rows.map((r) => String(r[i] ?? '').length)),
65
+ )
66
+ const line = (cells) =>
67
+ cells
68
+ .map((c, i) => (i === cols - 1 ? String(c ?? '') : String(c ?? '').padEnd(widths[i])))
69
+ .join(' ')
70
+ .trimEnd()
71
+ return headers ? [line(headers), ...rows.map(line)] : rows.map(line)
72
+ }
73
+
74
+ function flag(name) {
75
+ return args.includes(`--${name}`)
76
+ }
77
+ function opt(name) {
78
+ const i = args.indexOf(`--${name}`)
79
+ return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : null
80
+ }
81
+
82
+ 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
+ }
96
+
97
+ function expectOk(r, context) {
98
+ if (r.status >= 200 && r.status < 300) return r.json
99
+ const detail = r.json?.detail ?? `HTTP ${r.status}`
100
+ 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)
103
+ }
104
+
105
+ // ——— dataset resolution (id or slug) ———
106
+ async function resolveDataset(ref) {
107
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
108
+ const r = await api('GET', `/datasets/${ref}`)
109
+ if (r.status === 200) return r.json.dataset
110
+ die(`Dataset ${ref} not found in this key's scope`)
111
+ }
112
+ const me = expectOk(await api('GET', '/me'), 'auth')
113
+ for (const p of me.projects) {
114
+ const r = expectOk(await api('GET', `/projects/${p.id}/datasets`), 'datasets')
115
+ const hit = r.datasets.find((d) => d.slug === ref || d.name === ref)
116
+ if (hit) return hit
117
+ }
118
+ die(`No dataset with slug or name "${ref}" in this key's scope`)
119
+ }
120
+
121
+ // ——— local manifest ———
122
+ function walkDir(root) {
123
+ const files = []
124
+ const walk = (dir) => {
125
+ for (const entry of readdirSync(dir)) {
126
+ const full = join(dir, entry)
127
+ const st = statSync(full)
128
+ if (st.isDirectory()) walk(full)
129
+ else files.push({ abs: full, path: '/' + relative(root, full).split(sep).join('/'), size: st.size })
130
+ }
131
+ }
132
+ walk(root)
133
+ return files
134
+ }
135
+
136
+ function fmtBytes(n) {
137
+ if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB'
138
+ if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'
139
+ if (n >= 1024) return (n / 1024).toFixed(1) + ' KB'
140
+ return n + ' B'
141
+ }
142
+
143
+ // Upload a file via S3 presigned multipart (SEC-TOKEN-01: no bearer token ever
144
+ // reaches the client — declare tells us the part size, and each part gets a
145
+ // short-lived presigned PUT URL scoped to exactly that one part of one object).
146
+ // Resumable: the sign step reports parts that already landed, so a re-run skips
147
+ // them. Returns { upload_id, parts: [{part_number, etag}] } for confirm.
148
+ const SIGN_WINDOW = 1000 // the server's max_parts_per_sign
149
+
150
+ async function readPart(absPath, start, end) {
151
+ const chunks = []
152
+ await new Promise((res, rej) => {
153
+ const s = createReadStream(absPath, { start, end }) // end inclusive
154
+ s.on('data', (c) => chunks.push(c))
155
+ s.on('end', res)
156
+ s.on('error', rej)
157
+ })
158
+ return Buffer.concat(chunks)
159
+ }
160
+
161
+ async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone) {
162
+ const numParts = Math.max(1, Math.ceil(size / partSize))
163
+ const allParts = Array.from({ length: numParts }, (_, i) => i + 1)
164
+ const etags = new Map() // part_number -> etag
165
+ let uploadId = null
166
+
167
+ for (let i = 0; i < allParts.length; i += SIGN_WINDOW) {
168
+ const window = allParts.slice(i, i + SIGN_WINDOW)
169
+ const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: window }), 'sign upload parts')
170
+ uploadId = j.upload_id
171
+ for (const p of j.uploaded_parts) etags.set(p.part_number, p.etag) // already landed skip
172
+ const urlByPart = new Map(j.urls.map((u) => [u.part_number, u.url]))
173
+
174
+ // 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
+ const missing = window.filter((n) => !etags.has(n))
178
+ const queue = [...missing]
179
+ const resign = async (partNo) => {
180
+ const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: [partNo] }), 're-sign upload part')
181
+ const fresh = j.urls.find((u) => u.part_number === partNo)?.url
182
+ if (!fresh) die(`upload part ${partNo}: could not re-sign (already finalized?)`)
183
+ urlByPart.set(partNo, fresh)
184
+ return fresh
185
+ }
186
+ const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
187
+ for (;;) {
188
+ const partNo = queue.shift()
189
+ if (partNo === undefined) return
190
+ const start = (partNo - 1) * partSize
191
+ const end = Math.min(start + partSize, size) - 1
192
+ 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}`))
196
+ }
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
+ if (onPartDone) onPartDone()
202
+ }
203
+ })
204
+ await Promise.all(workers)
205
+ }
206
+
207
+ return { upload_id: uploadId, parts: allParts.map((n) => ({ part_number: n, etag: etags.get(n) })) }
208
+ }
209
+
210
+ // ——— commands ———
211
+
212
+ async function cmdPing() {
213
+ const j = expectOk(await api('GET', '/ping'), 'ping')
214
+ emit(j)
215
+ out(`✓ key "${j.key.name}" (${j.key.prefix}…) · bridge ${j.bridge.name} · ${j.projects.length} project(s)`)
216
+ }
217
+
218
+ async function cmdProjects() {
219
+ const j = expectOk(await api('GET', '/projects'), 'projects')
220
+ emit(j)
221
+ for (const line of table(null, j.projects.map((p) => [p.code, p.status, p.title]))) out(line)
222
+ }
223
+
224
+ async function cmdDatasets() {
225
+ const me = expectOk(await api('GET', '/me'), 'auth')
226
+ const rows = []
227
+ for (const p of me.projects) {
228
+ const r = expectOk(await api('GET', `/projects/${p.id}/datasets`), 'datasets')
229
+ for (const d of r.datasets) {
230
+ rows.push({
231
+ project: p.code,
232
+ dataset: d.slug,
233
+ status: d.lifecycle_status,
234
+ quality_version: d.quality_check_version,
235
+ open_draft: d.open_draft_batch_id,
236
+ id: d.id,
237
+ })
238
+ }
239
+ }
240
+ emit({ datasets: rows })
241
+ if (!JSON_MODE) {
242
+ const lines = table(
243
+ ['PROJECT', 'DATASET', 'STATUS', 'YOUR MOVE?'],
244
+ rows.map((r) => [r.project, r.dataset, r.status, r.open_draft ? 'yes — open draft to finish' : '—']),
245
+ )
246
+ for (const line of lines) out(line)
247
+ }
248
+ }
249
+
250
+ // The delivery contract, before you deliver anything. Answers "what is expected
251
+ // of me?" which used to be discoverable only by submitting and being rejected.
252
+ async function cmdContract() {
253
+ const dsRef = opt('dataset')
254
+ if (!dsRef) die('Usage: myelin contract --dataset <slug|id>')
255
+ const dataset = await resolveDataset(dsRef)
256
+ const j = expectOk(
257
+ await api('GET', `/datasets/${dataset.id}/quality-checks`),
258
+ 'quality-checks',
259
+ )
260
+ emit(j)
261
+ if (JSON_MODE) return
262
+
263
+ if (!j.version) {
264
+ out(`${dataset.name}: no quality contract published yet — nothing is enforced.`)
265
+ return
266
+ }
267
+
268
+ const s = j.summary
269
+ out(`${dataset.name} delivery contract v${j.version}`)
270
+ out(
271
+ `${s.total} checks · ${s.blocking} block validation · ` +
272
+ `${s.checkable_before_upload} checkable before upload` +
273
+ (s.needs_reviewer ? ` · ${s.needs_reviewer} reviewed by a person` : ''),
274
+ )
275
+ out('')
276
+
277
+ // Grouped by dimension so the contract reads as questions, not a flat list.
278
+ const byDim = new Map()
279
+ for (const c of j.checks) {
280
+ const key = c.dimension ?? 'review'
281
+ if (!byDim.has(key)) byDim.set(key, [])
282
+ byDim.get(key).push(c)
283
+ }
284
+ for (const d of s.dimensions) {
285
+ const items = byDim.get(d.key) ?? []
286
+ if (items.length === 0) continue
287
+ out(`${d.label.toUpperCase()} ${d.question}`)
288
+ for (const c of items) {
289
+ const gate = c.severity === 'blocking' ? 'must' : 'should'
290
+ const when = c.runs_at === 'preflight' ? '' : ' (checked at submission)'
291
+ out(` ${gate === 'must' ? '!' : '·'} ${c.assertion ?? c.name}${when}`)
292
+ }
293
+ out('')
294
+ }
295
+ const manual = byDim.get('review') ?? []
296
+ if (manual.length > 0) {
297
+ out('REVIEWER JUDGEMENT — decided by a person, not the engine')
298
+ for (const c of manual) out(` · ${c.name}`)
299
+ out('')
300
+ }
301
+ out(`Run "myelin check <dir> --dataset ${dsRef}" to test ${s.checkable_before_upload} of these locally.`)
302
+ }
303
+
304
+ async function cmdCheck() {
305
+ const dir = args[1]
306
+ const dsRef = opt('dataset')
307
+ if (!dir || !dsRef) die('Usage: myelin check <dir> --dataset <slug|id>')
308
+ const root = resolve(dir)
309
+ const files = walkDir(root)
310
+ if (files.length === 0) die(`No files under ${root}`)
311
+ const dataset = await resolveDataset(dsRef)
312
+
313
+ out(`Evaluating ${files.length} files (${fmtBytes(files.reduce((s, f) => s + f.size, 0))}) against quality checks v${dataset.quality_check_version ?? '—'}…`)
314
+ const j = expectOk(
315
+ await api('POST', `/datasets/${dataset.id}/preflight`, {
316
+ files: files.map((f) => ({ path: f.path, size_bytes: f.size })),
317
+ }),
318
+ 'preflight',
319
+ )
320
+ emit(j)
321
+ if (!JSON_MODE) {
322
+ // One width across all three lists so the verdicts line up, and the hint
323
+ // lines can still be interleaved under the check they belong to.
324
+ const w = Math.max(
325
+ 0,
326
+ ...j.evaluated.map((c) => c.check_type.length),
327
+ ...j.deferred.map((d) => d.check_type.length),
328
+ ...j.manual.map((m) => m.name.length),
329
+ )
330
+ for (const c of j.evaluated) {
331
+ const mark = c.verdict === 'passed' ? '✓' : c.verdict === 'flagged' ? '⚠' : '✗'
332
+ out(`${mark} ${c.check_type.padEnd(w)} ${c.verdict}${c.severity === 'blocking' && c.verdict === 'failed' ? ' BLOCKING' : ''}`)
333
+ if (c.verdict !== 'passed' && c.remediation) out(` hint: ${c.remediation}`)
334
+ }
335
+ for (const d of j.deferred) out(`… ${d.check_type.padEnd(w)} ${d.reason}`)
336
+ for (const m of j.manual) out(`○ ${m.name.padEnd(w)} ${m.reason}`)
337
+ }
338
+ if (j.blocking_failures > 0) {
339
+ out(`${j.blocking_failures} blocking issue(s). Fix before pushing to avoid a review round-trip.`)
340
+ process.exitCode = 2
341
+ return
342
+ }
343
+ out('All checks that run before upload pass.')
344
+ }
345
+
346
+ async function cmdPush() {
347
+ const dir = args[1]
348
+ const dsRef = opt('dataset')
349
+ if (!dir || !dsRef) die('Usage: myelin push <dir> --dataset <slug|id> [--submit] [--replace]')
350
+ const root = resolve(dir)
351
+ const files = walkDir(root)
352
+ if (files.length === 0) die(`No files under ${root}`)
353
+ const dataset = await resolveDataset(dsRef)
354
+
355
+ // 1. create or resume the draft
356
+ const created = expectOk(await api('POST', `/datasets/${dataset.id}/batches`), 'create batch')
357
+ const batchId = created.batch_id
358
+ out(`Draft ${created.resumed ? 'resumed' : 'created'} (${batchId.slice(0, 8)}…).`)
359
+
360
+ // 2. declare in chunks of 500 — idempotent, so re-running skips what's done
361
+ const declared = []
362
+ let upload = null
363
+ for (let i = 0; i < files.length; i += 500) {
364
+ const slice = files.slice(i, i + 500)
365
+ const r = await api('POST', `/batches/${batchId}/files`, {
366
+ files: slice.map((f) => ({ path: f.path, size_bytes: f.size })),
367
+ replace: flag('replace'),
368
+ })
369
+ if (r.status !== 200 && r.status !== 409) expectOk(r, 'declare')
370
+ const conflicts = r.json.files.filter((f) => f.error)
371
+ if (conflicts.length > 0) {
372
+ for (const c of conflicts) out(`✗ ${c.path}: ${c.error}`)
373
+ die(`${conflicts.length} path conflict(s) — pass --replace to overwrite`, 2)
374
+ }
375
+ declared.push(...r.json.files)
376
+ upload = r.json.upload
377
+ }
378
+ const partSize = upload?.part_size ?? 64 * 1024 * 1024
379
+
380
+ // 3. upload the delta (anything not already 'uploaded') via S3 multipart,
381
+ // 2 files in parallel (each file uploads its parts 4-wide internally)
382
+ const byPath = new Map(files.map((f) => [f.path, f]))
383
+ const pending = declared.filter((d) => d.upload_state !== 'uploaded')
384
+ out(`Uploading ${pending.length}/${declared.length} files (${fmtBytes(pending.reduce((s, d) => s + (byPath.get(d.path)?.size ?? 0), 0))}) · resumable`)
385
+ let done = 0
386
+ const queue = [...pending]
387
+ const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
388
+ for (;;) {
389
+ const d = queue.shift()
390
+ if (!d) return
391
+ 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}`)
395
+ done++
396
+ out(` ${d.path} (${done}/${pending.length})`)
397
+ }
398
+ })
399
+ await Promise.all(workers)
400
+ out('All files confirmed.')
401
+
402
+ // 4. optional submit
403
+ 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)
408
+ }
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
+ } else {
414
+ emit({ batch_id: batchId, files: declared.length, submitted: false })
415
+ out(`Draft ready (not submitted). Submit with: myelin push ${dir} --dataset ${dsRef} --submit`)
416
+ }
417
+ }
418
+
419
+ async function cmdStatus() {
420
+ const ref = args[1]
421
+ if (!ref) die('Usage: myelin status <batch-id|display-name> [--watch]')
422
+
423
+ const findBatch = async () => {
424
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
425
+ const r = await api('GET', `/batches/${ref}`)
426
+ if (r.status === 200) return r.json.batch
427
+ die(`Batch ${ref} not found in this key's scope`)
428
+ }
429
+ const list = expectOk(await api('GET', '/batches?limit=100'), 'batches')
430
+ const hit = list.batches.find((b) => b.display_name === ref)
431
+ if (!hit) die(`No batch named "${ref}" in this key's scope`)
432
+ return expectOk(await api('GET', `/batches/${hit.id}`), 'batch').batch
433
+ }
434
+
435
+ const print = async (batch) => {
436
+ emit({ batch })
437
+ out(`${batch.display_name}: ${batch.status} — ${batch.court === 'partner' ? 'your move.' : batch.court === 'reviewer' ? "reviewer's move." : batch.court === 'system' ? 'transferring…' : 'done.'}`)
438
+ if (batch.status === 'changes_requested') {
439
+ const f = expectOk(await api('GET', `/batches/${batch.id}/findings`), 'findings')
440
+ if (f.request_changes_comment) out(` reviewer: "${f.request_changes_comment}"`)
441
+ for (const fr of f.file_reviews.filter((x) => x.verdict === 'failed')) {
442
+ out(` ✗ ${fr.path} (${fr.reviewer ?? 'reviewer'}: ${fr.comment ?? 'failed'})`)
443
+ }
444
+ for (const qf of f.quality_findings.filter((x) => x.verdict === 'failed')) {
445
+ out(` ${qf.name ?? qf.rule_key}${qf.hint ? `: ${qf.hint}` : ''}`)
446
+ }
447
+ }
448
+ return batch
449
+ }
450
+
451
+ let batch = await print(await findBatch())
452
+ if (flag('watch')) {
453
+ const terminal = ['transferred', 'rejected', 'changes_requested', 'transfer_failed']
454
+ while (!terminal.includes(batch.status)) {
455
+ await new Promise((r) => setTimeout(r, 20_000))
456
+ const next = await findBatch()
457
+ if (next.status !== batch.status) batch = await print(next)
458
+ }
459
+ }
460
+ }
461
+
462
+ async function cmdSandbox() {
463
+ const file = args[1]
464
+ if (!file) die('Usage: myelin sandbox <file>')
465
+ const abs = resolve(file)
466
+ const size = statSync(abs).size
467
+ if (size > 4 * 1024 * 1024) die('Sandbox files should stay under ~4 MB — this verifies connectivity, not throughput.')
468
+
469
+ const me = expectOk(await api('GET', '/me'), 'auth')
470
+ const form = new FormData()
471
+ form.set('file', new File([readFileSync(abs)], basename(abs)))
472
+ const r = await api('POST', `/bridges/${me.bridge.id}/sandbox`, form, true)
473
+ const j = expectOk(r, 'sandbox')
474
+ emit(j)
475
+ out(`✓ Sandbox upload succeeded — the partner-side test transfer checklist item is satisfied for bridge "${me.bridge.name}".`)
476
+ }
477
+
478
+ async function cmdSampleDepth() {
479
+ const dsRef = opt('dataset')
480
+ const raw = args[1]
481
+ if (!dsRef || raw === undefined) {
482
+ die('Usage: myelin sample-depth <0-5> --dataset <slug|id>')
483
+ }
484
+ const depth = Number(raw)
485
+ if (!Number.isInteger(depth) || depth < 0 || depth > 5) {
486
+ die('sample-depth must be an integer between 0 and 5')
487
+ }
488
+ const dataset = await resolveDataset(dsRef)
489
+ const j = expectOk(
490
+ await api('POST', `/datasets/${dataset.id}/sample-depth`, { sample_depth: depth }),
491
+ 'sample-depth',
492
+ )
493
+ emit(j)
494
+ if (!JSON_MODE) {
495
+ out(
496
+ j.changed
497
+ ? `Sample depth for ${dataset.name} set to ${j.sample_depth}.`
498
+ : `Sample depth for ${dataset.name} already ${j.sample_depth} — nothing to change.`,
499
+ )
500
+ }
501
+ }
502
+
503
+ function cmdHelp() {
504
+ console.log(`myelin — Partner Ingestion CLI
505
+
506
+ Setup:
507
+ export MYELIN_API_KEY=myl_live_… (create in Bridge → API)
508
+ export MYELIN_API_URL=… (optional; default https://myelinbridge.com/api/v1)
509
+ export MYELIN_API_HEADER="Name: value" (optional; extra headers, one per line —
510
+ for a gateway that authenticates ahead of Myelin)
511
+
512
+ Commands:
513
+ ping verify the key; show bridge + projects
514
+ projects list scoped projects
515
+ datasets list datasets with "your move" hints
516
+ sample-depth <0-5> --dataset <slug|id> declare the folder depth a sample sits at (locks after 1st submit)
517
+ contract --dataset <slug|id> what this dataset expects of your delivery
518
+ check <dir> --dataset <slug|id> preflight local files against quality rules (no upload)
519
+ push <dir> --dataset <slug|id> create/resume a delivery and upload (resumable; re-run to resume)
520
+ [--submit] [--replace]
521
+ status <batch|name> [--watch] review status + fix-loop findings
522
+ sandbox <file> partner-side test transfer (bridge activation)
523
+
524
+ Client-side commands (need a CLIENT key — Organisation → API keys):
525
+ deliveries [--project <id>] what landed in your destination bucket
526
+ [--dataset <id>] [--since <iso>] [--limit <n>]
527
+ resolve <path|prefix|id> what a path in your bucket actually is —
528
+ project, dataset, batch, file
529
+
530
+ Every command accepts --json. Exit codes: 0 ok · 1 error · 2 blocked.`)
531
+ }
532
+
533
+ // ——— client-side commands (client keys only) ———
534
+ //
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.
538
+
539
+ async function cmdDeliveries() {
540
+ const qs = new URLSearchParams()
541
+ const map = { project: 'project_id', dataset: 'dataset_id', since: 'since', limit: 'limit' }
542
+ for (const [flagName, param] of Object.entries(map)) {
543
+ const v = opt(flagName)
544
+ if (v) qs.set(param, v)
545
+ }
546
+ const query = qs.toString()
547
+ const j = expectOk(await api('GET', `/deliveries${query ? `?${query}` : ''}`), 'deliveries')
548
+ emit(j)
549
+ if (j.deliveries.length === 0) {
550
+ out('No deliveries yet in this key’s scope.')
551
+ return
552
+ }
553
+ const rows = j.deliveries.map((d) => [
554
+ d.project.code ?? d.project.id.slice(0, 8),
555
+ d.dataset.slug ?? d.dataset.id.slice(0, 8),
556
+ d.batch_name ?? '—',
557
+ String(d.file_count),
558
+ (d.transferred_at ?? '').slice(0, 10),
559
+ d.destination_root ?? '—',
560
+ ])
561
+ 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}`)
563
+ }
564
+
565
+ async function cmdResolve() {
566
+ const path = args[1]
567
+ if (!path || path.startsWith('--')) die('Usage: myelin resolve <path|prefix|id>')
568
+ const j = expectOk(await api('GET', `/deliveries/resolve?path=${encodeURIComponent(path)}`), 'resolve')
569
+ emit(j)
570
+ out(`${j.resolved} · ${j.query}`)
571
+ if (j.project) out(` project ${j.project.code} — ${j.project.title}`)
572
+ if (j.dataset) out(` dataset ${j.dataset.name}${j.dataset.label ? ` (${j.dataset.label})` : ''}`)
573
+ if (j.delivery) {
574
+ out(` batch ${j.delivery.batch_name ?? j.delivery.batch_id} · #${j.delivery.sequence_number ?? '?'}`)
575
+ out(` validated ${j.delivery.validated_at ?? '—'} · delivered ${j.delivery.transferred_at ?? '—'}`)
576
+ if (j.delivery.manifest_path) out(` manifest ${j.delivery.manifest_path}`)
577
+ }
578
+ if (j.file) {
579
+ out(
580
+ ` file ${j.file.filename} · ${j.file.size_bytes} bytes` +
581
+ (j.file.declared_checksum
582
+ ? ` · ${j.file.declared_checksum_algorithm}:${j.file.declared_checksum}`
583
+ : ''),
584
+ )
585
+ }
586
+ }
587
+
588
+ // ——— main ———
589
+ if (!command || command === 'help' || command === '--help') {
590
+ cmdHelp()
591
+ process.exit(0)
592
+ }
593
+
594
+ const commands = {
595
+ ping: cmdPing,
596
+ projects: cmdProjects,
597
+ datasets: cmdDatasets,
598
+ 'sample-depth': cmdSampleDepth,
599
+ contract: cmdContract,
600
+ check: cmdCheck,
601
+ push: cmdPush,
602
+ status: cmdStatus,
603
+ sandbox: cmdSandbox,
604
+ deliveries: cmdDeliveries,
605
+ resolve: cmdResolve,
606
+ }
607
+ try {
608
+ if (!KEY) die('Set MYELIN_API_KEY (create a key in Bridge → API).')
609
+ const fn = commands[command]
610
+ if (!fn) die(`Unknown command "${command}" — run: myelin help`)
611
+ await fn()
612
+ } catch (err) {
613
+ // die() already printed and set the code; anything else is a real bug and
614
+ // deserves its stack.
615
+ if (!(err instanceof CliExit)) throw err
616
+ }