@myelinbridge/cli 0.13.0 → 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 +306 -220
  2. package/bin/myelin.js +350 -60
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,195 +1,260 @@
1
1
  # @myelinbridge/cli
2
2
 
3
- Push R&D data deliveries into [Myelin](https://myelinbridge.com) from a pipeline —
4
- preflight against the client's published quality rules, resumable upload, submit,
5
- and track review outcomes. Full API reference: https://myelinbridge.com/developers.
3
+ Deliver R&D data to your client through [Myelin](https://myelinbridge.com), from a
4
+ terminal or a script: run the client's quality checks on a folder before uploading a
5
+ byte, upload with resume, hand the delivery over for review, follow the outcome.
6
6
 
7
- ## Quick start (~10 minutes from key to first submit)
7
+ Node 20 or later. Nothing to install: `npx @myelinbridge/cli <command>` fetches it on
8
+ each run. Or `npm i -g @myelinbridge/cli` once, and the binary is `myelin`.
9
+ Full API reference: https://myelinbridge.com/developers.
8
10
 
9
- ```bash
10
- export MYELIN_API_KEY=myl_live_… # created by your bridge owner in Bridge → API
11
-
12
- npx @myelinbridge/cli ping # verifies auth, prints your projects
13
- npx @myelinbridge/cli datasets # what you can deliver to, and whose move it is
14
- npx @myelinbridge/cli contract --dataset onco1-wes # what is expected of your delivery
15
- npx @myelinbridge/cli sample-depth 1 --dataset onco1-wes # once, before your first submit
16
- npx @myelinbridge/cli roles --dataset onco1-wes # which file plays which role
17
- npx @myelinbridge/cli roles set samplesheet /metadata/samplesheet.csv --dataset onco1-wes
18
- npx @myelinbridge/cli check ./run_042 --dataset onco1-wes # validate BEFORE uploading a byte
19
- npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
20
- ```
21
-
22
- - **Declare your sample depth once, first.** The client creates and describes the
23
- dataset; you own your output structure, so you tell Myelin at which folder depth a
24
- sample sits — `0` = the delivery root is one sample, `1` (default) = each top-level
25
- folder is a sample, `2` = one level deeper. Sample-scoped quality checks group by it,
26
- so getting it right up front is what makes per-sample verdicts mean anything. It is
27
- **idempotent** (safe to assert on every pipeline run, before and after the lock) and
28
- **locks once your first batch leaves draft**, so that verdicts stay comparable across
29
- deliveries — after that, re-asserting the current value still succeeds and only a
30
- *change* exits `2`. This is the only dataset field you can write.
31
-
32
- - **Declare which file plays which role — whenever you like (0.11.0).** Quality
33
- checks read files by ROLE (`samplesheet`, `checksum_manifest`, `qc_report`,
34
- `subject_roster`, `capture_bed`), not by a filing convention you never agreed
35
- to. `roles` shows what is declared and what Myelin detected in your
36
- deliveries; `roles set <role> <path>` confirms a file; `roles none <role>`
37
- states your dataset has no such file (a statement, not a gap). Nothing is
38
- ever required to deliver — an undeclared role just means the checks that read
39
- it report *"could not check"* instead of running, so declaring is how you
40
- activate more checks before upload and catch problems before review does.
41
- Unlike `sample-depth` it **never locks**, and it is idempotent, so a pipeline
42
- can assert it on every run. The samplesheet is **sticky**: send it once and
43
- every later delivery reconciles against it — a partial delivery (93 of 96) is
44
- reported as a state, never as a failure.
45
-
46
- - **Your client's own fields show up, read-only (0.12.0).** Clients can define
47
- their own metadata fields on datasets (a sponsor study code, a work order, a
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`.
57
-
58
- - **A missing path in `roles set` is refused, not guessed (0.11.1).** Flags are
59
- not positional arguments: `roles set samplesheet --dataset onco1-wes` (path
60
- omitted) used to declare the samplesheet at the literal path `--dataset` and
61
- print a tick. Every samplesheet check then reported *"could not check — the
62
- declared file is not in the delivery"* on a delivery that was fine. The
63
- command now exits with its usage line instead.
64
-
65
- - **`--dataset` takes the slug in any case (0.11.0).** Slugs are matched
66
- case-insensitively (`onco1-wes` and `ONCO1-WES` name the same dataset); an id
67
- or an exact name works too.
68
-
69
- - **Resume = re-run.** `push` is idempotent: already-uploaded files are skipped
70
- (path + size), and within a large file, parts that already landed are skipped
71
- too (S3 multipart). Uploads go direct to storage over short-lived presigned
72
- URLs — no credential is stored on your machine, and revoking the API key cuts
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.
79
- - **Transient failures retry themselves.** Since 0.8.0 a rate limit (`429`)
80
- waits out `Retry-After` and retries on every command, and a part upload that
81
- hits a storage hiccup (5xx, an edge timeout, an expired URL) re-signs, backs
82
- off, and retries up to 4 attempts before surfacing — an unattended pipeline
83
- run survives a blip. A retried `--submit` whose first attempt actually landed
84
- gets a success echo (`already_submitted: true`), not a false failure.
85
- - **Slow connections shrink their parts.** Since 0.9.0, when the same part dies
86
- twice on a timeout (the edge kills any PUT that runs too long — HTTP 524),
87
- `push` restarts the file with smaller parts, 64 → 16 → 5 MB, instead of
88
- retrying the same slice into the same wall. A hospital-grade uplink down to
89
- roughly 30 KB/s can now complete a delivery; it is slower, but it finishes.
90
- - **Every file is fingerprinted.** Since 0.7.0, `push` computes an MD5 of each
91
- file while it uploads and records it with the delivery. If the client's
92
- quality contract includes a checksum-manifest check, your delivery verifies
93
- against your own `md5_manifest.csv` instead of reading as "unverifiable".
94
- - **Read the contract before you build the delivery.** `contract` prints what the
95
- client expects — every check as one plain sentence, grouped by what it answers
96
- (completeness, structure, validity, consistency, integrity, privacy), and marked
97
- `!` when a failure blocks validation or `?` when the client's reviewer confirms
98
- it rather than asking you to fix it. It also tells you which checks `check` can
99
- verify locally and which only run once the files are uploaded, so nothing about
100
- the bar is a surprise at review time.
101
-
102
- - **`check` costs nothing.** It evaluates your local file list against the dataset's
103
- quality checks server-side — same engine, same verdicts as submit — without
104
- uploading. Exit code 2 means the delivery would not be validatable as it stands:
105
- a blocking rule fails, **or** a blocking rule could not be evaluated at all.
106
- - **A check has five answers, not three** (since 0.10.0). Alongside *passed*,
107
- *flagged* and *failed*, the engine can now say it **could not check** something
108
- (a missing precondition, or a rule it cannot evaluate) and that a rule **does
109
- not apply** to your delivery. This matters to you in one specific way: a rule
110
- the client wrote badly used to come back as a *failure on your delivery*. It
111
- now says the rule could not be evaluated, and tells you it is not yours to fix.
112
- `check` prints the count it could not check and what each one needs — supply it
113
- and those checks run on your next delivery, which is fewer rejections later.
114
- - **Some checks are confirmed, not fixed.** A rule marked *needs confirmation*
115
- asks nothing of you: the client's reviewer records what they decided and the
116
- delivery proceeds. `check` reports these and never exits 2 on them.
117
- - **The fix loop is machine-readable.** On `changes_requested`,
118
- `myelin status <batch> --json` returns the failed files, reviewer comments, and
119
- rule remediation hints; fix, re-`push --submit`, unchanged files keep their
120
- review votes.
121
- - **Limits.** `push` declares 500 files per call and chunks automatically — nothing
122
- for you to split. `check` sends your whole file list in one preflight
123
- call, which accepts at most 10 000 files — a larger delivery cannot be preflighted
124
- by `check` today (the API answers `400 too_many_files`); its checks still run at
125
- submit. A delivery holds at most 25 000 files (submit refuses above that with
126
- `422 submit_blocked`), and a checksum manifest is inspected up to 16 MiB.
127
-
128
- ## If you are the client, not the partner
11
+ ## First delivery
129
12
 
130
- Two kinds of key exist, and they are not interchangeable. Everything above needs a
131
- **partner key** (write: upload, submit). A **client key** is read-only and answers the
132
- question your own systems ask once the data has landed: *the bucket is full of UUIDs —
133
- what is this?*
134
-
135
- Where they live follows from what they are. A **partner key belongs to one bridge**, because a
136
- partner does. A **client key belongs to your organisation**: one key, every partner, one answer.
137
- Your organisation admin creates it in **Organisation → API keys**. To narrow one to a single
138
- partner's data, scope it to that partner's projects.
13
+ Your client's bridge owner creates your key in **Bridge API**. It starts with
14
+ `myl_live_` and is shown once.
139
15
 
140
16
  ```bash
141
- export MYELIN_API_KEY=myl_live_…
17
+ export MYELIN_API_KEY=myl_live_… # Windows: setx MYELIN_API_KEY myl_live_… (then open a new terminal)
18
+
19
+ npx @myelinbridge/cli ping # 1. the key works, and what it can reach
20
+ npx @myelinbridge/cli datasets # 2. what you can deliver to
21
+ npx @myelinbridge/cli contract --dataset onco1-wes # 3. what the client expects of a delivery
22
+ npx @myelinbridge/cli check ./run_042 --dataset onco1-wes # 4. their checks on your folder — nothing is uploaded
23
+ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit # 5. upload, then hand over for review
24
+ npx @myelinbridge/cli status ONCO1-WES-001 --watch # 6. follow the review
25
+ ```
142
26
 
143
- # What has landed, newest first (page with --cursor from the printed next_cursor)
144
- myelin deliveries --dataset <dataset-id>
27
+ That is the whole path. Four things worth knowing about it:
28
+
29
+ - **`check` uploads nothing.** It sends your file list, and the same engine that runs
30
+ at submit answers. Exit code `2` means the delivery could not be validated as it
31
+ stands: a blocking check failed, or a blocking check could not be evaluated at all.
32
+ Exit `0` here means the checks that can run before upload pass; checks that read
33
+ file contents run at submit, and `contract` tells you which ones. Above 10 000 files
34
+ `check` cannot run (`too_many_files`, exit `1`): skip it, the same checks run at submit.
35
+ - **`push` sends every file under the folder** (dotfiles included, symlinks followed; a
36
+ broken link or an unreadable file stops the command and is named), and it is safe to
37
+ re-run: files already uploaded are skipped, and inside a large file, parts that already
38
+ landed are skipped too. A crashed run resumes with the same command. Stage a clean
39
+ folder if your pipeline leaves work files behind. `push` does not run `check` first.
40
+ Without `--submit` the draft stays open, one per dataset, and you submit it later, from
41
+ the CLI or from the portal; a later `push` of a different folder joins that same draft.
42
+ At the end it prints the delivery's name (`ONCO1-WES-001`), which `status` and `recall`
43
+ take.
44
+ - **One delivery at a time per dataset.** While a delivery is submitted or in review,
45
+ `push` to that dataset exits `2` with `delivery_locked` until the reviewer validates it
46
+ or sends it back. `recall` takes it back while it is still `submitted`. If you deliver
47
+ more often than your client reviews, agree a turnaround with them, or ask for one
48
+ dataset per run.
49
+ - **After `--submit`, a person decides.** A reviewer on your client's side opens the
50
+ delivery in their portal and either validates it, after which Myelin copies the files
51
+ into their bucket, or asks for changes; the turnaround is theirs, and Myelin notifies
52
+ them. `status` tells you what to fix: on `changes_requested` it prints the files the
53
+ reviewer flagged, their comments, and a hint per failed rule. Fix, `push --submit`
54
+ again: files you did not touch keep their review votes.
55
+ - **`datasets` empty, or `dataset_not_active`?** The client has not finished setup on
56
+ their side. Three things must exist first, all created by them: an active bridge, a
57
+ project your key is scoped to, and an activated dataset inside it. Ask your client
58
+ contact, naming the missing piece.
59
+
60
+ ### What you will see
61
+
62
+ An example session, human mode. Your names, rules and counts will differ; the shape will not.
145
63
 
146
- # What is this object, exactly?
147
- myelin resolve gs://acme-landing/inbox/acme-cro/4319…/de99…/7aa5…/data/SAMPLE_01/reads.fastq.gz
148
- # file · gs://…/reads.fastq.gz
149
- # project ONCO1 Oncology discovery
150
- # dataset WES batch 7 (Genomics)
151
- # batch ONCO1-WES-007 · #7
152
- # validated 2026-08-07T18:05:12Z · delivered 2026-08-07T18:06:20Z
153
- # manifest gs://…/7aa5…/_myelin/manifest.json
154
- # file reads.fastq.gz · 4096 bytes
64
+ ```
65
+ $ myelin ping
66
+ key "pipeline" (myl_live_Yxwfeh…) · bridge Acme ↔ Genos CRO · 1 project(s)
67
+ Next: myelin datasets
68
+
69
+ $ myelin datasets
70
+ PROJECT DATASET STATUS YOUR MOVE?
71
+ ONCO1 onco1-wes active —
72
+ Next: myelin contract --dataset onco1-wes
73
+
74
+ $ myelin contract --dataset onco1-wes
75
+ WES cohort — delivery contract v3
76
+ 6 checks · 3 block validation · 5 checkable before upload · 1 reviewed by a person
77
+
78
+ COMPLETENESS — is everything here?
79
+ ! a samplesheet is present at the delivery root
80
+ ! every sample in the samplesheet has R1 and R2 reads
81
+ STRUCTURE — is it laid out as agreed?
82
+ · file names follow {sample}_{lane}_{R1|R2}.fastq.gz
83
+ INTEGRITY — are the bytes what you say they are?
84
+ ! every file matches your checksum manifest (checked at submission)
85
+ REVIEWER JUDGEMENT — decided by a person, not the engine
86
+ · the consent scope covers this cohort
87
+
88
+ Run "myelin check <dir> --dataset onco1-wes" to test 5 of these locally.
89
+
90
+ $ myelin check ./run_042 --dataset onco1-wes
91
+ Evaluating 129 files (26.1 GB) against quality checks v3…
92
+ ✓ samplesheet present passed
93
+ ✗ paired reads complete failed — BLOCKING
94
+ hint: S07 has R1 but no R2 — add S07_L001_R2.fastq.gz or drop S07 from the samplesheet
95
+ ✓ file naming passed
96
+ … checksum manifest matches reads file contents; runs at submit
97
+ ○ consent scope a reviewer decides this
98
+ 1 blocking issue(s). Fix before pushing to avoid a review round-trip.
99
+ $ echo $?
100
+ 2
101
+
102
+ $ myelin push ./run_042 --dataset onco1-wes --submit # after the fix
103
+ Draft ONCO1-WES-004 created.
104
+ Uploading 130/130 files (26.1 GB) · resumable
105
+ ✓ /samplesheet.csv (1/130)
106
+ ✓ /S01/S01_L001_R1.fastq.gz (2/130)
107
+
108
+ All files confirmed.
109
+ Submitting… auto-checks: 5 passed, 0 flagged, 0 failed of 6.
110
+ ✓ Batch submitted for review. Track: myelin status ONCO1-WES-004 --watch
111
+
112
+ $ myelin status ONCO1-WES-004 --watch
113
+ ONCO1-WES-004: submitted — reviewer's move.
114
+ ONCO1-WES-004: in_review — reviewer's move.
115
+ ONCO1-WES-004: transferred — delivered to your client’s bucket.
155
116
  ```
156
117
 
157
- `resolve` accepts a full `gs://`/`s3://` URI, a bare prefix, or a single id, and answers at
158
- whatever granularity the path supports.
118
+ ### Two optional declarations
159
119
 
160
- Each delivery also carries the same record **as a file**, written next to the data at
161
- `_myelin/manifest.json` (plus `_myelin/files.csv`, a flat table you can load straight into a
162
- warehouse). Prefer the file for anything auditable: it is frozen at delivery time, needs no
163
- credentials, and does not depend on Myelin being reachable. Use the API when you want it live.
120
+ - **`sample-depth`** is the folder depth a sample sits at: `1` means each top-level
121
+ folder is one sample and is the default, `0` means the whole delivery is one sample,
122
+ `2` one level deeper. Set it only if the default is wrong for your layout. It locks
123
+ once your first delivery leaves draft, so per-sample verdicts stay comparable; if you
124
+ are not sure which depth your client expects, ask before the first submit.
125
+ - **`roles`** says which file plays which role: `samplesheet`, `checksum_manifest`,
126
+ `qc_report`, `subject_roster`, `capture_bed`. Nothing is required to deliver; a role
127
+ you declare turns on the checks that read that file, so more of the contract is
128
+ verified before review. `roles none <role>` records that your dataset has no such
129
+ file. It never locks. A checksum manifest is read in the shapes tools write: plain
130
+ `md5sum` output, or a delimited file with a filename column and an md5 column, paths
131
+ relative to the delivery root.
132
+
133
+ ## Commands
164
134
 
165
- A partner key calling these gets `403 wrong_key_side`, and vice versa.
135
+ `<ds>` is a dataset slug (any case), an id, or an exact name, as `datasets` prints them.
166
136
 
167
- ## Machine mode
137
+ | Command | Key | What it does |
138
+ |---|---|---|
139
+ | `ping` | partner | Checks the key and prints the bridge and the projects it can reach. |
140
+ | `datasets` | partner | Lists the datasets you can deliver to, and whose move it is. |
141
+ | `contract --dataset <ds>` | partner | Every check the dataset expects, one sentence each: `!` blocks validation, `~` is confirmed by the client's reviewer rather than fixed by you. Says which checks `check` can run locally. |
142
+ | `check <dir> --dataset <ds>` | partner | Runs the dataset's checks on your local file list, server-side, without uploading. Exit `2` when a blocking check fails or could not be evaluated. |
143
+ | `push <dir> --dataset <ds> [--submit] [--replace]` | partner | Creates or resumes the dataset's draft and uploads what has not landed yet. `--submit` hands it to review. `--replace` overwrites a path already declared with a different size or checksum, which otherwise exits `2`. |
144
+ | `status <batch> [--watch]` | partner | Where the delivery stands and whose move it is; on `changes_requested`, what to fix. Takes an id or a display name. `--watch` polls every 20 s and returns at the first final state: exit `0` at `transferred`, `2` at `changes_requested`, `1` at `rejected` or `transfer_failed`. A review can take days: for an unattended job, prefer a webhook. |
145
+ | `recall <batch>` | partner | Takes a submitted delivery back before the review starts; it becomes a draft again. Once a reviewer has opened it, `recall_failed`. |
146
+ | `projects` | partner | The projects this key is scoped to, with their status. |
147
+ | `sample-depth <0-5> --dataset <ds>` | partner | Declares the folder depth a sample sits at. Idempotent; locks once a delivery leaves draft. |
148
+ | `roles [set <role> <path> / none <role> / clear <role>] --dataset <ds>` | partner | Shows, declares or clears which file plays which role. Idempotent, never locks. |
149
+ | `sandbox <file>` | partner | Uploads one file under 4 MB as the partner-side test transfer your client's bridge-activation checklist asks for. Not a test environment, and not a step before `push`. |
150
+ | `deliveries [--project <id>] [--dataset <id>] [--since <iso>] [--limit <n>] [--cursor <ts>]` | client | What landed in your bucket, newest first. Page with `--cursor` from the printed `next_cursor`. |
151
+ | `resolve <path or prefix or id>` | client | A `gs://` or `s3://` path, a bare prefix or an id, back to the project, dataset, delivery and file it belongs to. |
152
+ | `version` | | Prints the CLI version. |
153
+ | `help [<command>]` | | This list, or one command in detail. Works without a key. |
154
+
155
+ ## From a script
156
+
157
+ Every command takes `--json`: one JSON object on stdout, no prose. Exit codes:
158
+ `0` ok · `1` error · `2` blocked, meaning a blocking check failed or could not be
159
+ evaluated, a declared path conflicts, or the API refused on the delivery's state or the
160
+ client's setup (`delivery_locked`, `submit_blocked`, `dataset_not_active`,
161
+ `api_disabled`, `bridge_paused`, `sample_depth_locked`).
168
162
 
169
- Every command takes `--json`. Exit codes: `0` ok · `1` error · `2` blocked
170
- (blocking preflight failure, **a blocking rule the engine could not evaluate**,
171
- locked delivery, blocked submit, locked sample depth).
163
+ ```bash
164
+ myelin check "$OUT" --dataset onco1-wes || exit $?
165
+ myelin push "$OUT" --dataset onco1-wes --submit
166
+ ```
172
167
 
173
- `check --json` carries the numbers to branch on rather than the prose:
168
+ Things a script should know:
169
+
170
+ - **An unknown option is an error** (`push --sumbit` exits `1` before a byte moves).
171
+ - **Pin the version you tested** in a pipeline: `npx @myelinbridge/cli@<version>`. Bare
172
+ `npx @myelinbridge/cli` runs whatever is latest today. `--json` shapes and exit codes
173
+ follow the API's rule: fields may appear, existing ones keep their meaning.
174
+ - **Rotating a key revokes the old one at once.** There is no overlap window, so rotate
175
+ between runs, not during one; a run that starts with a revoked key fails `401` and
176
+ resumes with the new key. A key may also carry an expiry the client chose when creating
177
+ it (`GET /v1/me` → `expires_at`).
178
+ - **No client-side timeout on API calls, by design**: a preflight of 10 000 files takes
179
+ minutes. Bound the command with your runner's timeout if you need one. A connection
180
+ refused, a DNS failure or a socket that never answers is reported at once (exit `1`) and
181
+ not retried; a `429` and a failed part upload are (see below).
182
+ - **`status --watch` exits `0` at `transferred`, `2` at `changes_requested`, `1` at
183
+ `rejected` or `transfer_failed`.** A network blip mid-watch is retried five times; if the
184
+ runner dies, re-run it: the state is server-side and nothing is lost.
185
+ - **`--json` shapes.** `ping`, `contract`, `check`, `deliveries` and `resolve` print the API
186
+ response as is. `datasets` prints `{ datasets: [{ project, dataset, status, quality_version,
187
+ open_draft, id }] }`. `push` prints `{ batch_id, files, submitted: false }` for a draft and
188
+ the submit response (`{ batch_id, status, already_submitted, auto_checks }`) with
189
+ `--submit`. `status` prints `{ batch }`, plus `findings` on `changes_requested`. The delivery
190
+ name that `status` takes is `batch.display_name`; the id works too.
191
+ - **Paths** are relative to the delivery root, `/`-separated; a leading `/` is optional.
192
+
193
+ `check --json` carries the numbers to branch on:
174
194
 
175
195
  | Field | |
176
196
  |---|---|
177
- | `blocking_failures` | Blocking rules that were evaluated and failed. Unchanged meaning since the first release. |
178
- | `blocking_not_evaluated` | Blocking rules the engine could not evaluate. **New in 0.10.0** — these stop the reviewer validating just as surely, which is why `check` now exits `2` on them too. |
179
- | `must_acknowledge_failures` | Rules the client asked to be told about. Reported, never exited on. |
180
- | `counts` | `passed`, `flagged`, `failed`, `not_evaluated`, `not_applicable` (which sum to `evaluated`), plus `deferred`, `manual` and `checks` (`evaluated + deferred + manual === checks`). |
181
-
182
- Every per-check result carries `details.abstained` when the engine did not
183
- conclude: `rule` means **the client's rule** could not be evaluated and there is
184
- nothing on your side to fix; `data` means something the check needs is missing
185
- from the delivery; `nothing_to_evaluate` means the rule matched none of your
186
- files. Branch on that before you page anyone.
187
-
188
- API errors carry structure, not just prose: in `--json` mode an error is
189
- `{ "error", "code", "status", "request_id" }` branch on `code`, and quote the
190
- `request_id` when reporting a problem (it is printed in human mode too; it lets
191
- Myelin find the exact server-side log line). `myelin version` prints the CLI
192
- version.
197
+ | `blocking_failures` | Blocking checks that were evaluated and failed. |
198
+ | `blocking_not_evaluated` | Blocking checks the engine could not evaluate. These stop the reviewer validating just as surely, so `check` exits `2` on them too. |
199
+ | `must_acknowledge_failures` | Checks the client asked to be told about. Reported, never exited on. |
200
+ | `counts` | `passed`, `flagged`, `failed`, `not_evaluated`, `not_applicable`, plus `deferred` and `manual`. |
201
+ | `metadata.missing_required` | The client's own required fields still empty on the dataset. Informational: their team fills them in Myelin, nothing to change in your delivery, and it never moves the exit code. |
202
+
203
+ A check that did not conclude says why in `details.abstained`: `rule` means the
204
+ client's own rule could not be evaluated and there is nothing on your side to fix;
205
+ `data` means something the check needs is missing from your delivery; `nothing_to_evaluate`
206
+ means the rule matched none of your files.
207
+
208
+ An API error in `--json` mode is `{ "error", "code", "status", "request_id" }`. Branch on
209
+ `code`; quote `request_id` when you report a problem, it names the exact server-side log
210
+ line. The id is printed in human mode too.
211
+
212
+ ## What push does when something goes wrong
213
+
214
+ - **A crash or a lost connection.** Re-run the same command. A file already uploaded at
215
+ the same path and size is skipped; within a large file, parts that already landed are
216
+ skipped.
217
+ - **A rate limit.** A `429` waits out `Retry-After` and retries, on every command.
218
+ - **A part that fails.** On a storage error, an edge timeout or an expired upload URL,
219
+ the part is re-signed and retried with backoff, four attempts, before the error surfaces.
220
+ - **A slow uplink.** When the same part dies twice on a timeout, the file restarts with
221
+ smaller parts, 64 → 16 → 5 MiB. A link down to roughly 30 KB/s completes; slowly, but
222
+ it finishes.
223
+ - **A retried `--submit`.** If the first attempt actually landed, the retry is answered
224
+ with `already_submitted: true`, not a failure.
225
+ - **Checksums.** An MD5 of each file is computed while it uploads and recorded with the
226
+ delivery, so a checksum-manifest check verifies against your own manifest.
227
+
228
+ Uploads go straight to storage over short-lived presigned URLs. No credential for the
229
+ client's storage is ever on your machine, and revoking the key cuts off signing at once.
230
+
231
+ ## Limits
232
+
233
+ | | |
234
+ |---|---|
235
+ | Files per delivery | 25 000. Submit refuses above that. |
236
+ | Files per `check` | 10 000 in one call. A larger delivery cannot be preflighted by `check`; its checks still run at submit. |
237
+ | Files per declare call | 500. `push` chunks automatically. |
238
+ | Per-file size | 20 GiB by default, set per deployment. |
239
+ | Checksum manifest | Inspected up to 16 MiB. |
240
+ | API rate | 120 requests per minute per key. Part uploads go direct to storage and do not count. |
241
+ | Throughput | On the order of 100 MB/s aggregate on a clean link (four parts in parallel); a 100 GB run is around twenty minutes. Your uplink is the limit. |
242
+
243
+ ## Where your files are, and for how long
244
+
245
+ Uploads land in Myelin's holding area, Supabase Storage in the EU (Ireland), never in your
246
+ client's bucket. There they can be read by you, by your client's reviewers on that project,
247
+ and by the transfer worker's service identity; Myelin's own operator role has no
248
+ customer-data access. Once the reviewer validates, the files are copied into the client's
249
+ bucket and the copy is verified. The holding copy is purged after that verified delivery, or
250
+ after a rejection, plus a grace window of seven days by default; each purge writes an audit
251
+ row. An empty draft with no activity for 24 hours is purged; a draft holding uploaded files
252
+ is kept, and its owner reminded. The full position is at https://myelinbridge.com/security.
253
+
254
+ There is no self-serve test environment. To rehearse without real data, ask your client for
255
+ a dataset created for that purpose, or ask Myelin for a sandbox. Questions about the setup on
256
+ the client's side go to your client contact; questions about the API go to the address on
257
+ https://myelinbridge.com/developers.
193
258
 
194
259
  ## Environment
195
260
 
@@ -197,13 +262,39 @@ version.
197
262
  |---|---|
198
263
  | `MYELIN_API_KEY` | Required. Created by your bridge owner in **Bridge → API**. |
199
264
  | `MYELIN_API_URL` | Optional. Defaults to `https://myelinbridge.com/api/v1`. |
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. |
265
+ | `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. It can never override `Authorization`. |
266
+ | `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` | Optional. Honoured on every call; lowercase spellings read too. See below. |
267
+
268
+ ## If you are the client, not the partner
269
+
270
+ Everything above needs a **partner key**. A **client key** is read-only and answers the
271
+ question your own systems ask once the data has landed: the bucket is full of ids, what
272
+ is this? Your organisation admin creates it in **Organisation → API keys**; one key covers
273
+ every partner. To narrow one to a single partner's data, scope it to that partner's projects.
274
+
275
+ ```bash
276
+ export MYELIN_API_KEY=myl_live_…
277
+
278
+ myelin deliveries --dataset <dataset-id> # what has landed, newest first
279
+ myelin resolve gs://acme-landing/inbox/acme-cro/4319…/de99…/7aa5…/data/SAMPLE_01/reads.fastq.gz
280
+ # file · gs://…/reads.fastq.gz
281
+ # project ONCO1 — Oncology discovery
282
+ # dataset WES cohort (Genomics)
283
+ # batch ONCO1-WES-007 · #7
284
+ # validated 2026-08-07T18:05:12Z · delivered 2026-08-07T18:06:20Z
285
+ # manifest gs://…/7aa5…/_myelin/manifest.json
286
+ ```
287
+
288
+ Each delivery also carries the same record **as a file**, written next to the data at
289
+ `_myelin/manifest.json`, with `_myelin/files.csv` beside it as a flat table. Prefer the
290
+ file for anything auditable: it is frozen at delivery time, needs no credentials, and does
291
+ not depend on Myelin being reachable. A partner key calling these gets `403 wrong_key_side`,
292
+ and vice versa.
202
293
 
203
294
  ## For your IT department
204
295
 
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.
296
+ Everything below is derived from what the code actually connects to. If you need to hand
297
+ one page to a network team, hand them this one.
207
298
 
208
299
  ### What the CLI needs to reach
209
300
 
@@ -215,14 +306,14 @@ Outbound **TCP 443 only**, to two hosts:
215
306
  | `cdjlhyyrbogzkoaujzmj.storage.supabase.co` | the bytes — uploads go **straight to storage** over short-lived presigned URLs, never through the API host |
216
307
 
217
308
  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.
309
+ half-works: `myelin ping` and `myelin check` succeed, `myelin push` stalls on part 1.
310
+ That failure names the host it could not reach, so the allowlist gap is legible from
311
+ the error.
221
312
 
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:
313
+ If your client runs Myelin at their own address, `MYELIN_API_URL` names the first host
314
+ and the second is that deployment's storage host. You never have to guess it: every
315
+ Myelin deployment publishes the authoritative list in its own `content-security-policy`
316
+ header:
226
317
 
227
318
  ```bash
228
319
  curl -sI https://myelinbridge.com | tr ';' '\n' | grep connect-src
@@ -242,29 +333,28 @@ Supabase API host, because sign-in and the session live there:
242
333
  | `cdjlhyyrbogzkoaujzmj.supabase.co` | 443 | sign-in and session |
243
334
  | `cdjlhyyrbogzkoaujzmj.storage.supabase.co` | 443 | browser uploads, the same presigned PUTs the CLI makes |
244
335
 
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.
336
+ Plain HTTPS is enough. The policy also permits `wss:` to the Supabase host, but the
337
+ portal opens no WebSocket today; if your proxy distinguishes the two, you do not need to
338
+ allow WebSocket upgrades for the portal to work.
248
339
 
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.
340
+ Nothing else is contacted: scripts and fonts are served by the application itself and no
341
+ analytics or tracker host is involved. That is not a promise in a README; it is the
342
+ `content-security-policy` header above, which the browser enforces on every response.
253
343
 
254
344
  ### What you do *not* need to open
255
345
 
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.
346
+ - **No inbound anything.** Nothing connects to your network. The one exception is opt-in
347
+ and yours to choose: if you register a webhook endpoint, Myelin makes an outbound HTTPS
348
+ call to an address you publish.
349
+ - **No access to the destination bucket.** The client's cloud bucket is written by
350
+ Myelin, server-side, after review. A partner never touches it and never needs a
351
+ credential for it.
262
352
  - **No FTP, SSH, rsync or fixed source IPs.** One protocol, one port.
263
353
 
264
354
  ### Behind a proxy
265
355
 
266
- Set the standard variables the CLI honours them on every call, the API and
267
- the part uploads alike:
356
+ Set the standard variables; the CLI honours them on every call, the API and the part
357
+ uploads alike:
268
358
 
269
359
  ```bash
270
360
  export HTTPS_PROXY=http://proxy.corp.example:8080
@@ -273,28 +363,24 @@ myelin ping # prints "via proxy http://proxy.corp.example:8080" when one
273
363
  ```
274
364
 
275
365
  - `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.
366
+ Lowercase spellings (`https_proxy`, …) are read too; both are in the wild.
367
+ - Credentials go in the URL: `http://user:pass@proxy:8080` (Basic). They are never
368
+ printed, logged, or echoed in an error.
369
+ - `NO_PROXY` accepts a host, a suffix (`.corp.example`, `*.corp.example`), `host:port`,
370
+ an IPv4 CIDR (`10.0.0.0/8`), or `*` for everything.
371
+ - **HTTP CONNECT proxies only.** A `socks5://` value is refused with a message rather
372
+ than silently bypassed: a CLI that quietly dials direct while you believe it is proxied
373
+ is worse than one that stops.
374
+ - **Basic proxy authentication only.** An NTLM or Kerberos proxy is not supported; put a
375
+ local CONNECT proxy that speaks it (cntlm, px) in front and point `HTTPS_PROXY` at that.
284
376
  - 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.
377
+ - **TLS terminates at Myelin, not at your proxy.** Through a CONNECT tunnel the proxy sees
378
+ the hostname and nothing else. If your proxy performs TLS interception, point Node at
379
+ its CA: `export NODE_EXTRA_CA_CERTS=/etc/ssl/corp-root.pem`.
294
380
 
295
381
  ## Webhooks instead of polling
296
382
 
297
- Register an HTTPS endpoint (portal Bridge → API, or `POST /v1/webhook-endpoints`)
298
- to receive signed events (`batch.validated`, `batch.changes_requested`,
299
- `batch.transferred`, …). Verification snippets: https://myelinbridge.com/developers.
300
- Polling fallback: `GET /v1/events?cursor=…`.
383
+ Register an HTTPS endpoint (portal Bridge → API, or `POST /v1/webhook-endpoints`) to
384
+ receive signed events (`batch.validated`, `batch.changes_requested`, `batch.transferred`,
385
+ …). Verification snippets: https://myelinbridge.com/developers. Polling fallback:
386
+ `GET /v1/events?cursor=…`.
package/bin/myelin.js CHANGED
@@ -918,6 +918,7 @@ async function cmdPing() {
918
918
  // ordinary output is unchanged.
919
919
  const via = proxyForUrl(new URL(API_URL))
920
920
  if (via) out(` via proxy ${via.display}`)
921
+ out('Next: myelin datasets')
921
922
  }
922
923
 
923
924
  async function cmdProjects() {
@@ -944,11 +945,19 @@ async function cmdDatasets() {
944
945
  }
945
946
  emit({ datasets: rows })
946
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
+ }
947
955
  const lines = table(
948
956
  ['PROJECT', 'DATASET', 'STATUS', 'YOUR MOVE?'],
949
957
  rows.map((r) => [r.project, r.dataset, r.status, r.open_draft ? 'yes — open draft to finish' : '—']),
950
958
  )
951
959
  for (const line of lines) out(line)
960
+ out(`Next: myelin contract --dataset ${rows[0].dataset}`)
952
961
  }
953
962
  }
