@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.
Files changed (3) hide show
  1. package/README.md +352 -168
  2. package/bin/myelin.js +776 -61
  3. 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 fetch(API_URL + path, {
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
- 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 })
456
865
  if (put && put.status === 200) break
457
866
  const status = put ? put.status : 0
458
- 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 ?? '')}`
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
- out(`Draft ${created.resumed ? 'resumed' : 'created'} (${batchId.slice(0, 8)}…).`)
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 ${batchId} --watch`)
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 ${batchId} --watch`)
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
- if (!ref) die('Usage: myelin status <batch-id|display-name> [--watch]')
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 = async () => {
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
- emit({ batch })
892
- out(`${batch.display_name}: ${batch.status} ${batch.court === 'partner' ? 'your move.' : batch.court === 'reviewer' ? "reviewer's move." : batch.court === 'system' ? 'transferring…' : 'done.'}`)
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
- const next = await findBatch()
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
- function cmdHelp() {
1037
- console.log(`myelin — Partner Ingestion CLI
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
- Setup:
1040
- export MYELIN_API_KEY=myl_live_… (create in Bridge API)
1041
- export MYELIN_API_URL=… (optional; default https://myelinbridge.com/api/v1)
1042
- export MYELIN_API_HEADER="Name: value" (optional; extra headers, one per line —
1043
- for a gateway that authenticates ahead of Myelin)
1044
-
1045
- Commands:
1046
- ping verify the key; show bridge + projects
1047
- projects list scoped projects
1048
- datasets list datasets with "your move" hints
1049
- sample-depth <0-5> --dataset <slug|id> declare the folder depth a sample sits at (locks after 1st submit)
1050
- roles [set|none|clear …] --dataset <…> which file plays each role (samplesheet, checksum list, …) —
1051
- declaring one activates the checks that read it; never locks
1052
- contract --dataset <slug|id> what this dataset expects of your delivery
1053
- check <dir> --dataset <slug|id> preflight local files against quality rules (no upload)
1054
- push <dir> --dataset <slug|id> create/resume a delivery and upload (resumable; re-run to resume)
1055
- [--submit] [--replace]
1056
- status <batch|name> [--watch] review status + fix-loop findings
1057
- sandbox <file> partner-side test transfer (bridge activation)
1058
-
1059
- Client-side commands (need a CLIENT key Organisation → API keys):
1060
- deliveries [--project <id>] what landed in your destination bucket
1061
- [--dataset <id>] [--since <iso>] [--limit <n>] [--cursor <ts>]
1062
- resolve <path|prefix|id> what a path in your bucket actually is —
1063
- project, dataset, batch, file
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
- if (!command || command === 'help' || command === '--help') {
1144
- cmdHelp()
1145
- process.exit(0)
1146
- }
1147
- if (command === 'version' || command === '--version' || command === '-v') {
1148
- console.log(JSON_MODE ? JSON.stringify({ version: VERSION }) : VERSION)
1149
- process.exit(0)
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
- try {
1167
- if (!KEY) die('Set MYELIN_API_KEY (create a key in Bridge API).')
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.