@jbrowse/cli 4.2.1 → 5.0.0-beta.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 (54) hide show
  1. package/README.md +334 -60
  2. package/bin/run +1 -1
  3. package/bundle/index.js +493 -2555
  4. package/dist/base.js +0 -3
  5. package/dist/bin.js +2 -1
  6. package/dist/cliFetch.js +3 -0
  7. package/dist/commands/add-assembly/index.js +46 -19
  8. package/dist/commands/add-assembly/utils.js +204 -290
  9. package/dist/commands/add-connection.js +73 -79
  10. package/dist/commands/add-track-json.js +24 -12
  11. package/dist/commands/add-track-utils/adapter-utils.js +173 -283
  12. package/dist/commands/add-track-utils/file-operations.js +27 -11
  13. package/dist/commands/add-track-utils/multiwig.js +47 -0
  14. package/dist/commands/add-track-utils/track-config.js +68 -25
  15. package/dist/commands/add-track-utils/validators.js +36 -20
  16. package/dist/commands/add-track.js +178 -62
  17. package/dist/commands/admin-server/index.js +30 -49
  18. package/dist/commands/admin-server/utils.js +235 -125
  19. package/dist/commands/create.js +18 -34
  20. package/dist/commands/make-pif/cigar-utils.js +62 -19
  21. package/dist/commands/make-pif/index.js +98 -11
  22. package/dist/commands/make-pif/pif-generator.js +276 -42
  23. package/dist/commands/process-utils.js +13 -2
  24. package/dist/commands/remove-track.js +20 -13
  25. package/dist/commands/set-default-session.js +35 -31
  26. package/dist/commands/shared/config-operations.js +3 -10
  27. package/dist/commands/shared/sort-utils.js +67 -10
  28. package/dist/commands/shared/validators.js +38 -3
  29. package/dist/commands/sort-bed.js +2 -33
  30. package/dist/commands/sort-gff.js +2 -33
  31. package/dist/commands/text-index/adapter-utils.js +6 -23
  32. package/dist/commands/text-index/aggregate.js +40 -34
  33. package/dist/commands/text-index/command.js +35 -7
  34. package/dist/commands/text-index/config-utils.js +60 -83
  35. package/dist/commands/text-index/file-list.js +32 -18
  36. package/dist/commands/text-index/index.js +4 -4
  37. package/dist/commands/text-index/indexing-utils.js +49 -73
  38. package/dist/commands/text-index/per-track.js +44 -31
  39. package/dist/commands/text-index/validators.js +1 -1
  40. package/dist/commands/upgrade.js +20 -32
  41. package/dist/commands/validate/configManifest.generated.js +5394 -0
  42. package/dist/commands/validate/displayDefaultKeys.js +28 -0
  43. package/dist/commands/validate/index.js +97 -0
  44. package/dist/commands/validate/suggest.js +35 -0
  45. package/dist/commands/validate/types.js +6 -0
  46. package/dist/commands/validate/validateConfig.js +457 -0
  47. package/dist/index.js +99 -50
  48. package/dist/types/common.js +3 -27
  49. package/dist/utils.js +309 -128
  50. package/dist/version.js +1 -1
  51. package/package.json +8 -10
  52. package/dist/commands/make-pif/file-utils.js +0 -30
  53. package/dist/commands/track-utils.js +0 -74
  54. package/dist/util.js +0 -35
