@myelinbridge/cli 0.12.2 → 0.14.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 +352 -168
- package/bin/myelin.js +776 -61
- package/package.json +1 -1
package/bin/myelin.js
CHANGED
|
@@ -40,10 +40,27 @@
|
|
|
40
40
|
// for), even though `check` correctly warned about it via preflight. The API
|
|
41
41
|
// now includes required-but-empty fields in `metadata` too
|
|
42
42
|
// (`required: true`, `value: null`), and `contract` renders them.
|
|
43
|
+
//
|
|
44
|
+
// 0.13.0 — the CLI reaches the network the way a corporate pipeline host
|
|
45
|
+
// actually reaches it. HTTPS_PROXY / HTTP_PROXY / NO_PROXY (either case) are
|
|
46
|
+
// honoured on every call, API and part upload alike; before this, Node's
|
|
47
|
+
// global fetch ignored all three and a partner whose only egress is a proxy
|
|
48
|
+
// could not deliver at all — the failure read as Myelin being down. The
|
|
49
|
+
// transport is now ours (node:http/node:https, still zero dependencies), so
|
|
50
|
+
// there is one path whether or not a proxy is in the picture. Two smaller
|
|
51
|
+
// consequences: a part upload that cannot reach storage now names the host it
|
|
52
|
+
// could not reach (never the presigned URL — its query string is the
|
|
53
|
+
// credential), and `ping` prints the proxy it used. The hosts and ports a
|
|
54
|
+
// partner's firewall must allow are written down for the first time, in the
|
|
55
|
+
// README's "For your IT department" section.
|
|
43
56
|
|
|
44
57
|
import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
|
|
45
58
|
import { resolve, join, relative, sep, basename } from 'node:path'
|
|
46
59
|
import { createHash } from 'node:crypto'
|
|
60
|
+
import http from 'node:http'
|
|
61
|
+
import https from 'node:https'
|
|
62
|
+
import tls from 'node:tls'
|
|
63
|
+
import zlib from 'node:zlib'
|
|
47
64
|
import process from 'node:process'
|
|
48
65
|
|
|
49
66
|
const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
|
|
@@ -154,13 +171,404 @@ function opt(name) {
|
|
|
154
171
|
return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : null
|
|
155
172
|
}
|
|
156
173
|
|
|
174
|
+
// ── Egress: the corporate network this CLI actually runs on ────────────────
|
|
175
|
+
//
|
|
176
|
+
// A CRO's pipeline host usually has no route to the internet of its own. Its
|
|
177
|
+
// only way out is an HTTP CONNECT proxy named by HTTPS_PROXY / HTTP_PROXY,
|
|
178
|
+
// with NO_PROXY listing what must stay inside. Node's global `fetch` ignores
|
|
179
|
+
// all three — Node 22+ honours them only if NODE_USE_ENV_PROXY=1 was set
|
|
180
|
+
// before the process started, Node 20 not at all — so until 0.13.0 every
|
|
181
|
+
// command dialled straight out and died with `fetch failed`. A partner whose
|
|
182
|
+
// only egress is a proxy could not deliver a single byte, and it read as
|
|
183
|
+
// Myelin being down rather than as a variable nobody had honoured.
|
|
184
|
+
//
|
|
185
|
+
// So the CLI speaks the proxy protocol itself, over node:http / node:https,
|
|
186
|
+
// and adds no dependency to do it: this is a binary a customer's IT
|
|
187
|
+
// department installs, and an empty `dependencies` is worth more than the
|
|
188
|
+
// ~200 lines below. It is also the only fix that reaches every Node in
|
|
189
|
+
// `engines` — the undici workaround needs a NODE_OPTIONS preload and a
|
|
190
|
+
// package we would have to ship.
|
|
191
|
+
//
|
|
192
|
+
// One transport for every call, proxied or not. Keeping `fetch` for the
|
|
193
|
+
// direct case would have left the proxy path running only at customer sites,
|
|
194
|
+
// which is the definition of a path that rots.
|
|
195
|
+
//
|
|
196
|
+
// ⚠ Every request carries an explicit agent, always. Node ≥24.5 can install a
|
|
197
|
+
// proxy-aware global agent of its own; if it ever did so under us, a request
|
|
198
|
+
// we had already tunnelled would be tunnelled twice. Our own agent means one
|
|
199
|
+
// behaviour on every Node we support.
|
|
200
|
+
|
|
201
|
+
const UA = `myelin-cli/${VERSION} (node ${process.versions.node})`
|
|
202
|
+
|
|
203
|
+
// Lowercase first — curl's precedence, and the spelling most pipelines set.
|
|
204
|
+
// Both are read because both are in the wild: on Windows they are the same
|
|
205
|
+
// variable, on Linux they are not, and which spelling we happened to prefer
|
|
206
|
+
// must never become the partner's problem.
|
|
207
|
+
const envPair = (...names) => {
|
|
208
|
+
for (const name of names) {
|
|
209
|
+
const value = process.env[name]
|
|
210
|
+
if (typeof value === 'string' && value.trim()) return { name, value: value.trim() }
|
|
211
|
+
}
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
const httpsProxyEnv = () => envPair('https_proxy', 'HTTPS_PROXY')
|
|
215
|
+
const httpProxyEnv = () => envPair('http_proxy', 'HTTP_PROXY')
|
|
216
|
+
const noProxyEnv = () => envPair('no_proxy', 'NO_PROXY')
|
|
217
|
+
const anyProxyEnv = () => Boolean(httpsProxyEnv() || httpProxyEnv())
|
|
218
|
+
|
|
219
|
+
const ipv4ToInt = (ip) => {
|
|
220
|
+
const parts = ip.split('.')
|
|
221
|
+
if (parts.length !== 4) return null
|
|
222
|
+
let n = 0
|
|
223
|
+
for (const part of parts) {
|
|
224
|
+
if (!/^\d{1,3}$/.test(part)) return null
|
|
225
|
+
const v = Number(part)
|
|
226
|
+
if (v > 255) return null
|
|
227
|
+
n = n * 256 + v
|
|
228
|
+
}
|
|
229
|
+
return n >>> 0
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const cidrContains = (host, entry) => {
|
|
233
|
+
const [network, bitsRaw] = entry.split('/')
|
|
234
|
+
const bits = Number(bitsRaw)
|
|
235
|
+
const a = ipv4ToInt(host)
|
|
236
|
+
const b = ipv4ToInt(network)
|
|
237
|
+
if (a === null || b === null || !Number.isInteger(bits) || bits < 0 || bits > 32) return false
|
|
238
|
+
if (bits === 0) return true
|
|
239
|
+
const mask = (0xffffffff << (32 - bits)) >>> 0
|
|
240
|
+
return ((a & mask) >>> 0) === ((b & mask) >>> 0)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// NO_PROXY entries are hostnames, suffixes (`.corp.example`, `*.corp.example`),
|
|
244
|
+
// `host:port`, an IPv4 CIDR, or `*` for everything. The CIDR form is here
|
|
245
|
+
// because a corporate NO_PROXY nearly always carries one (10.0.0.0/8), and
|
|
246
|
+
// tunnelling an internal host fails exactly as hard as not tunnelling an
|
|
247
|
+
// external one.
|
|
248
|
+
function noProxyMatches(hostname, port) {
|
|
249
|
+
const raw = noProxyEnv()?.value
|
|
250
|
+
if (!raw) return false
|
|
251
|
+
const host = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '')
|
|
252
|
+
for (const token of raw.split(/[,\s]+/)) {
|
|
253
|
+
if (!token) continue
|
|
254
|
+
if (token === '*') return true
|
|
255
|
+
let entry = token.toLowerCase()
|
|
256
|
+
// `host:port` — but never split a bare IPv6 literal on its own colons.
|
|
257
|
+
const withPort = /^(.+):(\d+)$/.exec(entry)
|
|
258
|
+
if (withPort && !withPort[1].includes(':')) {
|
|
259
|
+
if (withPort[2] !== String(port)) continue
|
|
260
|
+
entry = withPort[1]
|
|
261
|
+
}
|
|
262
|
+
if (entry.includes('/')) {
|
|
263
|
+
if (cidrContains(host, entry)) return true
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
entry = entry.replace(/^\*?\./, '')
|
|
267
|
+
if (!entry) continue
|
|
268
|
+
if (host === entry || host.endsWith(`.${entry}`)) return true
|
|
269
|
+
}
|
|
270
|
+
return false
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// A proxy variable we cannot parse is refused out loud. Ignoring it would put
|
|
274
|
+
// us back exactly where this release started: a CLI dialling direct while the
|
|
275
|
+
// partner believes their proxy is carrying the traffic.
|
|
276
|
+
//
|
|
277
|
+
// It throws rather than die()s. die() prints, and this runs inside the request
|
|
278
|
+
// path AND inside egressHint() — so a bad variable printed its message three
|
|
279
|
+
// times before the error it caused. One config problem, one line: the throw is
|
|
280
|
+
// rendered once by whoever was making the call, and checked up front by
|
|
281
|
+
// assertProxyEnvUsable() before any command runs.
|
|
282
|
+
const proxyCache = new Map()
|
|
283
|
+
function parseProxy(varName, raw) {
|
|
284
|
+
const cacheKey = `${varName} ${raw}`
|
|
285
|
+
if (proxyCache.has(cacheKey)) return proxyCache.get(cacheKey)
|
|
286
|
+
// `proxy.corp:8080`, with no scheme at all, is common enough to accept.
|
|
287
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`
|
|
288
|
+
let u = null
|
|
289
|
+
try { u = new URL(withScheme) } catch { u = null }
|
|
290
|
+
if (!u || !u.hostname) {
|
|
291
|
+
throw new Error(`${varName} is not a usable proxy URL ("${raw}") — expected http://host:port`)
|
|
292
|
+
}
|
|
293
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`${varName} names a ${u.protocol.replace(':', '')} proxy ("${raw}"), which this CLI cannot use. ` +
|
|
296
|
+
`Myelin speaks to an HTTP CONNECT proxy (http://host:port). Ask your network team for one, or for the ` +
|
|
297
|
+
`allowlist in the CLI README's "For your IT department" section.`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
const port = Number(u.port || (u.protocol === 'https:' ? 443 : 80))
|
|
301
|
+
const proxy = {
|
|
302
|
+
protocol: u.protocol,
|
|
303
|
+
hostname: u.hostname,
|
|
304
|
+
port,
|
|
305
|
+
// Credentials go on the wire and nowhere else: `display` is the only form
|
|
306
|
+
// a message, a log line or an error is allowed to carry.
|
|
307
|
+
auth: u.username
|
|
308
|
+
? Buffer.from(`${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`).toString('base64')
|
|
309
|
+
: null,
|
|
310
|
+
display: `${u.protocol}//${u.hostname}:${port}`,
|
|
311
|
+
}
|
|
312
|
+
proxyCache.set(cacheKey, proxy)
|
|
313
|
+
return proxy
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function proxyForUrl(url) {
|
|
317
|
+
const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
|
|
318
|
+
if (noProxyMatches(url.hostname, port)) return null
|
|
319
|
+
const pair = url.protocol === 'https:' ? httpsProxyEnv() : httpProxyEnv()
|
|
320
|
+
return pair ? parseProxy(pair.name, pair.value) : null
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Checked before the first request rather than at the moment one fails: a typo
|
|
324
|
+
// in HTTPS_PROXY deserves one clear line at the top, not a connection error
|
|
325
|
+
// wearing a configuration error's clothes.
|
|
326
|
+
function assertProxyEnvUsable() {
|
|
327
|
+
for (const pair of [httpsProxyEnv(), httpProxyEnv()]) {
|
|
328
|
+
if (!pair) continue
|
|
329
|
+
try { parseProxy(pair.name, pair.value) } catch (err) { die(err.message) }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const hostOf = (rawUrl) => {
|
|
334
|
+
try { return new URL(rawUrl).host } catch { return 'the storage host' }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Which egress a failed call took, in one clause appended to the error. The
|
|
338
|
+
// old message ("fetch failed") sent a proxied partner to us; naming the proxy
|
|
339
|
+
// sends them to their network team, which is where the answer lives.
|
|
340
|
+
function egressHint(rawUrl, message = '') {
|
|
341
|
+
let url = null
|
|
342
|
+
try { url = new URL(rawUrl) } catch { return '' }
|
|
343
|
+
let proxy = null
|
|
344
|
+
try { proxy = proxyForUrl(url) } catch { return '' }
|
|
345
|
+
if (proxy) return message.includes(proxy.display) ? '' : ` — via proxy ${proxy.display}`
|
|
346
|
+
const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
|
|
347
|
+
if (anyProxyEnv() && noProxyMatches(url.hostname, port)) {
|
|
348
|
+
return ` — connected directly: NO_PROXY excludes ${url.hostname}`
|
|
349
|
+
}
|
|
350
|
+
return ''
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const directAgents = {
|
|
354
|
+
'http:': new http.Agent({ keepAlive: true }),
|
|
355
|
+
'https:': new https.Agent({ keepAlive: true }),
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// CONNECT is where a misconfigured proxy hangs for ever — a blackholed SYN, or
|
|
359
|
+
// a proxy that accepts the socket and then says nothing. Every other wait in
|
|
360
|
+
// this file deliberately inherits fetch's "no timeout" (a 10 000-file
|
|
361
|
+
// preflight legitimately takes minutes); a tunnel that has not opened in 30 s
|
|
362
|
+
// is not opening.
|
|
363
|
+
const TUNNEL_TIMEOUT_MS = 30_000
|
|
364
|
+
|
|
365
|
+
function openTunnel(proxy, host, port) {
|
|
366
|
+
return new Promise((resolveSocket, reject) => {
|
|
367
|
+
const transport = proxy.protocol === 'https:' ? https : http
|
|
368
|
+
const req = transport.request({
|
|
369
|
+
host: proxy.hostname,
|
|
370
|
+
port: proxy.port,
|
|
371
|
+
method: 'CONNECT',
|
|
372
|
+
path: `${host}:${port}`,
|
|
373
|
+
agent: false,
|
|
374
|
+
headers: {
|
|
375
|
+
Host: `${host}:${port}`,
|
|
376
|
+
'User-Agent': UA,
|
|
377
|
+
...(proxy.auth ? { 'Proxy-Authorization': `Basic ${proxy.auth}` } : {}),
|
|
378
|
+
},
|
|
379
|
+
...(proxy.protocol === 'https:' ? { servername: proxy.hostname } : {}),
|
|
380
|
+
})
|
|
381
|
+
let settled = false
|
|
382
|
+
const timer = setTimeout(() => {
|
|
383
|
+
if (settled) return
|
|
384
|
+
settled = true
|
|
385
|
+
req.destroy()
|
|
386
|
+
reject(new Error(`proxy ${proxy.display} did not open a tunnel to ${host}:${port} within ${TUNNEL_TIMEOUT_MS / 1000}s`))
|
|
387
|
+
}, TUNNEL_TIMEOUT_MS)
|
|
388
|
+
timer.unref?.()
|
|
389
|
+
const settle = (fn) => (...a) => {
|
|
390
|
+
if (settled) return
|
|
391
|
+
settled = true
|
|
392
|
+
clearTimeout(timer)
|
|
393
|
+
fn(...a)
|
|
394
|
+
}
|
|
395
|
+
req.once('connect', settle((res, socket) => {
|
|
396
|
+
if (res.statusCode === 200) { resolveSocket(socket); return }
|
|
397
|
+
socket.destroy()
|
|
398
|
+
reject(new Error(
|
|
399
|
+
res.statusCode === 407
|
|
400
|
+
? `proxy ${proxy.display} requires authentication (407) — put the credentials in the proxy URL (http://user:pass@host:port)`
|
|
401
|
+
: `proxy ${proxy.display} refused a tunnel to ${host}:${port} (HTTP ${res.statusCode})`,
|
|
402
|
+
))
|
|
403
|
+
}))
|
|
404
|
+
// A proxy that answers CONNECT with an ordinary response — a captive
|
|
405
|
+
// portal, or an explicit proxy expecting absolute-form requests — is not
|
|
406
|
+
// going to tunnel anything. Say so rather than wait for the timeout.
|
|
407
|
+
req.once('response', settle((res) => {
|
|
408
|
+
res.resume()
|
|
409
|
+
reject(new Error(`proxy ${proxy.display} answered CONNECT with HTTP ${res.statusCode} instead of opening a tunnel to ${host}:${port}`))
|
|
410
|
+
}))
|
|
411
|
+
req.once('error', settle((err) => reject(new Error(`proxy ${proxy.display}: ${err.message}`))))
|
|
412
|
+
req.end()
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// One tunnelling agent per proxy. keepAlive is OFF on this path on purpose: a
|
|
417
|
+
// pooled socket here is a CONNECT tunnel the proxy may have torn down while it
|
|
418
|
+
// sat idle, and the next part upload would then fail on a socket that still
|
|
419
|
+
// looked healthy. One handshake per request, against a 64 MB part, is noise.
|
|
420
|
+
class TunnelAgent extends https.Agent {
|
|
421
|
+
constructor(proxy) {
|
|
422
|
+
super({ keepAlive: false })
|
|
423
|
+
this.proxy = proxy
|
|
424
|
+
}
|
|
425
|
+
createConnection(options, cb) {
|
|
426
|
+
openTunnel(this.proxy, options.host, options.port).then(
|
|
427
|
+
(socket) => cb(null, tls.connect({ socket, servername: options.servername || options.host })),
|
|
428
|
+
(err) => cb(err),
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const tunnelAgents = new Map()
|
|
433
|
+
const tunnelAgentFor = (proxy) => {
|
|
434
|
+
const key = `${proxy.display} ${proxy.auth ?? ''}`
|
|
435
|
+
let agent = tunnelAgents.get(key)
|
|
436
|
+
if (!agent) {
|
|
437
|
+
agent = new TunnelAgent(proxy)
|
|
438
|
+
tunnelAgents.set(key, agent)
|
|
439
|
+
}
|
|
440
|
+
return agent
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Bodies: a string, a Buffer, or a FormData (the sandbox upload). Multipart is
|
|
444
|
+
// not hand-rolled — `Response` is a global, so letting the platform encode the
|
|
445
|
+
// FormData costs no dependency and cannot disagree with what a server expects.
|
|
446
|
+
async function encodeBody(body) {
|
|
447
|
+
if (body == null) return { bytes: null, contentType: null }
|
|
448
|
+
if (typeof body === 'string') return { bytes: Buffer.from(body, 'utf8'), contentType: null }
|
|
449
|
+
if (Buffer.isBuffer(body)) return { bytes: body, contentType: null }
|
|
450
|
+
if (ArrayBuffer.isView(body)) return { bytes: Buffer.from(body.buffer, body.byteOffset, body.byteLength), contentType: null }
|
|
451
|
+
const encoded = new Response(body)
|
|
452
|
+
return { bytes: Buffer.from(await encoded.arrayBuffer()), contentType: encoded.headers.get('content-type') }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function decodeBody(raw, encoding) {
|
|
456
|
+
const enc = String(encoding ?? '').trim().toLowerCase()
|
|
457
|
+
try {
|
|
458
|
+
if (enc === 'gzip' || enc === 'x-gzip') return zlib.gunzipSync(raw).toString('utf8')
|
|
459
|
+
if (enc === 'br') return zlib.brotliDecompressSync(raw).toString('utf8')
|
|
460
|
+
if (enc === 'deflate') {
|
|
461
|
+
try { return zlib.inflateSync(raw).toString('utf8') } catch { return zlib.inflateRawSync(raw).toString('utf8') }
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
// A body we cannot inflate is still worth showing raw — half an error
|
|
465
|
+
// message beats none.
|
|
466
|
+
}
|
|
467
|
+
return raw.toString('utf8')
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// The whole HTTP surface of this CLI, in one function. It returns the small
|
|
471
|
+
// slice of the fetch Response shape the rest of the file uses — `status`,
|
|
472
|
+
// `headers.get()`, `json()`, `text()` — so the call sites read as they did.
|
|
473
|
+
async function httpRequest(rawUrl, { method = 'GET', headers = {}, body } = {}, redirectsLeft = 5) {
|
|
474
|
+
const url = new URL(rawUrl)
|
|
475
|
+
const proxy = proxyForUrl(url)
|
|
476
|
+
const { bytes, contentType } = await encodeBody(body)
|
|
477
|
+
|
|
478
|
+
const sent = {}
|
|
479
|
+
for (const [k, v] of Object.entries(headers)) if (v != null) sent[k.toLowerCase()] = String(v)
|
|
480
|
+
sent['user-agent'] ??= UA
|
|
481
|
+
sent['accept-encoding'] ??= 'gzip, deflate, br'
|
|
482
|
+
if (bytes) {
|
|
483
|
+
// ⚠ Always a Content-Length, never chunked: S3 rejects a chunked PUT, and
|
|
484
|
+
// a presigned part upload is the one request here that must never be
|
|
485
|
+
// refused for a transport reason.
|
|
486
|
+
sent['content-length'] = String(bytes.length)
|
|
487
|
+
if (contentType && !sent['content-type']) sent['content-type'] = contentType
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// https through a proxy is a CONNECT tunnel; plain http through a proxy is
|
|
491
|
+
// an absolute-form request line addressed to the proxy itself.
|
|
492
|
+
const tunnelled = Boolean(proxy) && url.protocol === 'https:'
|
|
493
|
+
const forwarded = Boolean(proxy) && url.protocol === 'http:'
|
|
494
|
+
if (forwarded) {
|
|
495
|
+
sent.host = url.host
|
|
496
|
+
if (proxy.auth) sent['proxy-authorization'] = `Basic ${proxy.auth}`
|
|
497
|
+
}
|
|
498
|
+
const transport = (forwarded ? proxy.protocol : url.protocol) === 'https:' ? https : http
|
|
499
|
+
const options = forwarded
|
|
500
|
+
? { host: proxy.hostname, port: proxy.port, method, path: url.href, headers: sent, agent: directAgents[proxy.protocol] }
|
|
501
|
+
: {
|
|
502
|
+
host: url.hostname,
|
|
503
|
+
port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)),
|
|
504
|
+
method,
|
|
505
|
+
path: `${url.pathname}${url.search}`,
|
|
506
|
+
headers: sent,
|
|
507
|
+
agent: tunnelled ? tunnelAgentFor(proxy) : directAgents[url.protocol],
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return new Promise((settle, reject) => {
|
|
511
|
+
const req = transport.request(options, (res) => {
|
|
512
|
+
// 407 is a proxy's status, never an origin server's. On the forwarded
|
|
513
|
+
// path it arrives as an ordinary response, so without this the CLI
|
|
514
|
+
// reported it as though Myelin had answered — "[error] HTTP 407" and a
|
|
515
|
+
// partner reading it as our problem. The tunnel path already says this
|
|
516
|
+
// properly; both now do.
|
|
517
|
+
if (forwarded && res.statusCode === 407) {
|
|
518
|
+
res.resume()
|
|
519
|
+
reject(new Error(`proxy ${proxy.display} requires authentication (407) — put the credentials in the proxy URL (http://user:pass@host:port)`))
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
const location = res.headers.location
|
|
523
|
+
if (location && [301, 302, 303, 307, 308].includes(res.statusCode) && redirectsLeft > 0) {
|
|
524
|
+
res.resume()
|
|
525
|
+
let next = null
|
|
526
|
+
try { next = new URL(location, url) } catch { next = null }
|
|
527
|
+
if (next) {
|
|
528
|
+
const toGet = res.statusCode === 303 || ([301, 302].includes(res.statusCode) && method !== 'GET' && method !== 'HEAD')
|
|
529
|
+
const forward = {}
|
|
530
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
531
|
+
// The rule fetch follows, and the one that matters here: a bearer
|
|
532
|
+
// token does not cross an origin. A front door that bounces us to
|
|
533
|
+
// an identity provider must never be handed the API key.
|
|
534
|
+
if (next.origin !== url.origin && k.toLowerCase() === 'authorization') continue
|
|
535
|
+
forward[k] = v
|
|
536
|
+
}
|
|
537
|
+
settle(httpRequest(next.href, { method: toGet ? 'GET' : method, headers: forward, body: toGet ? undefined : body }, redirectsLeft - 1))
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const chunks = []
|
|
542
|
+
res.on('data', (c) => chunks.push(c))
|
|
543
|
+
res.on('error', reject)
|
|
544
|
+
res.on('end', () => {
|
|
545
|
+
const text = decodeBody(Buffer.concat(chunks), res.headers['content-encoding'])
|
|
546
|
+
settle({
|
|
547
|
+
status: res.statusCode,
|
|
548
|
+
headers: {
|
|
549
|
+
get: (name) => {
|
|
550
|
+
const v = res.headers[String(name).toLowerCase()]
|
|
551
|
+
return v == null ? null : Array.isArray(v) ? v.join(', ') : v
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
text: async () => text,
|
|
555
|
+
json: async () => JSON.parse(text),
|
|
556
|
+
})
|
|
557
|
+
})
|
|
558
|
+
})
|
|
559
|
+
req.once('error', reject)
|
|
560
|
+
if (bytes) req.end(bytes)
|
|
561
|
+
else req.end()
|
|
562
|
+
})
|
|
563
|
+
}
|
|
564
|
+
|
|
157
565
|
// 429s are handled HERE so every command inherits the platform's documented
|
|
158
566
|
// contract (honour Retry-After): bounded — 4 waits, then the error surfaces
|
|
159
567
|
// like any other. The wait is capped at 60 s so a pathological header cannot
|
|
160
568
|
// stall a pipeline for an hour.
|
|
161
569
|
async function api(method, path, body, raw) {
|
|
162
570
|
for (let attempt = 1; ; attempt++) {
|
|
163
|
-
const res = await
|
|
571
|
+
const res = await httpRequest(API_URL + path, {
|
|
164
572
|
method,
|
|
165
573
|
headers: {
|
|
166
574
|
...EXTRA_HEADERS,
|
|
@@ -168,7 +576,7 @@ async function api(method, path, body, raw) {
|
|
|
168
576
|
...(body && !raw ? { 'Content-Type': 'application/json' } : {}),
|
|
169
577
|
},
|
|
170
578
|
body: raw ? body : body ? JSON.stringify(body) : undefined,
|
|
171
|
-
}).catch((err) => die(`Cannot reach ${API_URL}: ${err.message}`))
|
|
579
|
+
}).catch((err) => die(`Cannot reach ${API_URL}: ${err.message}${egressHint(API_URL, err.message)}`))
|
|
172
580
|
let json = null
|
|
173
581
|
try { json = await res.json() } catch { /* non-JSON body */ }
|
|
174
582
|
const requestId = res.headers.get('myelin-request-id') ?? json?.request_id ?? null
|
|
@@ -452,10 +860,19 @@ async function uploadFileParts(absPath, size, fileId, partSize, adoptLandedGeome
|
|
|
452
860
|
if (url === undefined) url = await resign(partNo, bytes)
|
|
453
861
|
if (url === null) break // part landed server-side — resign() recorded its etag
|
|
454
862
|
const startedAt = Date.now()
|
|
455
|
-
|
|
863
|
+
let netErr = null
|
|
864
|
+
put = await httpRequest(url, { method: 'PUT', body: bytes }).catch((err) => { netErr = err; return null })
|
|
456
865
|
if (put && put.status === 200) break
|
|
457
866
|
const status = put ? put.status : 0
|
|
458
|
-
|
|
867
|
+
// ⚠ Never print `url` itself — a presigned URL carries its
|
|
868
|
+
// authorization in the query string. The HOST is the part a partner
|
|
869
|
+
// needs: a firewall or proxy that allows the API host and not the
|
|
870
|
+
// storage host is the commonest way a delivery dies, and the old
|
|
871
|
+
// bare "network error" sent them to look at Myelin instead of at
|
|
872
|
+
// their own egress.
|
|
873
|
+
const reason = put
|
|
874
|
+
? `HTTP ${put.status}`
|
|
875
|
+
: `network error reaching ${hostOf(url)}${netErr ? ` (${netErr.message})` : ''}${egressHint(url, netErr?.message ?? '')}`
|
|
459
876
|
if (isTimeoutClassFailure(status, Date.now() - startedAt)) {
|
|
460
877
|
const strikes = (timeoutStrikes.get(partNo) ?? 0) + 1
|
|
461
878
|
timeoutStrikes.set(partNo, strikes)
|
|
@@ -496,6 +913,12 @@ async function cmdPing() {
|
|
|
496
913
|
const j = expectOk(await api('GET', '/ping'), 'ping')
|
|
497
914
|
emit(j)
|
|
498
915
|
out(`✓ key "${j.key.name}" (${j.key.prefix}…) · bridge ${j.bridge.name} · ${j.projects.length} project(s)`)
|
|
916
|
+
// The first question an IT department asks once a proxy is in play: is the
|
|
917
|
+
// CLI actually going through it? Printed only when one applies, so the
|
|
918
|
+
// ordinary output is unchanged.
|
|
919
|
+
const via = proxyForUrl(new URL(API_URL))
|
|
920
|
+
if (via) out(` via proxy ${via.display}`)
|
|
921
|
+
out('Next: myelin datasets')
|
|
499
922
|
}
|
|
500
923
|
|
|
501
924
|
async function cmdProjects() {
|
|
@@ -522,11 +945,19 @@ async function cmdDatasets() {
|
|
|
522
945
|
}
|
|
523
946
|
emit({ datasets: rows })
|
|
524
947
|
if (!JSON_MODE) {
|
|
948
|
+
if (rows.length === 0) {
|
|
949
|
+
// The commonest first-run dead end, and nothing on the partner's side
|
|
950
|
+
// fixes it: the client has not activated a dataset in a project this
|
|
951
|
+
// key is scoped to. Say so, rather than printing an empty table.
|
|
952
|
+
out('No dataset to deliver to yet. The client activates one on their side, in a project this key is scoped to — ask your client contact.')
|
|
953
|
+
return
|
|
954
|
+
}
|
|
525
955
|
const lines = table(
|
|
526
956
|
['PROJECT', 'DATASET', 'STATUS', 'YOUR MOVE?'],
|
|
527
957
|
rows.map((r) => [r.project, r.dataset, r.status, r.open_draft ? 'yes — open draft to finish' : '—']),
|
|
528
958
|
)
|
|
529
959
|
for (const line of lines) out(line)
|
|
960
|
+
out(`Next: myelin contract --dataset ${rows[0].dataset}`)
|
|
530
961
|
}
|
|
531
962
|
}
|
|
532
963
|
|
|
@@ -576,8 +1007,11 @@ async function cmdContract() {
|
|
|
576
1007
|
// was not `blocking` a "should", so a `must_acknowledge` rule — the one
|
|
577
1008
|
// that demands a named decision from the reviewer — printed identically
|
|
578
1009
|
// to a warning nobody has to read.
|
|
1010
|
+
// `?` is what `check` and `status` print for "not checked"; the reviewer-
|
|
1011
|
+
// confirmed rung gets its own glyph so one mark means one thing across
|
|
1012
|
+
// the three commands (first-contact review, 2026-09-19).
|
|
579
1013
|
const mark =
|
|
580
|
-
c.severity === 'blocking' ? '!' : c.severity === 'must_acknowledge' ? '
|
|
1014
|
+
c.severity === 'blocking' ? '!' : c.severity === 'must_acknowledge' ? '~' : '·'
|
|
581
1015
|
const when = c.runs_at === 'preflight' ? '' : ' (checked at submission)'
|
|
582
1016
|
const note =
|
|
583
1017
|
c.severity === 'must_acknowledge'
|
|
@@ -735,8 +1169,10 @@ async function cmdCheck() {
|
|
|
735
1169
|
// true, and the coverage is stated the rest of the time — framed as a
|
|
736
1170
|
// capability to gain, because it is one: more checks running before you send
|
|
737
1171
|
// means fewer rejections after.
|
|
1172
|
+
const next = `Next: myelin push ${dir} --dataset ${dsRef} --submit`
|
|
738
1173
|
if (!k || k.passed + k.not_applicable === k.evaluated) {
|
|
739
1174
|
out('All checks that run before upload pass.')
|
|
1175
|
+
out(next)
|
|
740
1176
|
return
|
|
741
1177
|
}
|
|
742
1178
|
const rest = []
|
|
@@ -746,6 +1182,7 @@ async function cmdCheck() {
|
|
|
746
1182
|
if (k.not_evaluated > 0) {
|
|
747
1183
|
out(`Supply what ${k.not_evaluated === 1 ? 'it needs' : 'they need'} and ${k.not_evaluated === 1 ? 'it runs' : 'they run'} on your next delivery.`)
|
|
748
1184
|
}
|
|
1185
|
+
out(next)
|
|
749
1186
|
}
|
|
750
1187
|
|
|
751
1188
|
async function cmdPush() {
|
|
@@ -782,7 +1219,10 @@ async function cmdPush() {
|
|
|
782
1219
|
}
|
|
783
1220
|
const created = expectOk(createRes, 'create batch')
|
|
784
1221
|
const batchId = created.batch_id
|
|
785
|
-
|
|
1222
|
+
// The display name (ONCO1-WES-004) is what a person passes to `status` and
|
|
1223
|
+
// `recall`; the create call answers with the id only, so read it once.
|
|
1224
|
+
const batchName = (await api('GET', `/batches/${batchId}`)).json?.batch?.display_name ?? batchId
|
|
1225
|
+
out(`Draft ${batchName} ${created.resumed ? 'resumed' : 'created'}.`)
|
|
786
1226
|
|
|
787
1227
|
// 2. declare in chunks of 500 — idempotent, so re-running skips what's done
|
|
788
1228
|
const declared = []
|
|
@@ -845,7 +1285,7 @@ async function cmdPush() {
|
|
|
845
1285
|
if (j.already_submitted) {
|
|
846
1286
|
// A retried submit whose first attempt actually landed: the API echoes
|
|
847
1287
|
// success instead of failing, and so do we — nothing to redo.
|
|
848
|
-
out(`✓ Batch already submitted (${j.status}) — nothing to redo. Track: myelin status ${
|
|
1288
|
+
out(`✓ Batch already submitted (${j.status}) — nothing to redo. Track: myelin status ${batchName} --watch`)
|
|
849
1289
|
} else {
|
|
850
1290
|
// ⚠ This line used to print three numbers and no denominator, which was
|
|
851
1291
|
// exhaustive until the engine could abstain — after that, an abstention
|
|
@@ -863,7 +1303,7 @@ async function cmdPush() {
|
|
|
863
1303
|
if (a.must_acknowledge_failures) {
|
|
864
1304
|
out(` ${a.must_acknowledge_failures} check(s) your client asked to be told about — nothing to fix.`)
|
|
865
1305
|
}
|
|
866
|
-
out(`✓ Batch submitted for review. Track: myelin status ${
|
|
1306
|
+
out(`✓ Batch submitted for review. Track: myelin status ${batchName} --watch`)
|
|
867
1307
|
}
|
|
868
1308
|
} else {
|
|
869
1309
|
emit({ batch_id: batchId, files: declared.length, submitted: false })
|
|
@@ -871,27 +1311,53 @@ async function cmdPush() {
|
|
|
871
1311
|
}
|
|
872
1312
|
}
|
|
873
1313
|
|
|
1314
|
+
// A batch by id or by display name (`ONCO1-WES-004`), as `push` prints it.
|
|
1315
|
+
// Shared by `status` and `recall`.
|
|
1316
|
+
async function resolveBatch(ref) {
|
|
1317
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
|
|
1318
|
+
const r = await api('GET', `/batches/${ref}`)
|
|
1319
|
+
if (r.status === 200) return expectOk(r, 'batch').batch
|
|
1320
|
+
die(`Batch ${ref} not found in this key's scope`)
|
|
1321
|
+
}
|
|
1322
|
+
// Paged: a key that has delivered more than a hundred times still finds
|
|
1323
|
+
// its batch by name.
|
|
1324
|
+
let cursor = null
|
|
1325
|
+
for (;;) {
|
|
1326
|
+
const list = expectOk(
|
|
1327
|
+
await api('GET', `/batches?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`),
|
|
1328
|
+
'batches',
|
|
1329
|
+
)
|
|
1330
|
+
const hit = list.batches.find((b) => b.display_name === ref)
|
|
1331
|
+
if (hit) return expectOk(await api('GET', `/batches/${hit.id}`), 'batch').batch
|
|
1332
|
+
if (!list.next_cursor) die(`No batch named "${ref}" in this key's scope`)
|
|
1333
|
+
cursor = list.next_cursor
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
874
1337
|
async function cmdStatus() {
|
|
875
1338
|
const ref = args[1]
|
|
876
|
-
|
|
1339
|
+
// `myelin status --watch` used to take "--watch" as the batch name and go
|
|
1340
|
+
// to the network with it; a flag is never a batch.
|
|
1341
|
+
if (!ref || ref.startsWith('--')) die('Usage: myelin status <batch-id|display-name> [--watch]')
|
|
877
1342
|
|
|
878
|
-
const findBatch =
|
|
879
|
-
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
|
|
880
|
-
const r = await api('GET', `/batches/${ref}`)
|
|
881
|
-
if (r.status === 200) return expectOk(r, 'batch').batch
|
|
882
|
-
die(`Batch ${ref} not found in this key's scope`)
|
|
883
|
-
}
|
|
884
|
-
const list = expectOk(await api('GET', '/batches?limit=100'), 'batches')
|
|
885
|
-
const hit = list.batches.find((b) => b.display_name === ref)
|
|
886
|
-
if (!hit) die(`No batch named "${ref}" in this key's scope`)
|
|
887
|
-
return expectOk(await api('GET', `/batches/${hit.id}`), 'batch').batch
|
|
888
|
-
}
|
|
1343
|
+
const findBatch = () => resolveBatch(ref)
|
|
889
1344
|
|
|
890
1345
|
const print = async (batch) => {
|
|
891
|
-
|
|
892
|
-
|
|
1346
|
+
// "done." used to cover rejected and transfer_failed alike. A final state
|
|
1347
|
+
// says what it is; the rest says whose move it is.
|
|
1348
|
+
const tail =
|
|
1349
|
+
batch.status === 'transferred' ? 'delivered to your client’s bucket.'
|
|
1350
|
+
: batch.status === 'rejected' ? 'rejected — final.'
|
|
1351
|
+
: batch.status === 'transfer_failed' ? 'transfer failed on the Myelin side — nothing to redo; ask your client contact.'
|
|
1352
|
+
: batch.court === 'partner' ? 'your move.'
|
|
1353
|
+
: batch.court === 'reviewer' ? "reviewer's move."
|
|
1354
|
+
: batch.court === 'system' ? 'transferring…'
|
|
1355
|
+
: 'done.'
|
|
1356
|
+
out(`${batch.display_name}: ${batch.status} — ${tail}`)
|
|
1357
|
+
let findings = null
|
|
893
1358
|
if (batch.status === 'changes_requested') {
|
|
894
1359
|
const f = expectOk(await api('GET', `/batches/${batch.id}/findings`), 'findings')
|
|
1360
|
+
findings = f
|
|
895
1361
|
if (f.request_changes_comment) out(` reviewer: "${f.request_changes_comment}"`)
|
|
896
1362
|
for (const fr of f.file_reviews.filter((x) => x.verdict === 'failed')) {
|
|
897
1363
|
out(` ✗ ${fr.path} (${fr.reviewer ?? 'reviewer'}: ${fr.comment ?? 'failed'})`)
|
|
@@ -914,20 +1380,55 @@ async function cmdStatus() {
|
|
|
914
1380
|
)
|
|
915
1381
|
}
|
|
916
1382
|
}
|
|
1383
|
+
emit(findings ? { batch, findings } : { batch })
|
|
917
1384
|
return batch
|
|
918
1385
|
}
|
|
919
1386
|
|
|
920
1387
|
let batch = await print(await findBatch())
|
|
921
1388
|
if (flag('watch')) {
|
|
1389
|
+
// Nothing to wait for on a draft: it is the partner's own move.
|
|
1390
|
+
if (batch.status === 'draft') {
|
|
1391
|
+
out(' nothing to wait for — it is your move: myelin push <dir> --dataset <ds> --submit')
|
|
1392
|
+
process.exitCode = 2
|
|
1393
|
+
return
|
|
1394
|
+
}
|
|
922
1395
|
const terminal = ['transferred', 'rejected', 'changes_requested', 'transfer_failed']
|
|
1396
|
+
let misses = 0
|
|
923
1397
|
while (!terminal.includes(batch.status)) {
|
|
924
1398
|
await new Promise((r) => setTimeout(r, 20_000))
|
|
925
|
-
|
|
1399
|
+
let next
|
|
1400
|
+
try {
|
|
1401
|
+
next = await findBatch()
|
|
1402
|
+
misses = 0
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
// A network blip mid-watch used to end the command with exit 1 after
|
|
1405
|
+
// hours of polling. Five consecutive misses is a real outage.
|
|
1406
|
+
if (!(err instanceof CliExit) || ++misses >= 5) throw err
|
|
1407
|
+
process.exitCode = 0
|
|
1408
|
+
out(` (could not reach the API — retrying, ${misses}/5)`)
|
|
1409
|
+
continue
|
|
1410
|
+
}
|
|
926
1411
|
if (next.status !== batch.status) batch = await print(next)
|
|
927
1412
|
}
|
|
1413
|
+
// A pipeline branches on the exit code: 0 delivered, 2 your move, 1 final.
|
|
1414
|
+
if (batch.status === 'changes_requested') process.exitCode = 2
|
|
1415
|
+
else if (batch.status === 'rejected' || batch.status === 'transfer_failed') process.exitCode = 1
|
|
928
1416
|
}
|
|
929
1417
|
}
|
|
930
1418
|
|
|
1419
|
+
// Take a submitted delivery back before the review starts. The page and the
|
|
1420
|
+
// API's own `delivery_locked` message both said "recall it" while the CLI had
|
|
1421
|
+
// no way to (first-contact review, 2026-09-19).
|
|
1422
|
+
async function cmdRecall() {
|
|
1423
|
+
const ref = args[1]
|
|
1424
|
+
if (!ref || ref.startsWith('--')) die('Usage: myelin recall <batch-id|display-name>')
|
|
1425
|
+
const batch = await resolveBatch(ref)
|
|
1426
|
+
const r = await api('POST', `/batches/${batch.id}/recall`)
|
|
1427
|
+
if (r.status !== 200) apiDie('recall', r)
|
|
1428
|
+
emit(r.json)
|
|
1429
|
+
out(`✓ ${batch.display_name} recalled — it is a draft again (${r.json.status}). Fix, then: myelin push <dir> --dataset <ds> --submit`)
|
|
1430
|
+
}
|
|
1431
|
+
|
|
931
1432
|
async function cmdSandbox() {
|
|
932
1433
|
const file = args[1]
|
|
933
1434
|
if (!file) die('Usage: myelin sandbox <file>')
|
|
@@ -984,6 +1485,10 @@ async function cmdRoles() {
|
|
|
984
1485
|
if (sub === 'set' || sub === 'none' || sub === 'clear') {
|
|
985
1486
|
const role = args[2]
|
|
986
1487
|
if (!role) die(`Usage: myelin roles ${sub} <role>${sub === 'set' ? ' <path>' : ''} --dataset <slug|id>`)
|
|
1488
|
+
// A misspelt role used to travel to the API and come back as a 400; the
|
|
1489
|
+
// five names are known here.
|
|
1490
|
+
const KNOWN_ROLES = ['samplesheet', 'checksum_manifest', 'qc_report', 'subject_roster', 'capture_bed']
|
|
1491
|
+
if (!KNOWN_ROLES.includes(role)) die(`Unknown role "${role}" — one of ${KNOWN_ROLES.join(', ')}`)
|
|
987
1492
|
const body = { role }
|
|
988
1493
|
if (sub === 'set') {
|
|
989
1494
|
const path = args[3]
|
|
@@ -1033,37 +1538,193 @@ async function cmdRoles() {
|
|
|
1033
1538
|
}
|
|
1034
1539
|
}
|
|
1035
1540
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1541
|
+
// ——— help ———
|
|
1542
|
+
//
|
|
1543
|
+
// Two doors, both open WITHOUT a key: `myelin help [<command>]` and
|
|
1544
|
+
// `myelin <command> --help`. The developer reading this is usually waiting for
|
|
1545
|
+
// a key from the bridge owner, and the manual is the one thing they can use
|
|
1546
|
+
// meanwhile. The overview lists the commands in the order a first delivery
|
|
1547
|
+
// runs them, so the list is also the path. No version numbers anywhere in this
|
|
1548
|
+
// text: a change ships as the current behaviour, not as a diff against a past
|
|
1549
|
+
// no partner has lived (founder, 2026-09-19). `scripts/test-cli-docs.ts` holds
|
|
1550
|
+
// this text, the README's table and the /developers table to the same set of
|
|
1551
|
+
// commands, and fails on a semver literal in any of them.
|
|
1552
|
+
|
|
1553
|
+
const HELP = {
|
|
1554
|
+
ping: {
|
|
1555
|
+
usage: 'myelin ping',
|
|
1556
|
+
what: 'Checks the key and prints the bridge it belongs to and the projects it can reach.',
|
|
1557
|
+
notes: [
|
|
1558
|
+
'Run this first. If it succeeds but `datasets` prints nothing, the client has not',
|
|
1559
|
+
'activated a dataset yet — nothing on your side fixes that; ask your client contact.',
|
|
1560
|
+
],
|
|
1561
|
+
},
|
|
1562
|
+
datasets: {
|
|
1563
|
+
usage: 'myelin datasets',
|
|
1564
|
+
what: 'Lists the datasets you can deliver to, and whose move it is on each.',
|
|
1565
|
+
notes: [
|
|
1566
|
+
'DATASET is the slug you pass to --dataset (any case). "YOUR MOVE? yes" means a',
|
|
1567
|
+
'draft of yours is open and waiting to be finished or submitted.',
|
|
1568
|
+
],
|
|
1569
|
+
},
|
|
1570
|
+
contract: {
|
|
1571
|
+
usage: 'myelin contract --dataset <ds>',
|
|
1572
|
+
what: 'Prints what the client expects of a delivery: every check, one sentence each.',
|
|
1573
|
+
notes: [
|
|
1574
|
+
' ! blocks validation if it fails',
|
|
1575
|
+
' ~ is confirmed by the client’s reviewer, never fixed by you',
|
|
1576
|
+
'Also says which checks `check` can run locally and which run once the files are up,',
|
|
1577
|
+
'and the client’s own fields on the dataset (read-only, informational).',
|
|
1578
|
+
],
|
|
1579
|
+
},
|
|
1580
|
+
check: {
|
|
1581
|
+
usage: 'myelin check <dir> --dataset <ds>',
|
|
1582
|
+
what: 'Runs the dataset’s checks on your local folder, server-side, without uploading a byte.',
|
|
1583
|
+
notes: [
|
|
1584
|
+
'Exit 2 when a blocking check failed OR could not be evaluated — both stop the',
|
|
1585
|
+
'reviewer validating. Checks that read file contents run at submit; they are listed',
|
|
1586
|
+
'as deferred here. Safe to run as often as you like.',
|
|
1587
|
+
],
|
|
1588
|
+
},
|
|
1589
|
+
push: {
|
|
1590
|
+
usage: 'myelin push <dir> --dataset <ds> [--submit] [--replace]',
|
|
1591
|
+
what: 'Creates or resumes the dataset’s draft and uploads what has not landed yet.',
|
|
1592
|
+
notes: [
|
|
1593
|
+
'--submit hand the delivery over for review once every file is up',
|
|
1594
|
+
'--replace overwrite a path already declared with a different size or checksum',
|
|
1595
|
+
' (without it, that path exits 2)',
|
|
1596
|
+
'Every file under <dir> is sent, dotfiles included, symlinks followed; a broken link',
|
|
1597
|
+
'or an unreadable file stops the command and is named. Stage a clean folder if your',
|
|
1598
|
+
'pipeline leaves work files behind.',
|
|
1599
|
+
'Re-run the same command to resume: uploaded files, and uploaded parts of a large',
|
|
1600
|
+
'file, are skipped. One draft per dataset; without --submit it stays open, and a',
|
|
1601
|
+
'later push of a DIFFERENT folder joins that same draft. While a delivery is',
|
|
1602
|
+
'submitted or in review, push to that dataset exits 2 (delivery_locked).',
|
|
1603
|
+
'push does not run check first — run check yourself. At the end it prints the',
|
|
1604
|
+
'delivery name (ONCO1-WES-004) that `status` and `recall` take.',
|
|
1605
|
+
],
|
|
1606
|
+
},
|
|
1607
|
+
status: {
|
|
1608
|
+
usage: 'myelin status <batch> [--watch]',
|
|
1609
|
+
what: 'Where a delivery stands and whose move it is. Takes an id or a display name.',
|
|
1610
|
+
notes: [
|
|
1611
|
+
'On changes_requested: the files the reviewer flagged, their comments, and a hint',
|
|
1612
|
+
'per failed rule. Fix, then `push --submit` again — untouched files keep their votes.',
|
|
1613
|
+
'--watch polls every 20 s and returns at the first final state, with an exit code',
|
|
1614
|
+
'a script can branch on: 0 transferred · 2 changes_requested (your move) ·',
|
|
1615
|
+
'1 rejected or transfer_failed. A network blip mid-watch is retried five times.',
|
|
1616
|
+
'A review can take days: for an unattended job prefer a webhook (see /developers).',
|
|
1617
|
+
'In --json, changes_requested carries the findings too.',
|
|
1618
|
+
],
|
|
1619
|
+
},
|
|
1620
|
+
recall: {
|
|
1621
|
+
usage: 'myelin recall <batch>',
|
|
1622
|
+
what: 'Takes a submitted delivery back before the review starts. It becomes a draft again.',
|
|
1623
|
+
notes: [
|
|
1624
|
+
'Only while the status is submitted. Once a reviewer has opened it (in_review) the',
|
|
1625
|
+
'API answers recall_failed; wait for their decision instead.',
|
|
1626
|
+
],
|
|
1627
|
+
},
|
|
1628
|
+
projects: {
|
|
1629
|
+
usage: 'myelin projects',
|
|
1630
|
+
what: 'Lists the projects this key is scoped to, with their status.',
|
|
1631
|
+
notes: [],
|
|
1632
|
+
},
|
|
1633
|
+
'sample-depth': {
|
|
1634
|
+
usage: 'myelin sample-depth <0-5> --dataset <ds>',
|
|
1635
|
+
what: 'Declares the folder depth a sample sits at. Optional; the default is 1.',
|
|
1636
|
+
notes: [
|
|
1637
|
+
' 0 the delivery root is one sample',
|
|
1638
|
+
' 1 each top-level folder is one sample (default)',
|
|
1639
|
+
' 2 one level deeper, and so on',
|
|
1640
|
+
'Set it only if the default is wrong for your layout. Idempotent; it locks once your',
|
|
1641
|
+
'first delivery leaves draft, so per-sample verdicts stay comparable.',
|
|
1642
|
+
],
|
|
1643
|
+
},
|
|
1644
|
+
roles: {
|
|
1645
|
+
usage: 'myelin roles [set <role> <path> | none <role> | clear <role>] --dataset <ds>',
|
|
1646
|
+
what: 'Shows, declares or clears which file plays which role. Optional; never locks.',
|
|
1647
|
+
notes: [
|
|
1648
|
+
'Roles: samplesheet, checksum_manifest, qc_report, subject_roster, capture_bed.',
|
|
1649
|
+
'Nothing is required to deliver. Declaring a role turns on the checks that read',
|
|
1650
|
+
'that file, so more of the contract is verified before review. `none` records that',
|
|
1651
|
+
'your dataset has no such file. Idempotent — safe to assert on every run.',
|
|
1652
|
+
],
|
|
1653
|
+
},
|
|
1654
|
+
sandbox: {
|
|
1655
|
+
usage: 'myelin sandbox <file>',
|
|
1656
|
+
what: 'Uploads one file under 4 MB as the partner-side test transfer your client’s bridge-activation checklist asks for.',
|
|
1657
|
+
notes: ['It proves the route works. Not a test environment, and not a step before push.'],
|
|
1658
|
+
},
|
|
1659
|
+
deliveries: {
|
|
1660
|
+
usage: 'myelin deliveries [--project <id>] [--dataset <id>] [--since <iso>] [--limit <n>] [--cursor <ts>]',
|
|
1661
|
+
what: 'What landed in your bucket, newest first. Needs a CLIENT key (Organisation → API keys).',
|
|
1662
|
+
notes: ['Page with --cursor, passing back the printed next_cursor verbatim.'],
|
|
1663
|
+
},
|
|
1664
|
+
resolve: {
|
|
1665
|
+
usage: 'myelin resolve <path|prefix|id>',
|
|
1666
|
+
what: 'What a path in your bucket is: project, dataset, delivery, file. Needs a CLIENT key.',
|
|
1667
|
+
notes: ['Accepts a gs:// or s3:// URI, a bare prefix, or a single id.'],
|
|
1668
|
+
},
|
|
1669
|
+
version: {
|
|
1670
|
+
usage: 'myelin version',
|
|
1671
|
+
what: 'Prints the CLI version.',
|
|
1672
|
+
notes: [],
|
|
1673
|
+
},
|
|
1674
|
+
help: {
|
|
1675
|
+
usage: 'myelin help [<command>]',
|
|
1676
|
+
what: 'This list, or one command in detail. Works without a key.',
|
|
1677
|
+
notes: [],
|
|
1678
|
+
},
|
|
1679
|
+
}
|
|
1038
1680
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1681
|
+
function cmdHelp(name) {
|
|
1682
|
+
if (name && !HELP[name]) die(`Unknown command "${name}" — run: myelin help`)
|
|
1683
|
+
// "Every command accepts --json" includes this one.
|
|
1684
|
+
if (JSON_MODE) {
|
|
1685
|
+
emit(name ? { command: name, ...HELP[name] } : { commands: HELP })
|
|
1686
|
+
return
|
|
1687
|
+
}
|
|
1688
|
+
if (name) {
|
|
1689
|
+
const h = HELP[name]
|
|
1690
|
+
console.log(`Usage: ${h.usage}\n\n${h.what}${h.notes.length ? '\n\n' + h.notes.join('\n') : ''}`)
|
|
1691
|
+
return
|
|
1692
|
+
}
|
|
1693
|
+
console.log(`myelin — deliver R&D data to your client through Myelin
|
|
1694
|
+
|
|
1695
|
+
Usage: myelin <command> [--json]
|
|
1696
|
+
myelin help <command>
|
|
1697
|
+
|
|
1698
|
+
Commands, in the order a first delivery runs them:
|
|
1699
|
+
ping the key works, and what it can reach
|
|
1700
|
+
datasets what you can deliver to, and whose move it is
|
|
1701
|
+
contract --dataset <ds> what the client expects of a delivery
|
|
1702
|
+
check <dir> --dataset <ds> their checks on your folder — nothing is uploaded
|
|
1703
|
+
push <dir> --dataset <ds> [--submit] upload (resumable, re-run to resume), then hand over
|
|
1704
|
+
status <batch> [--watch] follow the review; on changes_requested, what to fix
|
|
1705
|
+
|
|
1706
|
+
Optional:
|
|
1707
|
+
recall <batch> take a submitted delivery back before review starts
|
|
1708
|
+
projects the projects this key is scoped to
|
|
1709
|
+
sample-depth <0-5> --dataset <ds> the folder depth a sample sits at (default 1)
|
|
1710
|
+
roles … --dataset <ds> which file plays which role; turns on more checks
|
|
1711
|
+
sandbox <file> the connectivity test bridge activation asks for
|
|
1712
|
+
|
|
1713
|
+
Client-side commands (a CLIENT key, from Organisation → API keys):
|
|
1714
|
+
deliveries what landed in your bucket
|
|
1715
|
+
resolve <path|prefix|id> what a path in your bucket is
|
|
1064
1716
|
|
|
1065
1717
|
version print the CLI version
|
|
1718
|
+
help [<command>] this list, or one command in detail
|
|
1719
|
+
|
|
1720
|
+
Setup:
|
|
1721
|
+
export MYELIN_API_KEY=myl_live_… the key your bridge owner created in Bridge → API
|
|
1722
|
+
export MYELIN_API_URL=… optional; default https://myelinbridge.com/api/v1
|
|
1723
|
+
export MYELIN_API_HEADER="Name: value" optional; extra headers, one per line, for a gateway
|
|
1724
|
+
that authenticates ahead of Myelin
|
|
1725
|
+
export HTTPS_PROXY=http://proxy:8080 optional; HTTP_PROXY / NO_PROXY honoured too
|
|
1066
1726
|
|
|
1727
|
+
<ds> is a dataset slug (any case), an id, or an exact name, as \`datasets\` prints them.
|
|
1067
1728
|
Every command accepts --json. Exit codes: 0 ok · 1 error · 2 blocked.
|
|
1068
1729
|
Errors print the API request id — include it when reporting a problem.`)
|
|
1069
1730
|
}
|
|
@@ -1140,15 +1801,14 @@ async function cmdResolve() {
|
|
|
1140
1801
|
}
|
|
1141
1802
|
|
|
1142
1803
|
// ——— main ———
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1804
|
+
//
|
|
1805
|
+
// Order matters, and it is the order a developer without a key needs: help
|
|
1806
|
+
// and version answer before the key is looked at, an unknown command is
|
|
1807
|
+
// reported as unknown rather than as a missing key, and only a real command
|
|
1808
|
+
// about to call the API asks for MYELIN_API_KEY. The previous order checked
|
|
1809
|
+
// the key first, so `myelin push --help` and `myelin upload ./x` both answered
|
|
1810
|
+
// "Set MYELIN_API_KEY" — the manual was closed to exactly the person waiting
|
|
1811
|
+
// for a key (2026-09-19).
|
|
1152
1812
|
const commands = {
|
|
1153
1813
|
ping: cmdPing,
|
|
1154
1814
|
projects: cmdProjects,
|
|
@@ -1159,15 +1819,70 @@ const commands = {
|
|
|
1159
1819
|
check: cmdCheck,
|
|
1160
1820
|
push: cmdPush,
|
|
1161
1821
|
status: cmdStatus,
|
|
1822
|
+
recall: cmdRecall,
|
|
1162
1823
|
sandbox: cmdSandbox,
|
|
1163
1824
|
deliveries: cmdDeliveries,
|
|
1164
1825
|
resolve: cmdResolve,
|
|
1165
1826
|
}
|
|
1166
|
-
|
|
1167
|
-
|
|
1827
|
+
|
|
1828
|
+
// The options each command accepts. Anything else spelled `--…` is an error
|
|
1829
|
+
// before a byte moves: `push --sumbit` used to upload, not submit, and exit 0
|
|
1830
|
+
// — a green CI job that delivered nothing (first-contact review, 2026-09-19).
|
|
1831
|
+
// `--json`, `--help` and `-h` are global and handled before this table.
|
|
1832
|
+
const OPTIONS = {
|
|
1833
|
+
ping: [],
|
|
1834
|
+
projects: [],
|
|
1835
|
+
datasets: [],
|
|
1836
|
+
'sample-depth': ['dataset'],
|
|
1837
|
+
roles: ['dataset'],
|
|
1838
|
+
contract: ['dataset'],
|
|
1839
|
+
check: ['dataset'],
|
|
1840
|
+
push: ['dataset', 'submit', 'replace'],
|
|
1841
|
+
status: ['watch'],
|
|
1842
|
+
recall: [],
|
|
1843
|
+
sandbox: [],
|
|
1844
|
+
deliveries: ['project', 'dataset', 'since', 'limit', 'cursor'],
|
|
1845
|
+
resolve: [],
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
async function main() {
|
|
1849
|
+
if (!command || command === 'help' || command === '--help' || command === '-h') return cmdHelp(args[1])
|
|
1850
|
+
if (flag('help') || args.includes('-h')) return cmdHelp(command)
|
|
1851
|
+
if (command === 'version' || command === '--version' || command === '-v') {
|
|
1852
|
+
console.log(JSON_MODE ? JSON.stringify({ version: VERSION }) : VERSION)
|
|
1853
|
+
return
|
|
1854
|
+
}
|
|
1168
1855
|
const fn = commands[command]
|
|
1169
1856
|
if (!fn) die(`Unknown command "${command}" — run: myelin help`)
|
|
1857
|
+
const stray = args.slice(1).find((a) => a.startsWith('--') && !OPTIONS[command].includes(a.slice(2)))
|
|
1858
|
+
if (stray) die(`Unknown option "${stray}" for ${command} — run: myelin help ${command}`)
|
|
1859
|
+
// Usage before the key: `myelin push` with nothing else used to answer
|
|
1860
|
+
// "MYELIN_API_KEY is not set" instead of saying what push takes.
|
|
1861
|
+
const NEEDS = {
|
|
1862
|
+
contract: { dataset: true },
|
|
1863
|
+
check: { positional: true, dataset: true },
|
|
1864
|
+
push: { positional: true, dataset: true },
|
|
1865
|
+
'sample-depth': { positional: true, dataset: true },
|
|
1866
|
+
roles: { dataset: true },
|
|
1867
|
+
status: { positional: true },
|
|
1868
|
+
recall: { positional: true },
|
|
1869
|
+
sandbox: { positional: true },
|
|
1870
|
+
resolve: { positional: true },
|
|
1871
|
+
}[command]
|
|
1872
|
+
if (NEEDS) {
|
|
1873
|
+
const positional = args[1]
|
|
1874
|
+
const missingPositional = NEEDS.positional && (!positional || positional.startsWith('--'))
|
|
1875
|
+
if (missingPositional || (NEEDS.dataset && !opt('dataset'))) die(`Usage: ${HELP[command].usage}`)
|
|
1876
|
+
}
|
|
1877
|
+
// The first error every partner sees. It used to say "create a key in
|
|
1878
|
+
// Bridge → API" — an instruction to the one person who cannot do that.
|
|
1879
|
+
if (!KEY) die('MYELIN_API_KEY is not set. Your client creates the key for you (Bridge → API, on their side) and sends it. `myelin help` works without one.')
|
|
1880
|
+
assertProxyEnvUsable()
|
|
1170
1881
|
await fn()
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
try {
|
|
1885
|
+
await main()
|
|
1171
1886
|
} catch (err) {
|
|
1172
1887
|
// die() already printed and set the code; anything else is a real bug and
|
|
1173
1888
|
// deserves its stack.
|