@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.
Files changed (3) hide show
  1. package/README.md +59 -3
  2. package/bin/myelin.js +232 -8
  3. 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. It also tells you which checks `check` can
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 a blocking rule fails.
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, locked delivery, blocked submit, locked sample depth).
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((d) => d.slug === ref || d.name === ref)
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
- const gate = c.severity === 'blocking' ? 'must' : 'should'
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
- out(` ${gate === 'must' ? '!' : '·'} ${c.assertion ?? c.name}${when}`)
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 mark = c.verdict === 'passed' ? '✓' : c.verdict === 'flagged' ? '⚠' : '✗'
542
- out(`${mark} ${c.check_type.padEnd(w)} ${c.verdict}${c.severity === 'blocking' && c.verdict === 'failed' ? ' — BLOCKING' : ''}`)
543
- if (c.verdict !== 'passed' && c.remediation) out(` hint: ${c.remediation}`)
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
- out('All checks that run before upload pass.')
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
- out(`Submitting… auto-checks: ${a.passed} passed, ${a.flagged} flagged, ${a.failed} failed.`)
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.9.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
- }
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
+ }