954
963
 
@@ -998,8 +1007,11 @@ async function cmdContract() {
998
1007
  // was not `blocking` a "should", so a `must_acknowledge` rule — the one
999
1008
  // that demands a named decision from the reviewer — printed identically
1000
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).
1001
1013
  const mark =
1002
- c.severity === 'blocking' ? '!' : c.severity === 'must_acknowledge' ? '?' : '·'
1014
+ c.severity === 'blocking' ? '!' : c.severity === 'must_acknowledge' ? '~' : '·'
1003
1015
  const when = c.runs_at === 'preflight' ? '' : ' (checked at submission)'
1004
1016
  const note =
1005
1017
  c.severity === 'must_acknowledge'
@@ -1157,8 +1169,10 @@ async function cmdCheck() {
1157
1169
  // true, and the coverage is stated the rest of the time — framed as a
1158
1170
  // capability to gain, because it is one: more checks running before you send
1159
1171
  // means fewer rejections after.
1172
+ const next = `Next: myelin push ${dir} --dataset ${dsRef} --submit`
1160
1173
  if (!k || k.passed + k.not_applicable === k.evaluated) {
1161
1174
  out('All checks that run before upload pass.')
1175
+ out(next)
1162
1176
  return
1163
1177
  }
1164
1178
  const rest = []
@@ -1168,6 +1182,7 @@ async function cmdCheck() {
1168
1182
  if (k.not_evaluated > 0) {
1169
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.`)
1170
1184
  }
1185
+ out(next)
1171
1186
  }
1172
1187
 
1173
1188
  async function cmdPush() {
@@ -1204,7 +1219,10 @@ async function cmdPush() {
1204
1219
  }
1205
1220
  const created = expectOk(createRes, 'create batch')
1206
1221
  const batchId = created.batch_id
1207
- 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'}.`)
1208
1226
 
1209
1227
  // 2. declare in chunks of 500 — idempotent, so re-running skips what's done
1210
1228
  const declared = []
@@ -1267,7 +1285,7 @@ async function cmdPush() {
1267
1285
  if (j.already_submitted) {
1268
1286
  // A retried submit whose first attempt actually landed: the API echoes
1269
1287
  // success instead of failing, and so do we — nothing to redo.
1270
- 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`)
1271
1289
  } else {
1272
1290
  // ⚠ This line used to print three numbers and no denominator, which was
1273
1291
  // exhaustive until the engine could abstain — after that, an abstention
@@ -1285,7 +1303,7 @@ async function cmdPush() {
1285
1303
  if (a.must_acknowledge_failures) {
1286
1304
  out(` ${a.must_acknowledge_failures} check(s) your client asked to be told about — nothing to fix.`)
1287
1305
  }
1288
- out(`✓ Batch submitted for review. Track: myelin status ${batchId} --watch`)
1306
+ out(`✓ Batch submitted for review. Track: myelin status ${batchName} --watch`)
1289
1307
  }
1290
1308
  } else {
1291
1309
  emit({ batch_id: batchId, files: declared.length, submitted: false })
@@ -1293,27 +1311,53 @@ async function cmdPush() {
1293
1311
  }
1294
1312
  }
1295
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
+
1296
1337
  async function cmdStatus() {
1297
1338
  const ref = args[1]
1298
- 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]')
1299
1342
 
1300
- const findBatch = async () => {
1301
- if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(ref)) {
1302
- const r = await api('GET', `/batches/${ref}`)
1303
- if (r.status === 200) return expectOk(r, 'batch').batch
1304
- die(`Batch ${ref} not found in this key's scope`)
1305
- }
1306
- const list = expectOk(await api('GET', '/batches?limit=100'), 'batches')
1307
- const hit = list.batches.find((b) => b.display_name === ref)
1308
- if (!hit) die(`No batch named "${ref}" in this key's scope`)
1309
- return expectOk(await api('GET', `/batches/${hit.id}`), 'batch').batch
1310
- }
1343
+ const findBatch = () => resolveBatch(ref)
1311
1344
 
1312
1345
  const print = async (batch) => {
1313
- emit({ batch })
1314
- 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
1315
1358
  if (batch.status === 'changes_requested') {
1316
1359
  const f = expectOk(await api('GET', `/batches/${batch.id}/findings`), 'findings')
1360
+ findings = f
1317
1361
  if (f.request_changes_comment) out(` reviewer: "${f.request_changes_comment}"`)
1318
1362
  for (const fr of f.file_reviews.filter((x) => x.verdict === 'failed')) {
1319
1363
  out(` ✗ ${fr.path} (${fr.reviewer ?? 'reviewer'}: ${fr.comment ?? 'failed'})`)
@@ -1336,20 +1380,55 @@ async function cmdStatus() {
1336
1380
  )
1337
1381
  }
1338
1382
  }
1383
+ emit(findings ? { batch, findings } : { batch })
1339
1384
  return batch
1340
1385
  }
1341
1386
 
1342
1387
  let batch = await print(await findBatch())
1343
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
+ }
1344
1395
  const terminal = ['transferred', 'rejected', 'changes_requested', 'transfer_failed']
1396
+ let misses = 0
1345
1397
  while (!terminal.includes(batch.status)) {
1346
1398
  await new Promise((r) => setTimeout(r, 20_000))
1347
- 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
+ }
1348
1411
  if (next.status !== batch.status) batch = await print(next)
1349
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
1350
1416
  }
