@myelinbridge/cli 0.9.1 → 0.11.1
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 +59 -3
- package/bin/myelin.js +232 -8
- package/package.json +23 -23
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ npx @myelinbridge/cli ping # verifies auth, prints your projects
|
|
|
13
13
|
npx @myelinbridge/cli datasets # what you can deliver to, and whose move it is
|
|
14
14
|
npx @myelinbridge/cli contract --dataset onco1-wes # what is expected of your delivery
|
|
15
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
|
|
16
18
|
npx @myelinbridge/cli check ./run_042 --dataset onco1-wes # validate BEFORE uploading a byte
|
|
17
19
|
npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
18
20
|
```
|
|
@@ -27,6 +29,31 @@ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
|
27
29
|
deliveries — after that, re-asserting the current value still succeeds and only a
|
|
28
30
|
*change* exits `2`. This is the only dataset field you can write.
|
|
29
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
|
+
- **A missing path in `roles set` is refused, not guessed (0.11.1).** Flags are
|
|
47
|
+
not positional arguments: `roles set samplesheet --dataset onco1-wes` (path
|
|
48
|
+
omitted) used to declare the samplesheet at the literal path `--dataset` and
|
|
49
|
+
print a tick. Every samplesheet check then reported *"could not check — the
|
|
50
|
+
declared file is not in the delivery"* on a delivery that was fine. The
|
|
51
|
+
command now exits with its usage line instead.
|
|
52
|
+
|
|
53
|
+
- **`--dataset` takes the slug in any case (0.11.0).** Slugs are matched
|
|
54
|
+
case-insensitively (`onco1-wes` and `ONCO1-WES` name the same dataset); an id
|
|
55
|
+
or an exact name works too.
|
|
56
|
+
|
|
30
57
|
- **Resume = re-run.** `push` is idempotent: already-uploaded files are skipped
|
|
31
58
|
(path + size), and within a large file, parts that already landed are skipped
|
|
32
59
|
too (S3 multipart). Uploads go direct to storage over short-lived presigned
|
|
@@ -50,13 +77,26 @@ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
|
50
77
|
- **Read the contract before you build the delivery.** `contract` prints what the
|
|
51
78
|
client expects — every check as one plain sentence, grouped by what it answers
|
|
52
79
|
(completeness, structure, validity, consistency, integrity, privacy), and marked
|
|
53
|
-
`!` when a failure blocks validation
|
|
80
|
+
`!` when a failure blocks validation or `?` when the client's reviewer confirms
|
|
81
|
+
it rather than asking you to fix it. It also tells you which checks `check` can
|
|
54
82
|
verify locally and which only run once the files are uploaded, so nothing about
|
|
55
83
|
the bar is a surprise at review time.
|
|
56
84
|
|
|
57
85
|
- **`check` costs nothing.** It evaluates your local file list against the dataset's
|
|
58
86
|
quality checks server-side — same engine, same verdicts as submit — without
|
|
59
|
-
uploading. Exit code 2 means
|
|
87
|
+
uploading. Exit code 2 means the delivery would not be validatable as it stands:
|
|
88
|
+
a blocking rule fails, **or** a blocking rule could not be evaluated at all.
|
|
89
|
+
- **A check has five answers, not three** (since 0.10.0). Alongside *passed*,
|
|
90
|
+
*flagged* and *failed*, the engine can now say it **could not check** something
|
|
91
|
+
(a missing precondition, or a rule it cannot evaluate) and that a rule **does
|
|
92
|
+
not apply** to your delivery. This matters to you in one specific way: a rule
|
|
93
|
+
the client wrote badly used to come back as a *failure on your delivery*. It
|
|
94
|
+
now says the rule could not be evaluated, and tells you it is not yours to fix.
|
|
95
|
+
`check` prints the count it could not check and what each one needs — supply it
|
|
96
|
+
and those checks run on your next delivery, which is fewer rejections later.
|
|
97
|
+
- **Some checks are confirmed, not fixed.** A rule marked *needs confirmation*
|
|
98
|
+
asks nothing of you: the client's reviewer records what they decided and the
|
|
99
|
+
delivery proceeds. `check` reports these and never exits 2 on them.
|
|
60
100
|
- **The fix loop is machine-readable.** On `changes_requested`,
|
|
61
101
|
`myelin status <batch> --json` returns the failed files, reviewer comments, and
|
|
62
102
|
rule remediation hints; fix, re-`push --submit`, unchanged files keep their
|
|
@@ -110,7 +150,23 @@ A partner key calling these gets `403 wrong_key_side`, and vice versa.
|
|
|
110
150
|
## Machine mode
|
|
111
151
|
|
|
112
152
|
Every command takes `--json`. Exit codes: `0` ok · `1` error · `2` blocked
|
|
113
|
-
(blocking preflight failure,
|
|
153
|
+
(blocking preflight failure, **a blocking rule the engine could not evaluate**,
|
|
154
|
+
locked delivery, blocked submit, locked sample depth).
|
|
155
|
+
|
|
156
|
+
`check --json` carries the numbers to branch on rather than the prose:
|
|
157
|
+
|
|
158
|
+
| Field | |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `blocking_failures` | Blocking rules that were evaluated and failed. Unchanged meaning since the first release. |
|
|
161
|
+
| `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. |
|
|
162
|
+
| `must_acknowledge_failures` | Rules the client asked to be told about. Reported, never exited on. |
|
|
163
|
+
| `counts` | `passed`, `flagged`, `failed`, `not_evaluated`, `not_applicable` (which sum to `evaluated`), plus `deferred`, `manual` and `checks` (`evaluated + deferred + manual === checks`). |
|
|
164
|
+
|
|
165
|
+
Every per-check result carries `details.abstained` when the engine did not
|
|
166
|
+
conclude: `rule` means **the client's rule** could not be evaluated and there is
|
|
167
|
+
nothing on your side to fix; `data` means something the check needs is missing
|
|
168
|
+
from the delivery; `nothing_to_evaluate` means the rule matched none of your
|
|
169
|
+
files. Branch on that before you page anyone.
|
|
114
170
|
|
|
115
171
|
API errors carry structure, not just prose: in `--json` mode an error is
|
|
116
172
|
`{ "error", "code", "status", "request_id" }` — branch on `code`, and quote the
|
package/bin/myelin.js
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Exit codes: 0 ok · 1 error · 2 blocked (failing checks / locked delivery).
|
|
9
9
|
// Every command accepts --json for machine-readable output.
|
|
10
|
+
//
|
|
11
|
+
// 0.10.0 — the engine learned to abstain, so a check has five answers now, not
|
|
12
|
+
// three (`not_evaluated` = "we could not look", `not_applicable` = "this does
|
|
13
|
+
// not apply here"). Two consequences live in this file. Nothing renders an
|
|
14
|
+
// unknown verdict as ✗ any more: 0.9.1 did, so a check nobody ran printed as a
|
|
15
|
+
// failure the partner had caused. And `check` exits 2 on a BLOCKING rule the
|
|
16
|
+
// engine could not evaluate as well as on one it failed — the client's reviewer
|
|
17
|
+
// cannot validate either without deciding by hand, and hearing "all checks
|
|
18
|
+
// pass" before uploading is the green-before-sending / red-after trap this
|
|
19
|
+
// release exists to close.
|
|
10
20
|
|
|
11
21
|
import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
|
|
12
22
|
import { resolve, join, relative, sep, basename } from 'node:path'
|
|
@@ -63,6 +73,35 @@ const die = (message, code = 1, extra = null) => {
|
|
|
63
73
|
|
|
64
74
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
65
75
|
|
|
76
|
+
// ── The verdict vocabulary, as a partner reads it ──────────────────────────
|
|
77
|
+
//
|
|
78
|
+
// ⚠ NEVER print the raw token. `not_evaluated` on a terminal reads as an
|
|
79
|
+
// internal enum and, worse, as an accusation: a partner seeing it next to their
|
|
80
|
+
// delivery assumes their delivery is what failed. It is not — in the `rule`
|
|
81
|
+
// case it is the client's rule that could not be evaluated.
|
|
82
|
+
//
|
|
83
|
+
// ⚠ And never `✗` for a verdict we do not recognise. That is exactly what 0.9.1
|
|
84
|
+
// does (`verdict === 'passed' ? '✓' : verdict === 'flagged' ? '⚠' : '✗'`), so
|
|
85
|
+
// every check that abstains prints to an installed old client as a failure. A
|
|
86
|
+
// value from a newer server than this binary falls through to `?` and its own
|
|
87
|
+
// raw name — visibly unknown, never falsely red.
|
|
88
|
+
const VERDICT_MARK = {
|
|
89
|
+
passed: '✓',
|
|
90
|
+
flagged: '⚠',
|
|
91
|
+
failed: '✗',
|
|
92
|
+
not_evaluated: '?',
|
|
93
|
+
not_applicable: '–',
|
|
94
|
+
}
|
|
95
|
+
const VERDICT_LABEL = {
|
|
96
|
+
passed: 'passed',
|
|
97
|
+
flagged: 'flagged',
|
|
98
|
+
failed: 'failed',
|
|
99
|
+
not_evaluated: 'not checked',
|
|
100
|
+
not_applicable: 'does not apply here',
|
|
101
|
+
}
|
|
102
|
+
const verdictMark = (v) => VERDICT_MARK[v] ?? '?'
|
|
103
|
+
const verdictLabel = (v) => VERDICT_LABEL[v] ?? v
|
|
104
|
+
|
|
66
105
|
// Column widths come from the content. padEnd() alone silently ran a long
|
|
67
106
|
// value into the next column — a 28-character dataset slug swallowed the
|
|
68
107
|
// STATUS header's gutter. Pass headers = null for an unheadered list.
|
|
@@ -152,9 +191,15 @@ async function resolveDataset(ref) {
|
|
|
152
191
|
die(`Dataset ${ref} not found in this key's scope`)
|
|
153
192
|
}
|
|
154
193
|
const me = expectOk(await api('GET', '/me'), 'auth')
|
|
194
|
+
// Slugs are matched case-insensitively: seeded datasets carry lowercase slugs
|
|
195
|
+
// while the portal generates uppercase ones, and the slug is a handle a human
|
|
196
|
+
// types — the case of the fleet should never be their problem.
|
|
197
|
+
const want = ref.toLowerCase()
|
|
155
198
|
for (const p of me.projects) {
|
|
156
199
|
const r = expectOk(await api('GET', `/projects/${p.id}/datasets`), 'datasets')
|
|
157
|
-
const hit = r.datasets.find(
|
|
200
|
+
const hit = r.datasets.find(
|
|
201
|
+
(d) => (typeof d.slug === 'string' && d.slug.toLowerCase() === want) || d.name === ref,
|
|
202
|
+
)
|
|
158
203
|
if (hit) return hit
|
|
159
204
|
}
|
|
160
205
|
die(`No dataset with slug or name "${ref}" in this key's scope`)
|
|
@@ -479,6 +524,9 @@ async function cmdContract() {
|
|
|
479
524
|
out(`${dataset.name} — delivery contract v${j.version}`)
|
|
480
525
|
out(
|
|
481
526
|
`${s.total} checks · ${s.blocking} block validation · ` +
|
|
527
|
+
// Only when there are any: a contract with no rule at this rung must read
|
|
528
|
+
// exactly as it did before 0.10.0, not gain a "0 confirmed".
|
|
529
|
+
(s.must_acknowledge ? `${s.must_acknowledge} confirmed by your reviewer · ` : '') +
|
|
482
530
|
`${s.checkable_before_upload} checkable before upload` +
|
|
483
531
|
(s.needs_reviewer ? ` · ${s.needs_reviewer} reviewed by a person` : ''),
|
|
484
532
|
)
|
|
@@ -496,9 +544,18 @@ async function cmdContract() {
|
|
|
496
544
|
if (items.length === 0) continue
|
|
497
545
|
out(`${d.label.toUpperCase()} — ${d.question}`)
|
|
498
546
|
for (const c of items) {
|
|
499
|
-
|
|
547
|
+
// THREE rungs, not two. The ternary this replaces called everything that
|
|
548
|
+
// was not `blocking` a "should", so a `must_acknowledge` rule — the one
|
|
549
|
+
// that demands a named decision from the reviewer — printed identically
|
|
550
|
+
// to a warning nobody has to read.
|
|
551
|
+
const mark =
|
|
552
|
+
c.severity === 'blocking' ? '!' : c.severity === 'must_acknowledge' ? '?' : '·'
|
|
500
553
|
const when = c.runs_at === 'preflight' ? '' : ' (checked at submission)'
|
|
501
|
-
|
|
554
|
+
const note =
|
|
555
|
+
c.severity === 'must_acknowledge'
|
|
556
|
+
? ' — your client’s reviewer records a decision on this; nothing for you to fix'
|
|
557
|
+
: ''
|
|
558
|
+
out(` ${mark} ${c.assertion ?? c.name}${when}${note}`)
|
|
502
559
|
}
|
|
503
560
|
out('')
|
|
504
561
|
}
|
|
@@ -538,19 +595,92 @@ async function cmdCheck() {
|
|
|
538
595
|
...j.manual.map((m) => m.name.length),
|
|
539
596
|
)
|
|
540
597
|
for (const c of j.evaluated) {
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
598
|
+
const gate =
|
|
599
|
+
c.severity === 'blocking' && c.verdict === 'failed' ? ' — BLOCKING'
|
|
600
|
+
: c.severity === 'blocking' && c.verdict === 'not_evaluated' ? ' — BLOCKING, and nobody looked'
|
|
601
|
+
: c.severity === 'must_acknowledge' && c.verdict === 'failed' ? ' — your reviewer confirms this'
|
|
602
|
+
: ''
|
|
603
|
+
out(`${verdictMark(c.verdict)} ${c.check_type.padEnd(w)} ${verdictLabel(c.verdict)}${gate}`)
|
|
604
|
+
// Why we could not look, in the partner's terms. `abstained: 'rule'` is
|
|
605
|
+
// the one that matters most: it says the fault is in the client's rule,
|
|
606
|
+
// not in this delivery, and without the sentence the partner spends a
|
|
607
|
+
// morning re-checking files that were never the problem.
|
|
608
|
+
if (c.verdict === 'not_evaluated') {
|
|
609
|
+
out(
|
|
610
|
+
c.details?.abstained === 'rule'
|
|
611
|
+
? ' this rule could not be evaluated — nothing to fix on your side; ask your client to check it'
|
|
612
|
+
: ' the delivery is missing something this check needs — see the hint below, or your client’s contract',
|
|
613
|
+
)
|
|
614
|
+
}
|
|
615
|
+
// A remediation hint answers "how do I fix this?", which only has an
|
|
616
|
+
// answer when there IS something on the partner's side to fix: a verdict
|
|
617
|
+
// the engine reached, or a missing precondition they can supply. Never
|
|
618
|
+
// under a `rule` abstention (the client's rule is what broke) and never
|
|
619
|
+
// under `not_applicable` (there is nothing to do) — printing one there is
|
|
620
|
+
// an instruction to fix nothing.
|
|
621
|
+
const fixable =
|
|
622
|
+
c.verdict === 'failed' ||
|
|
623
|
+
c.verdict === 'flagged' ||
|
|
624
|
+
(c.verdict === 'not_evaluated' && c.details?.abstained !== 'rule')
|
|
625
|
+
if (fixable && c.remediation) out(` hint: ${c.remediation}`)
|
|
544
626
|
}
|
|
545
627
|
for (const d of j.deferred) out(`… ${d.check_type.padEnd(w)} ${d.reason}`)
|
|
546
628
|
for (const m of j.manual) out(`○ ${m.name.padEnd(w)} ${m.reason}`)
|
|
547
629
|
}
|
|
630
|
+
|
|
631
|
+
// ── The verdict of the run ────────────────────────────────────────────────
|
|
632
|
+
//
|
|
633
|
+
// Two things block the client's reviewer from validating, not one, and this
|
|
634
|
+
// command has to report both or it is telling a partner their delivery is
|
|
635
|
+
// clear when it is not (lib/quality/readiness.ts is the gate; the API's
|
|
636
|
+
// `blocking_not_evaluated` is the same number computed the same way).
|
|
637
|
+
//
|
|
638
|
+
// `must_acknowledge` deliberately does NOT exit 2. It is the rung that asks
|
|
639
|
+
// the reviewer for a decision and the partner for nothing, and a pipeline
|
|
640
|
+
// halting on it would put back exactly the over-blocking it was invented to
|
|
641
|
+
// remove — "to the CRO we block nothing" is the point of it.
|
|
642
|
+
const blockingAbstained = (j.evaluated ?? []).filter(
|
|
643
|
+
(c) => c.severity === 'blocking' && c.verdict === 'not_evaluated',
|
|
644
|
+
)
|
|
548
645
|
if (j.blocking_failures > 0) {
|
|
549
646
|
out(`${j.blocking_failures} blocking issue(s). Fix before pushing to avoid a review round-trip.`)
|
|
647
|
+
}
|
|
648
|
+
if (blockingAbstained.length > 0) {
|
|
649
|
+
out(
|
|
650
|
+
`${blockingAbstained.length} blocking check(s) could not be evaluated — nobody looked, so your ` +
|
|
651
|
+
`client's reviewer has to decide on each one by hand before this delivery can be validated.`,
|
|
652
|
+
)
|
|
653
|
+
const rule = blockingAbstained.filter((c) => c.details?.abstained === 'rule')
|
|
654
|
+
if (rule.length > 0) {
|
|
655
|
+
out(` ${rule.length} of them because the RULE could not be evaluated, not your delivery: ${rule.map((c) => c.check_type).join(', ')}`)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (j.blocking_failures + blockingAbstained.length > 0) {
|
|
550
659
|
process.exitCode = 2
|
|
551
660
|
return
|
|
552
661
|
}
|
|
553
|
-
|
|
662
|
+
if (j.must_acknowledge_failures > 0) {
|
|
663
|
+
out(`${j.must_acknowledge_failures} check(s) your client asked to be told about — nothing to fix; their reviewer records a decision. Not a blocker.`)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const k = j.counts
|
|
667
|
+
// "All checks that run before upload pass" was written when every check
|
|
668
|
+
// reached a conclusion. It is a green claim over rules nobody ran the moment
|
|
669
|
+
// one abstains, so it is now reserved for the run where that is literally
|
|
670
|
+
// true, and the coverage is stated the rest of the time — framed as a
|
|
671
|
+
// capability to gain, because it is one: more checks running before you send
|
|
672
|
+
// means fewer rejections after.
|
|
673
|
+
if (!k || k.passed + k.not_applicable === k.evaluated) {
|
|
674
|
+
out('All checks that run before upload pass.')
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
const rest = []
|
|
678
|
+
if (k.flagged + k.failed > 0) rest.push(`${k.flagged + k.failed} worth a look`)
|
|
679
|
+
if (k.not_evaluated > 0) rest.push(`${k.not_evaluated} we could not check`)
|
|
680
|
+
out(`Nothing blocking. ${k.passed}/${k.evaluated} pass${rest.length ? ` — ${rest.join(', ')}` : ''}.`)
|
|
681
|
+
if (k.not_evaluated > 0) {
|
|
682
|
+
out(`Supply what ${k.not_evaluated === 1 ? 'it needs' : 'they need'} and ${k.not_evaluated === 1 ? 'it runs' : 'they run'} on your next delivery.`)
|
|
683
|
+
}
|
|
554
684
|
}
|
|
555
685
|
|
|
556
686
|
async function cmdPush() {
|
|
@@ -649,8 +779,22 @@ async function cmdPush() {
|
|
|
649
779
|
// success instead of failing, and so do we — nothing to redo.
|
|
650
780
|
out(`✓ Batch already submitted (${j.status}) — nothing to redo. Track: myelin status ${batchId} --watch`)
|
|
651
781
|
} else {
|
|
782
|
+
// ⚠ This line used to print three numbers and no denominator, which was
|
|
783
|
+
// exhaustive until the engine could abstain — after that, an abstention
|
|
784
|
+
// was in none of the three and "7 passed, 1 flagged, 0 failed" on a
|
|
785
|
+
// ten-rule contract looked complete while two rules had never been
|
|
786
|
+
// looked at. `of N` is the denominator that makes the omission visible.
|
|
652
787
|
const a = j.auto_checks
|
|
653
|
-
|
|
788
|
+
const tally = [`${a.passed} passed`, `${a.flagged} flagged`, `${a.failed} failed`]
|
|
789
|
+
if (a.not_evaluated) tally.push(`${a.not_evaluated} not checked`)
|
|
790
|
+
if (a.not_applicable) tally.push(`${a.not_applicable} not applicable`)
|
|
791
|
+
out(`Submitting… auto-checks: ${tally.join(', ')}${a.total ? ` of ${a.total}` : ''}.`)
|
|
792
|
+
if (a.blocking_not_evaluated) {
|
|
793
|
+
out(` ${a.blocking_not_evaluated} blocking check(s) could not be evaluated — your client's reviewer decides on those by hand.`)
|
|
794
|
+
}
|
|
795
|
+
if (a.must_acknowledge_failures) {
|
|
796
|
+
out(` ${a.must_acknowledge_failures} check(s) your client asked to be told about — nothing to fix.`)
|
|
797
|
+
}
|
|
654
798
|
out(`✓ Batch submitted for review. Track: myelin status ${batchId} --watch`)
|
|
655
799
|
}
|
|
656
800
|
} else {
|
|
@@ -687,6 +831,20 @@ async function cmdStatus() {
|
|
|
687
831
|
for (const qf of f.quality_findings.filter((x) => x.verdict === 'failed')) {
|
|
688
832
|
out(` ✗ ${qf.name ?? qf.rule_key}${qf.hint ? `: ${qf.hint}` : ''}`)
|
|
689
833
|
}
|
|
834
|
+
// ⚠ The filter above is "what to fix", and until 0.10.0 it was the whole
|
|
835
|
+
// list. A rule the engine could not evaluate is not a failure and never
|
|
836
|
+
// matched it — so a delivery bounced back partly BECAUSE two checks could
|
|
837
|
+
// not run showed the partner only the failures, and they resubmitted into
|
|
838
|
+
// the same wall. An abstention that a partner can clear (`abstained`
|
|
839
|
+
// anything but 'rule') belongs on this list; a `rule` one belongs to the
|
|
840
|
+
// client and is printed as such rather than as work owed.
|
|
841
|
+
for (const qf of f.quality_findings.filter((x) => x.verdict === 'not_evaluated')) {
|
|
842
|
+
out(
|
|
843
|
+
qf.details?.abstained === 'rule'
|
|
844
|
+
? ` ? ${qf.name ?? qf.rule_key}: this rule could not be evaluated — ask your client to check it`
|
|
845
|
+
: ` ? ${qf.name ?? qf.rule_key}: we could not check this${qf.hint ? ` — ${qf.hint}` : ''}`,
|
|
846
|
+
)
|
|
847
|
+
}
|
|
690
848
|
}
|
|
691
849
|
return batch
|
|
692
850
|
}
|
|
@@ -744,6 +902,69 @@ async function cmdSampleDepth() {
|
|
|
744
902
|
}
|
|
745
903
|
}
|
|
746
904
|
|
|
905
|
+
// Which delivered file plays each role (sprint 19). Checks read files by ROLE
|
|
906
|
+
// — declaring one activates every check that was waiting on it, and nothing
|
|
907
|
+
// here is ever required to deliver. Unlike sample-depth this never locks:
|
|
908
|
+
// declarations apply forward, and re-declaring is allowed for the life of the
|
|
909
|
+
// dataset (idempotent — assert it on every run).
|
|
910
|
+
async function cmdRoles() {
|
|
911
|
+
const dsRef = opt('dataset')
|
|
912
|
+
if (!dsRef) die('Usage: myelin roles [set <role> <path> | none <role> | clear <role>] --dataset <slug|id>')
|
|
913
|
+
const dataset = await resolveDataset(dsRef)
|
|
914
|
+
const sub = args[1]
|
|
915
|
+
|
|
916
|
+
if (sub === 'set' || sub === 'none' || sub === 'clear') {
|
|
917
|
+
const role = args[2]
|
|
918
|
+
if (!role) die(`Usage: myelin roles ${sub} <role>${sub === 'set' ? ' <path>' : ''} --dataset <slug|id>`)
|
|
919
|
+
const body = { role }
|
|
920
|
+
if (sub === 'set') {
|
|
921
|
+
const path = args[3]
|
|
922
|
+
// ⚠ `args` only strips --json, so an omitted path lets the NEXT FLAG slide
|
|
923
|
+
// into its place: `roles set samplesheet --dataset onco1-wes` used to
|
|
924
|
+
// declare the samplesheet at `/--dataset`. The API accepts it (printable,
|
|
925
|
+
// under 255 chars), the CLI printed a tick, and every samplesheet check
|
|
926
|
+
// then answered role_file_missing on a delivery that was fine.
|
|
927
|
+
if (!path || path.startsWith('--')) {
|
|
928
|
+
die('Usage: myelin roles set <role> <path> --dataset <slug|id>')
|
|
929
|
+
}
|
|
930
|
+
body.path = path
|
|
931
|
+
} else if (sub === 'none') {
|
|
932
|
+
body.none = true
|
|
933
|
+
} else {
|
|
934
|
+
body.clear = true
|
|
935
|
+
}
|
|
936
|
+
const j = expectOk(await api('PUT', `/datasets/${dataset.id}/file-roles`, body), 'file-roles')
|
|
937
|
+
emit(j)
|
|
938
|
+
if (!JSON_MODE) {
|
|
939
|
+
out(
|
|
940
|
+
j.changed
|
|
941
|
+
? `✓ ${role} ${sub === 'set' ? `→ ${body.path}` : sub === 'none' ? 'declared: no such file in this dataset' : 'declaration cleared'} for ${dataset.name}.`
|
|
942
|
+
: `${role} already declared that way for ${dataset.name} — nothing to change.`,
|
|
943
|
+
)
|
|
944
|
+
}
|
|
945
|
+
return
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const j = expectOk(await api('GET', `/datasets/${dataset.id}/file-roles`), 'file-roles')
|
|
949
|
+
emit(j)
|
|
950
|
+
if (JSON_MODE) return
|
|
951
|
+
out(`${dataset.name} — delivery file roles`)
|
|
952
|
+
const declaredBy = new Map(j.declarations.map((d) => [d.role, d]))
|
|
953
|
+
for (const role of ['samplesheet', 'checksum_manifest', 'qc_report', 'subject_roster', 'capture_bed']) {
|
|
954
|
+
const d = declaredBy.get(role)
|
|
955
|
+
if (d) {
|
|
956
|
+
out(` ${role.padEnd(18)} ${d.declared_none ? '(no such file in this dataset — declared)' : d.path}`)
|
|
957
|
+
} else {
|
|
958
|
+
const cands = j.candidates.filter((c) => c.role === role)
|
|
959
|
+
out(` ${role.padEnd(18)} not declared${cands.length ? ` — looks like: ${cands.map((c) => c.path).join(', ')}` : ''}`)
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const undeclared = 5 - j.declarations.length
|
|
963
|
+
if (undeclared > 0) {
|
|
964
|
+
out(`\nDeclaring a role activates the checks that read it — run: myelin roles set <role> <path> --dataset ${dsRef}`)
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
747
968
|
function cmdHelp() {
|
|
748
969
|
console.log(`myelin — Partner Ingestion CLI
|
|
749
970
|
|
|
@@ -758,6 +979,8 @@ Commands:
|
|
|
758
979
|
projects list scoped projects
|
|
759
980
|
datasets list datasets with "your move" hints
|
|
760
981
|
sample-depth <0-5> --dataset <slug|id> declare the folder depth a sample sits at (locks after 1st submit)
|
|
982
|
+
roles [set|none|clear …] --dataset <…> which file plays each role (samplesheet, checksum list, …) —
|
|
983
|
+
declaring one activates the checks that read it; never locks
|
|
761
984
|
contract --dataset <slug|id> what this dataset expects of your delivery
|
|
762
985
|
check <dir> --dataset <slug|id> preflight local files against quality rules (no upload)
|
|
763
986
|
push <dir> --dataset <slug|id> create/resume a delivery and upload (resumable; re-run to resume)
|
|
@@ -856,6 +1079,7 @@ const commands = {
|
|
|
856
1079
|
projects: cmdProjects,
|
|
857
1080
|
datasets: cmdDatasets,
|
|
858
1081
|
'sample-depth': cmdSampleDepth,
|
|
1082
|
+
roles: cmdRoles,
|
|
859
1083
|
contract: cmdContract,
|
|
860
1084
|
check: cmdCheck,
|
|
861
1085
|
push: cmdPush,
|
package/package.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@myelinbridge/cli",
|
|
3
|
-
"version": "0.
|
|
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
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"myelin": "bin/myelin.js"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"bin/",
|
|
11
|
-
"README.md"
|
|
12
|
-
],
|
|
13
|
-
"engines": {
|
|
14
|
-
"node": ">=20"
|
|
15
|
-
},
|
|
16
|
-
"keywords": [
|
|
17
|
-
"myelin",
|
|
18
|
-
"pharma",
|
|
19
|
-
"ingestion",
|
|
20
|
-
"s3"
|
|
21
|
-
],
|
|
22
|
-
"license": "UNLICENSED"
|
|
23
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@myelinbridge/cli",
|
|
3
|
+
"version": "0.11.1",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"myelin": "bin/myelin.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"myelin",
|
|
18
|
+
"pharma",
|
|
19
|
+
"ingestion",
|
|
20
|
+
"s3"
|
|
21
|
+
],
|
|
22
|
+
"license": "UNLICENSED"
|
|
23
|
+
}
|