@myelinbridge/cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +36 -0
  2. package/bin/myelin.js +103 -1
  3. package/package.json +1 -1
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,40 @@ 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
+ - **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, and `check`
49
+ tells you when required ones are not filled in yet — informational only:
50
+ their team fills them in Myelin, there is nothing to change in your delivery,
51
+ and metadata never moves the exit code. In `--json`, datasets carry a
52
+ `metadata` object (`{key: {label, type, value}}`) and preflight carries
53
+ `metadata.missing_required`.
54
+
55
+ - **A missing path in `roles set` is refused, not guessed (0.11.1).** Flags are
56
+ not positional arguments: `roles set samplesheet --dataset onco1-wes` (path
57
+ omitted) used to declare the samplesheet at the literal path `--dataset` and
58
+ print a tick. Every samplesheet check then reported *"could not check — the
59
+ declared file is not in the delivery"* on a delivery that was fine. The
60
+ command now exits with its usage line instead.
61
+
62
+ - **`--dataset` takes the slug in any case (0.11.0).** Slugs are matched
63
+ case-insensitively (`onco1-wes` and `ONCO1-WES` name the same dataset); an id
64
+ or an exact name works too.
65
+
30
66
  - **Resume = re-run.** `push` is idempotent: already-uploaded files are skipped
31
67
  (path + size), and within a large file, parts that already landed are skipped
32
68
  too (S3 multipart). Uploads go direct to storage over short-lived presigned
package/bin/myelin.js CHANGED
@@ -17,6 +17,12 @@
17
17
  // cannot validate either without deciding by hand, and hearing "all checks
18
18
  // pass" before uploading is the green-before-sending / red-after trap this
19
19
  // release exists to close.
20
+ //
21
+ // 0.12.0 — clients can define their own fields on datasets (sprint 22).
22
+ // `contract` shows them read-only, and `check` reports the required ones the
23
+ // client has not filled in yet — informational ONLY: metadata never moves the
24
+ // exit code (the client fills these in Myelin; nothing for a partner to fix,
25
+ // and a pipeline must never halt on it).
20
26
 
21
27
  import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
22
28
  import { resolve, join, relative, sep, basename } from 'node:path'
@@ -102,6 +108,11 @@ const VERDICT_LABEL = {
102
108
  const verdictMark = (v) => VERDICT_MARK[v] ?? '?'
103
109
  const verdictLabel = (v) => VERDICT_LABEL[v] ?? v
104
110
 
111
+ // A client field value in human mode (0.12.0). Arrays joined, booleans said
112
+ // in words, everything else printed as-is — never JSON punctuation.
113
+ const fmtFieldValue = (v) =>
114
+ Array.isArray(v) ? v.join(', ') : v === true ? 'yes' : v === false ? 'no' : String(v ?? '—')
115
+
105
116
  // Column widths come from the content. padEnd() alone silently ran a long
106
117
  // value into the next column — a 28-character dataset slug swallowed the
107
118
  // STATUS header's gutter. Pass headers = null for an unheadered list.
@@ -191,9 +202,15 @@ async function resolveDataset(ref) {
191
202
  die(`Dataset ${ref} not found in this key's scope`)
192
203
  }
193
204
  const me = expectOk(await api('GET', '/me'), 'auth')
205
+ // Slugs are matched case-insensitively: seeded datasets carry lowercase slugs
206
+ // while the portal generates uppercase ones, and the slug is a handle a human
207
+ // types — the case of the fleet should never be their problem.
208
+ const want = ref.toLowerCase()
194
209
  for (const p of me.projects) {
195
210
  const r = expectOk(await api('GET', `/projects/${p.id}/datasets`), 'datasets')
196
- const hit = r.datasets.find((d) => d.slug === ref || d.name === ref)
211
+ const hit = r.datasets.find(
212
+ (d) => (typeof d.slug === 'string' && d.slug.toLowerCase() === want) || d.name === ref,
213
+ )
197
214
  if (hit) return hit
198
215
  }
199
216
  die(`No dataset with slug or name "${ref}" in this key's scope`)
@@ -559,6 +576,15 @@ async function cmdContract() {
559
576
  for (const c of manual) out(` · ${c.name}`)
560
577
  out('')
561
578
  }
579
+ // 0.12.0 — the client's own fields on this dataset, read-only (their team
580
+ // writes them in Myelin; the API never accepts them from a partner key).
581
+ const meta = dataset.metadata ?? {}
582
+ const metaKeys = Object.keys(meta)
583
+ if (metaKeys.length > 0) {
584
+ out('CLIENT FIELDS — how your client describes this dataset (read-only)')
585
+ for (const k of metaKeys) out(` · ${meta[k].label}: ${fmtFieldValue(meta[k].value)}`)
586
+ out('')
587
+ }
562
588
  out(`Run "myelin check <dir> --dataset ${dsRef}" to test ${s.checkable_before_upload} of these locally.`)
563
589
  }
564
590
 
