@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.
- package/README.md +36 -0
- package/bin/myelin.js +103 -1
- 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(
|
|
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.
|
|
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": {
|