@myelinbridge/cli 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +107 -6
  2. package/bin/myelin.js +482 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -45,12 +45,15 @@ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
45
45
 
46
46
  - **Your client's own fields show up, read-only (0.12.0).** Clients can define
47
47
  their own metadata fields on datasets (a sponsor study code, a work order, a
48
- therapeutic area). `contract` prints them under CLIENT FIELDS, and `check`
49
- tells you when required ones are not filled in yet — informational only:
50
- their team fills them in Myelin, there is nothing to change in your delivery,
51
- and metadata never moves the exit code. In `--json`, datasets carry a
52
- `metadata` object (`{key: {label, type, value}}`) and preflight carries
53
- `metadata.missing_required`.
48
+ therapeutic area). `contract` prints them under CLIENT FIELDS including a
49
+ required field nobody has filled in yet, shown as *"required, not filled
50
+ in yet"* (0.12.2) and `check` tells you the same thing via preflight;
51
+ informational only: their team fills them in Myelin, there is nothing to
52
+ change in your delivery, and metadata never moves the exit code. In
53
+ `--json`, datasets carry a `metadata` object
54
+ (`{key: {label, type, value, required?}}` — `required: true` and
55
+ `value: null` mark a required field with no value yet) and preflight
56
+ carries `metadata.missing_required`.
54
57
 
55
58
  - **A missing path in `roles set` is refused, not guessed (0.11.1).** Flags are