@@ -1,7 +1,8 @@
1
- import { parseArgs } from 'util';
2
- import { createPIF, getOutputFilename, spawnSortProcess, waitForProcessClose, } from "./pif-generator.js";
3
- import { printHelp } from "../../utils.js";
4
- import { validateFileArgument, validateRequiredCommands, } from "../shared/validators.js";
1
+ import { parseArgs } from 'node:util';
2
+ import { printHelp } from '../../utils.js';
3
+ import { describeExit, waitForProcessClose } from '../process-utils.js';
4
+ import { validateFileArgument, validateRequiredCommands, } from '../shared/validators.js';
5
+ import { DEFAULT_COARSE_SPLIT_GAP, createPIF, getOutputFilename, missingPairs, spawnSortProcess, } from './pif-generator.js';
5
6
  export async function run(args) {
6
7
  const options = {
7
8
  help: {
@@ -16,6 +17,14 @@ export async function run(args) {
16
17
  type: 'boolean',
17
18
  description: 'Create a CSI index for the PIF file instead of TBI',
18
19
  },
20
+ coarse: {
21
+ type: 'string',
22
+ description: 'Minimum insertion/deletion length (bp) at which a coarse-tier row is split into multiple pieces so each row stays tight — 0 emits an unsplit coarse tier. Defaults to 10000. The no-CIGAR coarse tier (prefix T/Q) is emitted alongside the per-row CIGAR fine tier by default so whole-genome synteny views can auto-switch to it; pass --no-coarse to omit it.',
23
+ },
24
+ 'no-coarse': {
25
+ type: 'boolean',
26
+ description: 'Do not emit the coarse no-CIGAR tier; write only the per-row CIGAR fine tier.',
27
+ },
19
28
  };
20
29
  const { values: flags, positionals } = parseArgs({
21
30
  args,
@@ -24,14 +33,30 @@ export async function run(args) {
24
33
  });
25
34
  const description = 'creates pairwise indexed PAF (PIF), with bgzip and tabix';
26
35
  const examples = [
27
- '$ jbrowse make-pif input.paf # creates input.pif.gz in same directory',
36
+ '# creates input.pif.gz and input.pif.gz.tbi in the same directory',
37
+ '$ jbrowse make-pif input.paf',
38
+ '',
39
+ '# specify the output file, also creates output.pif.gz.tbi',
40
+ '$ jbrowse make-pif input.paf --out output.pif.gz',
41
+ '',
42
+ '# use a CSI index for assemblies with chromosomes longer than ~512 Mb',
43
+ '$ jbrowse make-pif input.paf --csi',
44
+ '',
45
+ '# emit an unsplit coarse tier (alongside the fine tier)',
46
+ '$ jbrowse make-pif input.paf --coarse 0',
28
47
  '',
29
- '$ jbrowse make-pif input.paf --out output.pif.gz # specify output file, creates output.pif.gz.tbi also',
48
+ '# emit only the per-row CIGAR fine tier, skipping the coarse tier',
49
+ '$ jbrowse make-pif input.paf --no-coarse',
30
50
  ];
51
+ const notes = 'Use --csi for assemblies containing sequences longer than ~512 Mb. The ' +
52
+ 'default TBI index cannot address coordinates beyond 2^29 (~536 Mb), so a ' +
53
+ 'CSI index is required for large chromosomes (e.g. some plant and ' +
54
+ 'amphibian genomes). Requires sh, sort, bgzip, and tabix on the PATH.';
31
55
  if (flags.help) {
32
56
  printHelp({
33
57
  description,
34
58
  examples,
59
+ notes,
35
60
  usage: 'jbrowse make-pif <file> [options]',
36
61
  options,
37
62
  });
@@ -39,11 +64,73 @@ export async function run(args) {
39
64
  }
40
65
  const file = positionals[0];
41
66
  validateFileArgument(file, 'make-pif', 'paf');
42
- validateRequiredCommands(['sh', 'sort', 'grep', 'tabix', 'bgzip']);
43
- const { out, csi = false } = flags;
67
+ validateRequiredCommands(['sh', 'sort', 'tabix', 'bgzip']);
68
+ const { out, csi = false, coarse, 'no-coarse': noCoarse = false } = flags;
44
69
  const outputFile = getOutputFilename(file, out);
70
+ // --no-coarse used to silently win over an explicit --coarse, so a run asking
71
+ // for both wrote a file with no coarse tier and said nothing about it
72
+ if (noCoarse && coarse !== undefined) {
73
+ throw new Error('--coarse and --no-coarse are mutually exclusive');
74
+ }
75
+ const coarseSplitGap = noCoarse
76
+ ? undefined
77
+ : coarse === undefined
78
+ ? DEFAULT_COARSE_SPLIT_GAP
79
+ : +coarse;
80
+ if (coarseSplitGap !== undefined &&
81
+ (!Number.isFinite(coarseSplitGap) || coarseSplitGap < 0)) {
82
+ throw new Error(`Invalid --coarse value: ${coarse}`);
83
+ }
45
84
  const child = spawnSortProcess(outputFile, csi);
46
- await createPIF(file, child.stdin);
47
- child.stdin.end();
48
- await waitForProcessClose(child);
85
+ const stdin = child.stdin;
86
+ // end stdin even if createPIF throws, otherwise the spawned sort/index child
87
+ // is left running with an open stdin
88
+ const stats = await createPIF(file, stdin, coarseSplitGap).finally(() => {
89
+ stdin.end();
90
+ });
91
+ const { samples, rows, skipped } = stats;
92
+ // no SIGPIPE exemption here (unlike sort-gff/sort-bed): this pipeline's output
93
+ // is the .pif.gz file, so anything that killed it early left that file broken
94
+ const exit = await waitForProcessClose(child);
95
+ if (exit.code !== 0) {
96
+ throw new Error(`PIF sort/index pipeline exited with ${describeExit(exit)}`);
97
+ }
98
+ // A file that yielded nothing is almost always the wrong file, not an empty
99
+ // alignment: without this the command wrote a valid, indexed, empty PIF and
100
+ // printed the add-track suggestion for it
101
+ if (rows === 0) {
102
+ throw new Error(`No valid PAF rows found in ${file ?? 'stdin'} (${skipped} line(s) had fewer than the 12 mandatory PAF columns). Is this a PAF file?`);
103
+ }
104
+ if (skipped > 0) {
105
+ console.warn(`Warning: skipped ${skipped} of ${rows + skipped} line(s) with fewer than the 12 mandatory PAF columns`);
106
+ }
107
+ // An all-vs-all PAF that does not state every pair indexes perfectly well and
108
+ // then draws an empty synteny band for each pair the aligner never emitted,
109
+ // which reads as "no homology here" rather than as "never computed". This is
110
+ // the moment someone is looking at the file, and the census it needs was
111
+ // already taken on the pass above.
112
+ const missing = missingPairs(stats);
113
+ if (missing.length > 0) {
114
+ const shown = missing
115
+ .slice(0, 5)
116
+ .map(([a, b]) => `${a}<->${b}`)
117
+ .join(', ');
118
+ const rest = missing.length - Math.min(missing.length, 5);
119
+ console.warn(`Warning: this file has ${samples.size} samples but states only ` +
120
+ `${(samples.size * (samples.size - 1)) / 2 - missing.length} of their ` +
121
+ `${(samples.size * (samples.size - 1)) / 2} pairs. Synteny bands for the ` +
122
+ `missing pairs (${shown}${rest > 0 ? `, and ${rest} more` : ''}) will draw ` +
123
+ `empty. That is what a reference-anchored alignment looks like — only ` +
124
+ `pairs involving the reference are stated. Order the synteny rows so the ` +
125
+ `reference sits between the others, use a complete all-vs-all if the ` +
126
+ `project publishes one, or view a larger cohort as a multiple alignment ` +
127
+ `(MAF) rather than a stack of pairwise bands.`);
128
+ }
129
+ const indexFile = `${outputFile}.${csi ? 'csi' : 'tbi'}`;
130
+ const nextCommand = samples.size > 0
131
+ ? 'Next, add it as an all-vs-all synteny track (PanSN names detected):\n' +
132
+ ` jbrowse add-track ${outputFile} --adapterType AllVsAllIndexedPAFAdapter -a ${[...samples].sort().join(',')} --load copy`
133
+ : 'Next, add it as a synteny track (set -a to your query,target assembly names):\n' +
134
+ ` jbrowse add-track ${outputFile} -a query,target --load copy`;
135
+ console.log(`Created ${outputFile} and ${indexFile}\n\n${nextCommand}`);
49
136
  }
@@ -1,55 +1,289 @@
1
- import { spawn } from 'child_process';
2
- import path from 'path';
3
- import { flipCigar, parseCigar, swapIndelCigar } from "./cigar-utils.js";
4
- import { createWriteWithBackpressure, getReadline, getStdReadline, } from "./file-utils.js";
5
- export async function createPIF(filename, stream) {
6
- const rl1 = filename ? getReadline(filename) : getStdReadline();
7
- const writeWithBackpressure = createWriteWithBackpressure(stream);
8
- // Process the file line by line with backpressure handling
9
- try {
10
- for await (const line of rl1) {
11
- const [c1, l1, s1, e1, strand, c2, l2, s2, e2, ...rest] = line.split('\t');
12
- // Write the first line and handle backpressure
13
- await writeWithBackpressure(`${[`t${c2}`, l2, s2, e2, strand, c1, l1, s1, e1, ...rest].join('\t')}\n`);
14
- const cigarIdx = rest.findIndex(f => f.startsWith('cg:Z'));
15
- const CIGAR = rest[cigarIdx];
16
- if (CIGAR) {
17
- rest[cigarIdx] = `cg:Z:${strand === '-'
18
- ? flipCigar(parseCigar(CIGAR.slice(5))).join('')
19
- : swapIndelCigar(CIGAR.slice(5))}`;
1
+ import { spawn } from 'node:child_process';
2
+ import { createReadStream } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { Transform } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { createGunzip } from 'node:zlib';
7
+ import { csToCigar, flipCigar, pafIdentity, swapIndelCigar, } from '@jbrowse/cigar-utils';
8
+ import { splitCigarOnLargeGaps } from './cigar-utils.js';
9
+ // Default split gap (bp) for the coarse tier. A row is broken into multiple
10
+ // coarse pieces wherever a CIGAR insertion/deletion is at least this long, so
11
+ // each coarse row's bounding box stays tight. 10kb matches the adapter's
12
+ // default coarseBpPerPxThreshold (~1px at the zoom where the coarse tier is
13
+ // served), so smaller gaps that would be sub-pixel there don't fragment rows.
14
+ export const DEFAULT_COARSE_SPLIT_GAP = 10_000;
15
+ function panSNSample(refName) {
16
+ const i = refName.indexOf('#');
17
+ return i === -1 ? undefined : refName.slice(0, i);
18
+ }
19
+ function addPanSNPair(stats, c1, c2) {
20
+ const a = panSNSample(c1);
21
+ const b = panSNSample(c2);
22
+ if (a !== undefined) {
23
+ stats.samples.add(a);
24
+ }
25
+ if (b !== undefined) {
26
+ stats.samples.add(b);
27
+ }
28
+ if (a !== undefined && b !== undefined && a !== b) {
29
+ stats.pairs.add(a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
30
+ }
31
+ }
32
+ /**
33
+ * The sample pairs a complete all-vs-all would state but this file does not.
34
+ * Empty for a pairwise PAF (no PanSN names) and for a genuinely complete file.
35
+ */
36
+ export function missingPairs({ samples, pairs }) {
37
+ const sorted = [...samples].sort();
38
+ const out = [];
39
+ for (let i = 0; i < sorted.length; i++) {
40
+ for (let j = i + 1; j < sorted.length; j++) {
41
+ const a = sorted[i];
42
+ const b = sorted[j];
43
+ if (!pairs.has(`${a}\u0000${b}`)) {
44
+ out.push([a, b]);
20
45
  }
21
- // Write the second line and handle backpressure
22
- await writeWithBackpressure(`${[`q${c1}`, l1, s1, e1, strand, c2, l2, s2, e2, ...rest].join('\t')}\n`);
23
46
  }
24
47
  }
25
- catch (error) {
26
- console.error('Error processing PAF file:', error);
27
- throw error;
48
+ return out;
49
+ }
50
+ function coarsePieces({ cigar, strand, tstart, tend, qstart, qend, numMatches, blockLen, splitGap, }) {
51
+ const whole = { tstart, tend, qstart, qend, numMatches, blockLen };
52
+ const split = cigar && splitGap > 0
53
+ ? splitCigarOnLargeGaps({
54
+ cigar,
55
+ strand,
56
+ tstart,
57
+ tend,
58
+ qstart,
59
+ qend,
60
+ splitGap,
61
+ })
62
+ : undefined;
63
+ // The walk's reconstruction is taken only when it CLOSED on the row's own far
64
+ // corner (see `CigarSplit.closed`) — otherwise the columns are what the fine
65
+ // tier draws and the coarse row must not drift off them.
66
+ //
67
+ // Closure rather than a piece count: `segments.length < 2` read as "nothing to
68
+ // split on", but a large gap at either END also yields a single piece, a
69
+ // genuine tightening that was being thrown away for the loose box. It is the
70
+ // ordinary case below the default `--coarse`. The count also never noticed a
71
+ // MULTI-piece walk that failed to close, which is the case it was written for.
72
+ return split?.closed && split.segments.length > 0
73
+ ? split.segments.map(seg => ({
74
+ ...seg,
75
+ numMatches: blockLen > 0 ? Math.round((numMatches * seg.blockLen) / blockLen) : 0,
76
+ }))
77
+ : [whole];
78
+ }
79
+ // A PIF row carries exactly ONE alignment string — `cg:Z:`, in the orientation
80
+ // of the perspective it is indexed under. A minimap2 `cs:Z:` is folded into that
81
+ // CIGAR and never emitted, for two reasons:
82
+ //
83
+ // - `cs` has its own orientation, and reorienting it means reversing op order
84
+ // AND reverse-complementing the spelled-out bases. Keeping an unflipped `cs`
85
+ // beside a flipped `cg` is silently wrong rather than merely lossy:
86
+ // `SyntenyFeature.forEachMismatch` prefers `cs`, so a row from
87
+ // `minimap2 -c --cs` (which emits both tags) drew every indel with reversed
88
+ // sense from the query perspective.
89
+ // - `csToCigar` yields `=`/`X`, so folding a `cs` in is strictly MORE
90
+ // informative than minimap2's own M-style `cg` — which is why `cs` wins when
91
+ // a row carries both. The substituted base letters are the only thing lost;
92
+ // mismatch positions survive as `X`.
93
+ //
94
+ // Returns the rewritten optional-tag list and where the surviving alignment
95
+ // string sits in it (-1 when the row carries none).
96
+ function foldCsIntoCg(tags) {
97
+ const csIdx = tags.findIndex(f => f.startsWith('cs:Z'));
98
+ if (csIdx === -1) {
99
+ return { tags, cigarIdx: tags.findIndex(f => f.startsWith('cg:Z')) };
100
+ }
101
+ const folded = `cg:Z:${csToCigar(tags[csIdx].slice(5))}`;
102
+ const rewritten = tags.flatMap((f, i) => i === csIdx ? [folded] : f.startsWith('cg:Z') ? [] : [f]);
103
+ return { tags: rewritten, cigarIdx: rewritten.indexOf(folded) };
104
+ }
105
+ // Coarse identity must match the fine tier exactly, or coloring jumps at the
106
+ // zoom where the view switches tiers. So it is written off the SAME function the
107
+ // adapters read the fine tier with (de:f: tag, then odgi's id:f:, then
108
+ // num_matches/block_len) rather than a private copy of that chain — a copy is
109
+ // what previously skipped the id:f: rung. A CIGAR recompute is deliberately not
110
+ // in the chain at all: a cg (M-style) CIGAR folds mismatches into M, so it would
111
+ // report ~0 divergence (spurious 100% identity) for a divergent alignment.
112
+ //
113
+ // When the row's own de:f: is what the reader lands on, that string is passed
114
+ // through byte-for-byte rather than recomputed: a round trip through toFixed(6)
115
+ // would silently truncate a 7-decimal tag and drift the two tiers apart by the
116
+ // exact mechanism this is guarding against.
117
+ function coarseDivergence(tags, numMatches, blockLen) {
118
+ const rawDe = tags.find(f => f.startsWith('de:f:'))?.slice(5);
119
+ const identity = pafIdentity({
120
+ de: rawDe,
121
+ id: tags.find(f => f.startsWith('id:f:'))?.slice(5),
122
+ numMatches,
123
+ blockLen,
124
+ });
125
+ return rawDe !== undefined && 1 - +rawDe === identity
126
+ ? rawDe
127
+ : (1 - identity).toFixed(6);
128
+ }
129
+ function pifRow(fields) {
130
+ return `${fields.join('\t')}\n`;
131
+ }
132
+ function processLine(line, coarseSplitGap, stats) {
133
+ if (line.startsWith('#')) {
134
+ return '';
28
135
  }
29
- finally {
30
- rl1.close();
136
+ const parts = line.split('\t');
137
+ // A valid PAF row has 12 mandatory columns; anything shorter (blank, or
138
+ // truncated) would produce NaN coords, so skip it rather than emit garbage.
139
+ if (parts.length < 12) {
140
+ stats.skipped++;
141
+ return '';
31
142
  }
143
+ stats.rows++;
144
+ // rest[0]=num_matches, rest[1]=block_len, rest[2]=mapq, rest[3+]=optional tags
145
+ const [c1, l1, s1, e1, strand, c2, l2, s2, e2, ...rest] = parts;
146
+ addPanSNPair(stats, c1, c2);
147
+ const { tags, cigarIdx } = foldCsIntoCg(rest);
148
+ const cigar = cigarIdx === -1 ? undefined : tags[cigarIdx].slice(5);
149
+ // the t-row keeps the CIGAR as PAF spelled it (target perspective); the q-row
150
+ // re-orients it for the query perspective it is indexed under
151
+ const queryTags = cigar === undefined
152
+ ? tags
153
+ : tags.map((f, i) => i === cigarIdx
154
+ ? `cg:Z:${strand === '-' ? flipCigar(cigar) : swapIndelCigar(cigar)}`
155
+ : f);
156
+ const fineRows = pifRow([`t${c2}`, l2, s2, e2, strand, c1, l1, s1, e1, ...tags]) +
157
+ pifRow([`q${c1}`, l1, s1, e1, strand, c2, l2, s2, e2, ...queryTags]);
158
+ if (coarseSplitGap === undefined) {
159
+ return fineRows;
160
+ }
161
+ const numMatches = +tags[0];
162
+ const blockLen = +tags[1];
163
+ const mapq = tags[2];
164
+ // Every optional tag except the alignment string itself rides along, so a
165
+ // click on a coarse ribbon shows the same attributes as the same alignment
166
+ // does zoomed in (rustybam's tags, minimap2's tp/cm/s1, odgi's id). Only the
167
+ // CIGAR is dropped — dropping it is the entire point of the tier — and de:f:,
168
+ // which is rewritten below. No `cs:Z:` can reach here: it was folded into the
169
+ // cg above, since a PIF row carries one alignment string.
170
+ const passthrough = tags
171
+ .slice(3)
172
+ .filter(f => !f.startsWith('cg:Z:') && !f.startsWith('de:f:'));
173
+ const de = coarseDivergence(tags, numMatches, blockLen);
174
+ return (fineRows +
175
+ coarsePieces({
176
+ cigar,
177
+ strand: strand,
178
+ tstart: +s2,
179
+ tend: +e2,
180
+ qstart: +s1,
181
+ qend: +e1,
182
+ numMatches,
183
+ blockLen,
184
+ splitGap: coarseSplitGap,
185
+ })
186
+ .flatMap(piece => {
187
+ const tail = [
188
+ piece.numMatches,
189
+ piece.blockLen,
190
+ mapq,
191
+ ...passthrough,
192
+ `de:f:${de}`,
193
+ ];
194
+ const { tstart, tend, qstart, qend } = piece;
195
+ return [
196
+ pifRow([
197
+ `T${c2}`,
198
+ l2,
199
+ tstart,
200
+ tend,
201
+ strand,
202
+ c1,
203
+ l1,
204
+ qstart,
205
+ qend,
206
+ ...tail,
207
+ ]),
208
+ pifRow([
209
+ `Q${c1}`,
210
+ l1,
211
+ qstart,
212
+ qend,
213
+ strand,
214
+ c2,
215
+ l2,
216
+ tstart,
217
+ tend,
218
+ ...tail,
219
+ ]),
220
+ ];
221
+ })
222
+ .join(''));
223
+ }
224
+ function makePifTransform(coarseSplitGap, stats) {
225
+ let tail = '';
226
+ return new Transform({
227
+ transform(chunk, _enc, callback) {
228
+ const data = tail + chunk.toString('utf8');
229
+ const lastNl = data.lastIndexOf('\n');
230
+ if (lastNl === -1) {
231
+ tail = data;
232
+ callback();
233
+ return;
234
+ }
235
+ tail = data.slice(lastNl + 1);
236
+ callback(null, data
237
+ .slice(0, lastNl)
238
+ .split('\n')
239
+ .filter(Boolean)
240
+ .map(l => processLine(l, coarseSplitGap, stats))
241
+ .join(''));
242
+ },
243
+ flush(callback) {
244
+ callback(null, tail ? processLine(tail, coarseSplitGap, stats) : '');
245
+ },
246
+ });
247
+ }
248
+ // resolves to what the pass observed: the PanSN samples (empty for a pairwise
249
+ // PAF), so the caller can suggest the right adapter and assembly names, and the
250
+ // row/skip counts so it can warn on a file that was not PAF at all
251
+ export async function createPIF(filename, stream, coarseSplitGap) {
252
+ const stats = {
253
+ samples: new Set(),
254
+ pairs: new Set(),
255
+ rows: 0,
256
+ skipped: 0,
257
+ };
258
+ const transform = makePifTransform(coarseSplitGap, stats);
259
+ if (filename) {
260
+ const source = createReadStream(filename);
261
+ await (/\.b?gz$/i.test(filename)
262
+ ? pipeline(source, createGunzip(), transform, stream)
263
+ : pipeline(source, transform, stream));
264
+ }
265
+ else {
266
+ await pipeline(process.stdin, transform, stream);
267
+ }
268
+ return stats;
32
269
  }
33
270
  export function spawnSortProcess(outputFile, useCsi) {
34
- // Use a more portable approach to avoid E2BIG errors
35
271
  const sortCmd = `sort -t"\`printf '\\t'\`" -k1,1 -k3,3n`;
36
- const bgzipCommand = `bgzip > "${outputFile}"`;
37
- const tabixCommand = `tabix ${useCsi ? '-C ' : ''}-s1 -b3 -e4 -0 "${outputFile}"`;
38
- const fullCommand = `${sortCmd} | ${bgzipCommand}; ${tabixCommand}`;
39
- const minimalEnv = {
40
- ...process.env,
41
- LC_ALL: 'C',
42
- };
43
- return spawn('sh', ['-c', fullCommand], {
44
- env: minimalEnv,
272
+ const bgzipCommand = `bgzip > "$1"`;
273
+ const tabixCommand = `tabix ${useCsi ? '-C ' : ''}-s1 -b3 -e4 -0 "$1"`;
274
+ // `&&` (not `;`) so a bgzip failure aborts before tabix runs and propagates
275
+ // as the pipeline's exit code. The output path is passed as the shell's "$1"
276
+ // positional rather than interpolated into the command string, so a path
277
+ // with shell metacharacters (`"`, `$(...)`, backticks) can't break out and
278
+ // execute — same technique as sort-utils.ts. useCsi is a fixed literal.
279
+ const fullCommand = `${sortCmd} | ${bgzipCommand} && ${tabixCommand}`;
280
+ return spawn('sh', ['-c', fullCommand, 'sh', outputFile], {
281
+ env: { ...process.env, LC_ALL: 'C' },
45
282
  stdio: ['pipe', process.stdout, process.stderr],
46
283
  });
47
284
  }
48
285
  export function getOutputFilename(file, out) {
49
- return out || `${path.basename(file || 'output', '.paf')}.pif.gz`;
50
- }
51
- export async function waitForProcessClose(child) {
52
- return new Promise(resolve => {
53
- child.on('close', resolve);
54
- });
286
+ // strip .paf or .paf.gz so a gzipped input doesn't yield input.paf.gz.pif.gz
287
+ const base = path.basename(file || 'output').replace(/\.paf(\.gz)?$/i, '');
288
+ return out || `${base}.pif.gz`;
55
289
  }
@@ -1,10 +1,21 @@
1
1
  export async function waitForProcessClose(child) {
2
2
  return new Promise((resolve, reject) => {
3
- child.on('close', code => {
4
- resolve(code);
3
+ child.on('close', (code, signal) => {
4
+ resolve({ code, signal });
5
5
  });
6
6
  child.on('error', err => {
7
7
  reject(err);
8
8
  });
9
9
  });
10
10
  }
11
+ // A downstream consumer closing the pipe early — `jbrowse sort-bed big.bed |
12
+ // head` — is that consumer's choice, not a failure of ours. `sh` reports a
13
+ // pipeline member's death by SIGPIPE as 128+13 rather than propagating the
14
+ // signal, so both spellings have to be recognized.
15
+ export function diedOfSigpipe({ code, signal }) {
16
+ return signal === 'SIGPIPE' || code === 128 + 13;
17
+ }
18
+ // for an error message: the exit code, or the signal when the child was killed
19
+ export function describeExit({ code, signal }) {
20
+ return code === null ? `signal ${signal}` : `code ${code}`;
21
+ }
@@ -1,5 +1,5 @@
1
- import { parseArgs } from 'util';
2
- import { printHelp, readJsonFile, resolveConfigPath, writeJsonFile, } from "../utils.js";
1
+ import { parseArgs } from 'node:util';
2
+ import { printHelp, readConfigFile, requirePositional, resolveConfigPath, writeJsonFile, } from '../utils.js';
3
3
  export async function run(args) {
4
4
  const options = {
5
5
  help: {
@@ -21,30 +21,37 @@ export async function run(args) {
21
21
  allowPositionals: true,
22
22
  });
23
23
  const description = 'Remove a track configuration from a JBrowse 2 configuration. Be aware that this can cause crashes in saved sessions that refer to this track!';
24
- const examples = ['$ jbrowse remove-track trackId'];
24
+ const usage = 'jbrowse remove-track <trackId> [options]';
25
+ const examples = [
26
+ '# remove a track from the config.json in the current directory',
27
+ '$ jbrowse remove-track my_track_id',
28
+ '',
29
+ '# remove a track from a config.json in a specific installation directory',
30
+ '$ jbrowse remove-track my_track_id --out /path/to/jb2/',
31
+ '',
32
+ '# remove a track from a specific config file',
33
+ '$ jbrowse remove-track my_track_id --target /path/to/jb2/config.json',
34
+ ];
25
35
  if (flags.help) {
26
36
  printHelp({
27
37
  description,
28
38
  examples,
29
- usage: 'jbrowse remove-track <trackId> [options]',
39
+ usage,
30
40
  options,
31
41
  });
32
42
  return;
33
43
  }
34
44
  const trackId = positionals[0];
35
- if (!trackId) {
36
- throw new Error('Missing required argument: trackId\nUsage: jbrowse remove-track <trackId> [options]');
37
- }
45
+ requirePositional(trackId, 'trackId', usage);
38
46
  const target = await resolveConfigPath(flags.target, flags.out);
39
- const config = await readJsonFile(target);
40
- const originalLength = config.tracks?.length || 0;
41
- config.tracks = config.tracks?.filter(({ trackId: id }) => id !== trackId);
42
- const newLength = config.tracks?.length || 0;
43
- if (originalLength === newLength) {
47
+ const config = await readConfigFile(target);
48
+ const tracks = config.tracks ?? [];
49
+ const remaining = tracks.filter(track => track.trackId !== trackId);
50
+ if (remaining.length === tracks.length) {
44
51
  console.log(`No track found with trackId: ${trackId}`);
45
52
  }
46
53
  else {
47
- await writeJsonFile(target, config);
54
+ await writeJsonFile(target, { ...config, tracks: remaining });
48
55
  console.log(`Removed track with trackId: ${trackId} from ${target}`);
49
56
  }
50
57
  }