1351
1417
  }
1352
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
+
1353
1432
  async function cmdSandbox() {
1354
1433
  const file = args[1]
1355
1434
  if (!file) die('Usage: myelin sandbox <file>')
@@ -1406,6 +1485,10 @@ async function cmdRoles() {
1406
1485
  if (sub === 'set' || sub === 'none' || sub === 'clear') {
1407
1486
  const role = args[2]
1408
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(', ')}`)
1409
1492
  const body = { role }
1410
1493
  if (sub === 'set') {
1411
1494
  const path = args[3]
@@ -1455,39 +1538,193 @@ async function cmdRoles() {
1455
1538
  }
1456
1539
  }
1457
1540
 
1458
- function cmdHelp() {
1459
- 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
+ }
1460
1680
 
1461
- Setup:
1462
- export MYELIN_API_KEY=myl_live_… (create in Bridge API)
1463
- export MYELIN_API_URL=… (optional; default https://myelinbridge.com/api/v1)
1464
- export MYELIN_API_HEADER="Name: value" (optional; extra headers, one per line —
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")
1468
-
1469
- Commands:
1470
- ping verify the key; show bridge + projects
1471
- projects list scoped projects
1472
- datasets list datasets with "your move" hints
1473
- sample-depth <0-5> --dataset <slug|id> declare the folder depth a sample sits at (locks after 1st submit)
1474
- roles [set|none|clear …] --dataset <…> which file plays each role (samplesheet, checksum list, …) —
1475
- declaring one activates the checks that read it; never locks
1476
- contract --dataset <slug|id> what this dataset expects of your delivery
1477
- check <dir> --dataset <slug|id> preflight local files against quality rules (no upload)
1478
- push <dir> --dataset <slug|id> create/resume a delivery and upload (resumable; re-run to resume)
1479
- [--submit] [--replace]
1480
- status <batch|name> [--watch] review status + fix-loop findings
1481
- sandbox <file> partner-side test transfer (bridge activation)
1482
-
1483
- Client-side commands (need a CLIENT key Organisation API keys):
1484
- deliveries [--project <id>] what landed in your destination bucket
1485
- [--dataset <id>] [--since <iso>] [--limit <n>] [--cursor <ts>]
1486
- resolve <path|prefix|id> what a path in your bucket actually is —
1487
- 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
1488
1716
 
1489
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
1490
1726
 
1727
+ <ds> is a dataset slug (any case), an id, or an exact name, as \`datasets\` prints them.
1491
1728
  Every command accepts --json. Exit codes: 0 ok · 1 error · 2 blocked.
1492
1729
  Errors print the API request id — include it when reporting a problem.`)
1493
1730
  }