56
59
  not positional arguments: `roles set samplesheet --dataset onco1-wes` (path
@@ -68,6 +71,11 @@ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
68
71
  too (S3 multipart). Uploads go direct to storage over short-lived presigned
69
72
  URLs — no credential is stored on your machine, and revoking the API key cuts
70
73
  off signing immediately.
74
+ - **A corporate proxy is not our problem to ignore (0.13.0).** `HTTPS_PROXY`,
75
+ `HTTP_PROXY` and `NO_PROXY` are honoured on every call — the API and the part
76
+ uploads alike. Before this the CLI dialled straight out and a pipeline host
77
+ whose only egress is a proxy could not deliver at all. The hosts and ports to
78
+ allow are written down under **For your IT department** below.
71
79
  - **Transient failures retry themselves.** Since 0.8.0 a rate limit (`429`)
72
80
  waits out `Retry-After` and retries on every command, and a part upload that
73
81
  hits a storage hiccup (5xx, an edge timeout, an expired URL) re-signs, backs
@@ -190,6 +198,99 @@ version.
190
198
  | `MYELIN_API_KEY` | Required. Created by your bridge owner in **Bridge → API**. |
191
199
  | `MYELIN_API_URL` | Optional. Defaults to `https://myelinbridge.com/api/v1`. |
192
200
  | `MYELIN_API_HEADER` | Optional. Extra headers sent with every API call, one `Name: value` per line — for a Myelin deployment fronted by something that authenticates before Myelin does (a corporate gateway, an SSO-protected preview). It can never override `Authorization`. |
201
+ | `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` | Optional, honoured since **0.13.0** — see below. Lowercase spellings read too. |
202
+
203
+ ## For your IT department
204
+
205
+ Everything below is derived from what the code actually connects to. If you need
206
+ to hand one page to a network team, hand them this one.
207
+
208
+ ### What the CLI needs to reach
209
+
210
+ Outbound **TCP 443 only**, to two hosts:
211
+
212
+ | Host | Why |
213
+ |---|---|
214
+ | `myelinbridge.com` | the API — auth, the quality contract, preflight, part signing, submit, status |
215
+ | `cdjlhyyrbogzkoaujzmj.storage.supabase.co` | the bytes — uploads go **straight to storage** over short-lived presigned URLs, never through the API host |
216
+
217
+ The second one surprises people, and it is the single commonest way a delivery
218
+ half-works: `myelin ping` and `myelin check` succeed, `myelin push` stalls on
219
+ part 1. Since 0.13.0 that failure names the host it could not reach, so the
220
+ allowlist gap is legible from the error.
221
+
222
+ If your client runs Myelin at their own address, `MYELIN_API_URL` names the
223
+ first host and the second is that deployment's storage host. You never have to
224
+ guess it — every Myelin deployment publishes the authoritative list in its own
225
+ `content-security-policy` header:
226
+
227
+ ```bash
228
+ curl -sI https://myelinbridge.com | tr ';' '\n' | grep connect-src
229
+ # connect-src 'self' https://<ref>.supabase.co wss://<ref>.supabase.co https://<ref>.storage.supabase.co
230
+ ```
231
+
232
+ A wildcard rule works if your proxy prefers one: `*.supabase.co`.
233
+
234
+ ### What a person using the web portal needs
235
+
236
+ Someone reviewing a delivery in a browser needs the two hosts above **plus** the
237
+ Supabase API host, because sign-in and the session live there:
238
+
239
+ | Host | Port | Why |
240
+ |---|---|---|
241
+ | `myelinbridge.com` | 443 | the application itself |
242
+ | `cdjlhyyrbogzkoaujzmj.supabase.co` | 443 | sign-in and session |
243
+ | `cdjlhyyrbogzkoaujzmj.storage.supabase.co` | 443 | browser uploads, the same presigned PUTs the CLI makes |
244
+
245
+ Plain HTTPS is enough. The policy also permits `wss:` to the Supabase host, but
246
+ the portal opens no WebSocket today — if your proxy distinguishes the two, you
247
+ do not need to allow WebSocket upgrades for the portal to work.
248
+
249
+ Nothing else is contacted: scripts and fonts are served by the application
250
+ itself and no analytics or tracker host is involved. That is not a promise in a
251
+ README — it is the `content-security-policy` header above, which the browser
252
+ enforces on every response.
253
+
254
+ ### What you do *not* need to open
255
+
256
+ - **No inbound anything.** Nothing connects to your network. The one exception
257
+ is opt-in and yours to choose: if you register a webhook endpoint, Myelin
258
+ makes an outbound HTTPS call to an address you publish.
259
+ - **No access to the destination bucket.** The client's cloud bucket is written
260
+ by Myelin, server-side, after review. A partner never touches it and never
261
+ needs a credential for it.
262
+ - **No FTP, SSH, rsync or fixed source IPs.** One protocol, one port.
263
+
264
+ ### Behind a proxy
265
+
266
+ Set the standard variables — the CLI honours them on every call, the API and
267
+ the part uploads alike:
268
+
269
+ ```bash
270
+ export HTTPS_PROXY=http://proxy.corp.example:8080
271
+ export NO_PROXY=.corp.example,10.0.0.0/8
272
+ myelin ping # prints "via proxy http://proxy.corp.example:8080" when one is in use
273
+ ```
274
+
275
+ - `HTTPS_PROXY` for https targets, `HTTP_PROXY` for http, `NO_PROXY` to exclude.
276
+ Lowercase spellings (`https_proxy`, …) are read too — both are in the wild.
277
+ - Credentials go in the URL: `http://user:pass@proxy:8080` (Basic). They are
278
+ never printed, logged, or echoed in an error.
279
+ - `NO_PROXY` accepts a host, a suffix (`.corp.example`, `*.corp.example`),
280
+ `host:port`, an IPv4 CIDR (`10.0.0.0/8`), or `*` for everything.
281
+ - **HTTP CONNECT proxies only.** A `socks5://` value is refused with a message
282
+ rather than silently bypassed — a CLI that quietly dials direct while you
283
+ believe it is proxied is worse than one that stops.
284
+ - A typo in a proxy variable fails immediately, before any command runs.
285
+ - **TLS terminates at Myelin, not at your proxy.** Through a CONNECT tunnel the
286
+ proxy sees the hostname and nothing else. If your proxy performs TLS
287
+ interception, point Node at its CA:
288
+ `export NODE_EXTRA_CA_CERTS=/etc/ssl/corp-root.pem`.
289
+
290
+ Before 0.13.0 the CLI ignored all three variables — Node's `fetch` does — so on
291
+ a host whose only egress is a proxy every command failed with `fetch failed`.
292
+ If you are running an older version behind a proxy, upgrade rather than work
293
+ around it.
193
294
 
194
295
  ## Webhooks instead of polling
195
296
 
package/bin/myelin.js CHANGED
@@ -23,10 +23,44 @@
23
23
  // client has not filled in yet — informational ONLY: metadata never moves the
24
24
  // exit code (the client fills these in Myelin; nothing for a partner to fix,
25
25
  // and a pipeline must never halt on it).
26
+ //
27
+ // 0.12.1 — three display-only fixes, no behavior change. `check`'s
28
+ // human-readable output now labels every rule by `name` instead of
29
+ // `check_type`: the type is the underlying evaluator (many-to-one with
30
+ // rules), so two rules sharing one used to print identical, contradictory
31
+ // lines. `check`/`push` now refuse an omitted <dir> the same way `roles set`
32
+ // already refused an omitted <path> (0.11.1) — before, the following
33
+ // --dataset flag was silently swallowed as the directory and the command
34
+ // died with a raw ENOENT instead of its usage line. `resolve` no longer
35
+ // doubles a project's code ("AML-CART-3B — AML-CART-3B — Phase 3…") when the
36
+ // title already starts with it.
37
+ //
38
+ // 0.12.2 — a required field with no value yet used to be invisible in
39
+ // `contract` (it only ever rendered keys the dataset already had a value
40
+ // for), even though `check` correctly warned about it via preflight. The API
41
+ // now includes required-but-empty fields in `metadata` too
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.
26
56
 
27
57
  import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
28
58
  import { resolve, join, relative, sep, basename } from 'node:path'
29
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'
30
64
  import process from 'node:process'
31
65
 
32
66
  const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
@@ -137,13 +171,404 @@ function opt(name) {
137
171
  return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : null
138
172
  }
139
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
+
140
565
  // 429s are handled HERE so every command inherits the platform's documented
141
566
  // contract (honour Retry-After): bounded — 4 waits, then the error surfaces
142
567
  // like any other. The wait is capped at 60 s so a pathological header cannot
143
568
  // stall a pipeline for an hour.
144
569
  async function api(method, path, body, raw) {
145
570
  for (let attempt = 1; ; attempt++) {
146
- const res = await fetch(API_URL + path, {
571
+ const res = await httpRequest(API_URL + path, {
147
572
  method,
148
573
  headers: {
149
574
  ...EXTRA_HEADERS,
@@ -151,7 +576,7 @@ async function api(method, path, body, raw) {
151
576
  ...(body && !raw ? { 'Content-Type': 'application/json' } : {}),
152
577
  },
153
578
  body: raw ? body : body ? JSON.stringify(body) : undefined,
154
- }).catch((err) => die(`Cannot reach ${API_URL}: ${err.message}`))
579
+ }).catch((err) => die(`Cannot reach ${API_URL}: ${err.message}${egressHint(API_URL, err.message)}`))
155
580
  let json = null
156
581
  try { json = await res.json() } catch { /* non-JSON body */ }
157
582
  const requestId = res.headers.get('myelin-request-id') ?? json?.request_id ?? null
@@ -435,10 +860,19 @@ async function uploadFileParts(absPath, size, fileId, partSize, adoptLandedGeome
435
860
  if (url === undefined) url = await resign(partNo, bytes)
436
861
  if (url === null) break // part landed server-side — resign() recorded its etag
437
862
  const startedAt = Date.now()
438
- put = await fetch(url, { method: 'PUT', body: bytes }).catch(() => null)
863
+ let netErr = null
864
+ put = await httpRequest(url, { method: 'PUT', body: bytes }).catch((err) => { netErr = err; return null })
439
865
  if (put && put.status === 200) break
440
866
  const status = put ? put.status : 0
441
- const reason = put ? `HTTP ${put.status}` : 'network error'
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 ?? '')}`
442
876
  if (isTimeoutClassFailure(status, Date.now() - startedAt)) {
443
877
  const strikes = (timeoutStrikes.get(partNo) ?? 0) + 1
444
878
  timeoutStrikes.set(partNo, strikes)
@@ -479,6 +913,11 @@ async function cmdPing() {
479
913
  const j = expectOk(await api('GET', '/ping'), 'ping')
480
914
  emit(j)
481
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}`)
482
921
  }
483
922
 
484
923
  async function cmdProjects() {
@@ -582,7 +1021,17 @@ async function cmdContract() {
582
1021
  const metaKeys = Object.keys(meta)
583
1022
  if (metaKeys.length > 0) {
584
1023
  out('CLIENT FIELDS — how your client describes this dataset (read-only)')
585
- for (const k of metaKeys) out(` · ${meta[k].label}: ${fmtFieldValue(meta[k].value)}`)
1024
+ for (const k of metaKeys) {
1025
+ const f = meta[k]
1026
+ // A required field with no value yet is invisible unless we say so
1027
+ // here too — `myelin check` already warns about it via preflight, and
1028
+ // the two must agree on what's outstanding (0.12.0, cli-api-18).
1029
+ out(
1030
+ f.required
1031
+ ? ` · ${f.label} — required, not filled in yet`
1032
+ : ` · ${f.label}: ${fmtFieldValue(f.value)}`,
1033
+ )
1034
+ }
586
1035
  out('')
587
1036
  }
588
1037
  out(`Run "myelin check <dir> --dataset ${dsRef}" to test ${s.checkable_before_upload} of these locally.`)
@@ -591,7 +1040,10 @@ async function cmdContract() {
591
1040
  async function cmdCheck() {
592
1041
  const dir = args[1]
593
1042
  const dsRef = opt('dataset')
594
- if (!dir || !dsRef) die('Usage: myelin check <dir> --dataset <slug|id>')
1043
+ // Same trap as `roles set` (see the comment there): an omitted <dir> lets
1044
+ // the following --dataset flag slide into its place, which used to reach
1045
+ // resolve()/walkDir() and fail with a raw ENOENT instead of usage.
1046
+ if (!dir || dir.startsWith('--') || !dsRef) die('Usage: myelin check <dir> --dataset <slug|id>')
595
1047
  const root = resolve(dir)
596
1048
  const files = walkDir(root)
597
1049
  if (files.length === 0) die(`No files under ${root}`)
@@ -608,10 +1060,15 @@ async function cmdCheck() {
608
1060
  if (!JSON_MODE) {
609
1061
  // One width across all three lists so the verdicts line up, and the hint
610
1062
  // lines can still be interleaved under the check they belong to.
1063
+ // Labelled by `name`, not `check_type` — the type is the underlying
1064
+ // evaluator (e.g. "manifest_present") and is many-to-one with rules, so
1065
+ // two rules of the same type used to print identical, contradictory
1066
+ // lines. `name` is unique per rule and is what the manual list below
1067
+ // already used.
611
1068
  const w = Math.max(
612
1069
  0,
613
- ...j.evaluated.map((c) => c.check_type.length),
614
- ...j.deferred.map((d) => d.check_type.length),
1070
+ ...j.evaluated.map((c) => c.name.length),
1071
+ ...j.deferred.map((d) => d.name.length),
615
1072
  ...j.manual.map((m) => m.name.length),
616
1073
  )
617
1074
  for (const c of j.evaluated) {
@@ -620,7 +1077,7 @@ async function cmdCheck() {
620
1077
  : c.severity === 'blocking' && c.verdict === 'not_evaluated' ? ' — BLOCKING, and nobody looked'
621
1078
  : c.severity === 'must_acknowledge' && c.verdict === 'failed' ? ' — your reviewer confirms this'
622
1079
  : ''
623
- out(`${verdictMark(c.verdict)} ${c.check_type.padEnd(w)} ${verdictLabel(c.verdict)}${gate}`)
1080
+ out(`${verdictMark(c.verdict)} ${c.name.padEnd(w)} ${verdictLabel(c.verdict)}${gate}`)
624
1081
  // Why we could not look, in the partner's terms. `abstained: 'rule'` is
625
1082
  // the one that matters most: it says the fault is in the client's rule,
626
1083
  // not in this delivery, and without the sentence the partner spends a
@@ -644,7 +1101,7 @@ async function cmdCheck() {
644
1101
  (c.verdict === 'not_evaluated' && c.details?.abstained !== 'rule')
645
1102
  if (fixable && c.remediation) out(` hint: ${c.remediation}`)
646
1103
  }
647
- for (const d of j.deferred) out(`… ${d.check_type.padEnd(w)} ${d.reason}`)
1104
+ for (const d of j.deferred) out(`… ${d.name.padEnd(w)} ${d.reason}`)
648
1105
  for (const m of j.manual) out(`○ ${m.name.padEnd(w)} ${m.reason}`)
649
1106
  }
650
1107
 
@@ -716,7 +1173,10 @@ async function cmdCheck() {
716
1173
  async function cmdPush() {
717
1174
  const dir = args[1]
718
1175
  const dsRef = opt('dataset')
719
- if (!dir || !dsRef) die('Usage: myelin push <dir> --dataset <slug|id> [--submit] [--replace]')
1176
+ // Same trap as `roles set` (see the comment there): an omitted <dir> lets
1177
+ // the following --dataset flag slide into its place, which used to reach
1178
+ // resolve()/walkDir() and fail with a raw ENOENT instead of usage.
1179
+ if (!dir || dir.startsWith('--') || !dsRef) die('Usage: myelin push <dir> --dataset <slug|id> [--submit] [--replace]')
720
1180
  const root = resolve(dir)
721
1181
  const files = walkDir(root)
722
1182
  if (files.length === 0) die(`No files under ${root}`)
@@ -1003,6 +1463,8 @@ Setup:
1003
1463
  export MYELIN_API_URL=… (optional; default https://myelinbridge.com/api/v1)
1004
1464
  export MYELIN_API_HEADER="Name: value" (optional; extra headers, one per line —
1005
1465
  for a gateway that authenticates ahead of Myelin)
1466
+ export HTTPS_PROXY=http://proxy:8080 (optional; HTTP_PROXY / NO_PROXY honoured too,
1467
+ either case — see README "For your IT department")
1006
1468
 
1007
1469
  Commands:
1008
1470
  ping verify the key; show bridge + projects
@@ -1077,7 +1539,14 @@ async function cmdResolve() {
1077
1539
  const j = expectOk(await api('GET', `/deliveries/resolve?path=${encodeURIComponent(path)}`), 'resolve')
1078
1540
  emit(j)
1079
1541
  out(`${j.resolved} · ${j.query}`)
1080
- if (j.project) out(` project ${j.project.code} ${j.project.title}`)
1542
+ // Some seeded/client titles already embed the project code
1543
+ // ("AML-CART-3B — Phase 3 CAR-T…") — only prepend it when the title
1544
+ // doesn't already start with it, or the code prints twice.
1545
+ if (j.project) {
1546
+ const title =
1547
+ j.project.title && j.project.title.startsWith(j.project.code) ? j.project.title : `${j.project.code} — ${j.project.title}`
1548
+ out(` project ${title}`)
1549
+ }
1081
1550
  if (j.dataset) out(` dataset ${j.dataset.name}${j.dataset.label ? ` (${j.dataset.label})` : ''}`)
1082
1551
  if (j.delivery) {
1083
1552
  out(` batch ${j.delivery.batch_name ?? j.delivery.batch_id} · #${j.delivery.sequence_number ?? '?'}`)
@@ -1120,6 +1589,7 @@ const commands = {
1120
1589
  }
1121
1590
  try {
1122
1591
  if (!KEY) die('Set MYELIN_API_KEY (create a key in Bridge → API).')
1592
+ assertProxyEnvUsable()
1123
1593
  const fn = commands[command]
1124
1594
  if (!fn) die(`Unknown command "${command}" — run: myelin help`)
1125
1595
  await fn()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myelinbridge/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Myelin Partner Ingestion CLI — push R&D data deliveries from a pipeline: preflight against the client's quality rules, resumable upload, submit, track review outcomes.",
5
5
  "type": "module",
6
6
  "bin": {