@@ -656,6 +682,16 @@ async function cmdCheck() {
656
682
  if (j.must_acknowledge_failures > 0) {
657
683
  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.`)
658
684
  }
685
+ // 0.12.0 — required client fields not filled in yet. Informational ONLY:
686
+ // their team fills these in Myelin, nothing changes in the delivery, and
687
+ // metadata never moves the exit code (warn, never block — by design).
688
+ const gaps = j.metadata?.missing_required ?? []
689
+ if (gaps.length > 0) {
690
+ out(
691
+ `${gaps.length} field(s) your client asks for on this dataset ${gaps.length === 1 ? 'is' : 'are'} ` +
692
+ `not filled in yet: ${gaps.map((g) => g.label).join(', ')}. Their team fills these in Myelin — not a blocker.`,
693
+ )
694
+ }
659
695
 
660
696
  const k = j.counts
661
697
  // "All checks that run before upload pass" was written when every check
@@ -896,6 +932,69 @@ async function cmdSampleDepth() {
896
932
  }
897
933
  }
898
934
 
935
+ // Which delivered file plays each role (sprint 19). Checks read files by ROLE
936
+ // — declaring one activates every check that was waiting on it, and nothing
937
+ // here is ever required to deliver. Unlike sample-depth this never locks:
938
+ // declarations apply forward, and re-declaring is allowed for the life of the
939
+ // dataset (idempotent — assert it on every run).
940
+ async function cmdRoles() {
941
+ const dsRef = opt('dataset')
942
+ if (!dsRef) die('Usage: myelin roles [set <role> <path> | none <role> | clear <role>] --dataset <slug|id>')
943
+ const dataset = await resolveDataset(dsRef)
944
+ const sub = args[1]
945
+
946
+ if (sub === 'set' || sub === 'none' || sub === 'clear') {
947
+ const role = args[2]
948
+ if (!role) die(`Usage: myelin roles ${sub} <role>${sub === 'set' ? ' <path>' : ''} --dataset <slug|id>`)
949
+ const body = { role }
950
+ if (sub === 'set') {
951
+ const path = args[3]
952
+ // ⚠ `args` only strips --json, so an omitted path lets the NEXT FLAG slide
953
+ // into its place: `roles set samplesheet --dataset onco1-wes` used to
954
+ // declare the samplesheet at `/--dataset`. The API accepts it (printable,
955
+ // under 255 chars), the CLI printed a tick, and every samplesheet check
956
+ // then answered role_file_missing on a delivery that was fine.
957
+ if (!path || path.startsWith('--')) {
958
+ die('Usage: myelin roles set <role> <path> --dataset <slug|id>')
959
+ }
960
+ body.path = path
961
+ } else if (sub === 'none') {
962
+ body.none = true
963
+ } else {
964
+ body.clear = true
965
+ }
966
+ const j = expectOk(await api('PUT', `/datasets/${dataset.id}/file-roles`, body), 'file-roles')
967
+ emit(j)
968
+ if (!JSON_MODE) {
969
+ out(
970
+ j.changed
971
+ ? `✓ ${role} ${sub === 'set' ? `→ ${body.path}` : sub === 'none' ? 'declared: no such file in this dataset' : 'declaration cleared'} for ${dataset.name}.`
972
+ : `${role} already declared that way for ${dataset.name} — nothing to change.`,
973
+ )
974
+ }
975
+ return
976
+ }
977
+
978
+ const j = expectOk(await api('GET', `/datasets/${dataset.id}/file-roles`), 'file-roles')
979
+ emit(j)
980
+ if (JSON_MODE) return
981
+ out(`${dataset.name} — delivery file roles`)
982
+ const declaredBy = new Map(j.declarations.map((d) => [d.role, d]))
983
+ for (const role of ['samplesheet', 'checksum_manifest', 'qc_report', 'subject_roster', 'capture_bed']) {
984
+ const d = declaredBy.get(role)
985
+ if (d) {
986
+ out(` ${role.padEnd(18)} ${d.declared_none ? '(no such file in this dataset — declared)' : d.path}`)
987
+ } else {
988
+ const cands = j.candidates.filter((c) => c.role === role)
989
+ out(` ${role.padEnd(18)} not declared${cands.length ? ` — looks like: ${cands.map((c) => c.path).join(', ')}` : ''}`)
990
+ }
991
+ }
992
+ const undeclared = 5 - j.declarations.length
993
+ if (undeclared > 0) {
994
+ out(`\nDeclaring a role activates the checks that read it — run: myelin roles set <role> <path> --dataset ${dsRef}`)
995
+ }
996
+ }
997
+
899
998
  function cmdHelp() {
900
999
  console.log(`myelin — Partner Ingestion CLI
901
1000
 
@@ -910,6 +1009,8 @@ Commands:
910
1009
  projects list scoped projects
911
1010
  datasets list datasets with "your move" hints
912
1011
  sample-depth <0-5> --dataset <slug|id> declare the folder depth a sample sits at (locks after 1st submit)
1012
+ roles [set|none|clear …] --dataset <…> which file plays each role (samplesheet, checksum list, …) —
1013
+ declaring one activates the checks that read it; never locks
913
1014
  contract --dataset <slug|id> what this dataset expects of your delivery
914
1015
  check <dir> --dataset <slug|id> preflight local files against quality rules (no upload)
915
1016
  push <dir> --dataset <slug|id> create/resume a delivery and upload (resumable; re-run to resume)
@@ -1008,6 +1109,7 @@ const commands = {
1008
1109
  projects: cmdProjects,
1009
1110
  datasets: cmdDatasets,
1010
1111
  'sample-depth': cmdSampleDepth,
1112
+ roles: cmdRoles,
1011
1113
  contract: cmdContract,
1012
1114
  check: cmdCheck,
1013
1115
  push: cmdPush,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myelinbridge/cli",
3
- "version": "0.10.0",
3
+ "version": "0.12.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": {