@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.
- package/README.md +306 -220
- package/bin/myelin.js +350 -60
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,195 +1,260 @@
|
|
|
1
1
|
# @myelinbridge/cli
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
myelin
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
whatever granularity the path supports.
|
|
118
|
+
### Two optional declarations
|
|
159
119
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
135
|
+
`<ds>` is a dataset slug (any case), an id, or an exact name, as `datasets` prints them.
|
|
166
136
|
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
163
|
+
```bash
|
|
164
|
+
myelin check "$OUT" --dataset onco1-wes || exit $?
|
|
165
|
+
myelin push "$OUT" --dataset onco1-wes --submit
|
|
166
|
+
```
|
|
172
167
|
|
|
173
|
-
|
|
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
|
|
178
|
-
| `blocking_not_evaluated` | Blocking
|
|
179
|
-
| `must_acknowledge_failures` |
|
|
180
|
-
| `counts` | `passed`, `flagged`, `failed`, `not_evaluated`, `not_applicable
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
API
|
|
189
|
-
`
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
201
|
-
| `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` | Optional
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
- **No access to the destination bucket.** The client's cloud bucket is written
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
267
|
-
|
|
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
|
|
277
|
-
- Credentials go in the URL: `http://user:pass@proxy:8080` (Basic). They are
|
|
278
|
-
|
|
279
|
-
- `NO_PROXY` accepts a host, a suffix (`.corp.example`, `*.corp.example`),
|
|
280
|
-
|
|
281
|
-
- **HTTP CONNECT proxies only.** A `socks5://` value is refused with a message
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1459
|
-
|
|
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
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
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.
|
|
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": {
|