@@ -1564,15 +1801,14 @@ async function cmdResolve() {
1564
1801
  }
1565
1802
 
1566
1803
  // ——— main ———
1567
- if (!command || command === 'help' || command === '--help') {
1568
- cmdHelp()
1569
- process.exit(0)
1570
- }
1571
- if (command === 'version' || command === '--version' || command === '-v') {
1572
- console.log(JSON_MODE ? JSON.stringify({ version: VERSION }) : VERSION)
1573
- process.exit(0)
1574
- }
1575
-
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).
1576
1812
  const commands = {
1577
1813
  ping: cmdPing,
1578
1814
  projects: cmdProjects,
@@ -1583,16 +1819,70 @@ const commands = {
1583
1819
  check: cmdCheck,
1584
1820
  push: cmdPush,
1585
1821
  status: cmdStatus,
1822
+ recall: cmdRecall,
1586
1823
  sandbox: cmdSandbox,
1587
1824
  deliveries: cmdDeliveries,
1588
1825
  resolve: cmdResolve,
1589
1826
  }
1590
- try {
1591
- if (!KEY) die('Set MYELIN_API_KEY (create a key in Bridge API).')
1592
- assertProxyEnvUsable()
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
+ }
1593
1855
  const fn = commands[command]
1594
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()
1595
1881
  await fn()
1882
+ }
1883
+
1884
+ try {
1885
+ await main()
1596
1886
  } catch (err) {
1597
1887
  // die() already printed and set the code; anything else is a real bug and
1598
1888
  // deserves its stack.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myelinbridge/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.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": {