@jbrowse/cli 4.3.0 → 5.0.0-beta.10

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 (71) hide show
  1. package/README.md +451 -72
  2. package/dist/base.js +0 -3
  3. package/dist/bin.js +2 -1
  4. package/dist/cliFetch.js +3 -0
  5. package/dist/commands/add-assembly/index.js +46 -19
  6. package/dist/commands/add-assembly/utils.js +204 -290
  7. package/dist/commands/add-connection.js +73 -79
  8. package/dist/commands/add-track-json.js +24 -12
  9. package/dist/commands/add-track-utils/adapter-utils.js +189 -288
  10. package/dist/commands/add-track-utils/file-operations.js +27 -11
  11. package/dist/commands/add-track-utils/multiwig.js +47 -0
  12. package/dist/commands/add-track-utils/track-config.js +66 -25
  13. package/dist/commands/add-track-utils/validators.js +36 -20
  14. package/dist/commands/add-track.js +200 -62
  15. package/dist/commands/admin-server/index.js +39 -50
  16. package/dist/commands/admin-server/utils.js +238 -126
  17. package/dist/commands/create.js +18 -34
  18. package/dist/commands/make-density/density-generator.js +180 -0
  19. package/dist/commands/make-density/index.js +139 -0
  20. package/dist/commands/make-pif/index.js +133 -11
  21. package/dist/commands/make-pif/pif-generator.js +243 -43
  22. package/dist/commands/process-utils.js +13 -2
  23. package/dist/commands/remove-track.js +20 -13
  24. package/dist/commands/set-default-session.js +35 -31
  25. package/dist/commands/shared/config-operations.js +3 -10
  26. package/dist/commands/shared/density.js +12 -0
  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 +19 -26
  32. package/dist/commands/text-index/aggregate.js +40 -34
  33. package/dist/commands/text-index/command.js +37 -7
  34. package/dist/commands/text-index/config-utils.js +62 -85
  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 +44 -96
  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 +7613 -0
  42. package/dist/commands/validate/configSchema.generated.js +20400 -0
  43. package/dist/commands/validate/displayDefaultKeys.js +28 -0
  44. package/dist/commands/validate/index.js +113 -0
  45. package/dist/commands/validate/liftConfig.js +34 -0
  46. package/dist/commands/validate/markRules/aggregateFieldName.js +9 -0
  47. package/dist/commands/validate/markRules/autoBin.js +27 -0
  48. package/dist/commands/validate/markRules/colorScale.js +244 -0
  49. package/dist/commands/validate/markRules/cssColorsLevel4.js +162 -0
  50. package/dist/commands/validate/markRules/jexlStrings.js +23 -0
  51. package/dist/commands/validate/markRules/markProblems.js +532 -0
  52. package/dist/commands/validate/markRules/markRuleFacts.js +6 -0
  53. package/dist/commands/validate/markRules/markSpecs.js +96 -0
  54. package/dist/commands/validate/markRules/markVocabulary.js +57 -0
  55. package/dist/commands/validate/markRules/shapeNameList.js +7 -0
  56. package/dist/commands/validate/markRules/stepChannels.js +55 -0
  57. package/dist/commands/validate/schemaValidate.js +425 -0
  58. package/dist/commands/validate/suggest.js +35 -0
  59. package/dist/commands/validate/types.js +6 -0
  60. package/dist/commands/validate/validateConfig.js +502 -0
  61. package/dist/index.js +104 -50
  62. package/dist/types/common.js +3 -27
  63. package/dist/utils.js +309 -128
  64. package/dist/version.js +1 -1
  65. package/package.json +8 -14
  66. package/bin/run +0 -4
  67. package/bundle/index.js +0 -3686
  68. package/dist/commands/make-pif/cigar-utils.js +0 -24
  69. package/dist/commands/make-pif/file-utils.js +0 -30
  70. package/dist/commands/track-utils.js +0 -74
  71. package/dist/util.js +0 -35
@@ -1,55 +1,255 @@
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 { coarsenCigar, csToCigar, flipCigar, flipCoarseCigar, swapCoarseCigar, swapIndelCigar, } from '@jbrowse/cigar-utils';
8
+ import { version } from '../../version.js';
9
+ // Default gap (bp) of the coarse tier's coarse CIGAR: how far a straight line
10
+ // across one of its runs may be from the alignment's real path. Indels longer
11
+ // than half of it keep their letter; shorter ones fold into the runs. 10kb
12
+ // matches the adapter's default coarseBpPerPxThreshold, so at the zoom where the
13
+ // tier is served the bound is ~1px and a folded indel was sub-pixel anyway.
14
+ export const DEFAULT_COARSE_GAP = 10_000;
15
+ // Generation 2 (2026-09): every row carries `pi:i:`, the input row's index,
16
+ // and the header names the writer.
17
+ export const PIF_FORMAT_VERSION = 2;
18
+ /**
19
+ * The one meta line a PIF carries, sorted first by the C-locale sort and kept
20
+ * by tabix as a header. It states the format generation, the writer, the
21
+ * tiers written, the coarse tier's accuracy bound (`--coarse`), and whether
22
+ * every input row had a CIGAR — the facts a reader cannot recover from the rows.
23
+ */
24
+ export function pifHeader(coarseGap, stats) {
25
+ const cigars = stats.cigarRows === 0
26
+ ? 'none'
27
+ : stats.cigarRows === stats.rows
28
+ ? 'all'
29
+ : 'some';
30
+ const tiers = coarseGap === undefined ? 'fine' : 'fine,coarse';
31
+ const bound = coarseGap === undefined ? '' : `\tcoarse:i:${coarseGap}`;
32
+ return `#pif\tversion:i:${PIF_FORMAT_VERSION}\twriter:Z:jbrowse-cli/${version}\ttiers:Z:${tiers}${bound}\tcigars:Z:${cigars}\n`;
33
+ }
34
+ function panSNSample(refName) {
35
+ const i = refName.indexOf('#');
36
+ return i === -1 ? undefined : refName.slice(0, i);
37
+ }
38
+ function addPanSNPair(stats, c1, c2) {
39
+ const a = panSNSample(c1);
40
+ const b = panSNSample(c2);
41
+ if (a !== undefined) {
42
+ stats.samples.add(a);
43
+ }
44
+ if (b !== undefined) {
45
+ stats.samples.add(b);
46
+ }
47
+ if (a !== undefined && b !== undefined && a !== b) {
48
+ stats.pairs.add(a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
49
+ }
50
+ }
51
+ /**
52
+ * The sample pairs a complete all-vs-all would state but this file does not.
53
+ * Empty for a pairwise PAF (no PanSN names) and for a genuinely complete file.
54
+ */
55
+ export function missingPairs({ samples, pairs }) {
56
+ const sorted = [...samples].sort();
57
+ const out = [];
58
+ for (let i = 0; i < sorted.length; i++) {
59
+ for (let j = i + 1; j < sorted.length; j++) {
60
+ const a = sorted[i];
61
+ const b = sorted[j];
62
+ if (!pairs.has(`${a}\u0000${b}`)) {
63
+ out.push([a, b]);
20
64
  }
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
65
  }
24
66
  }
25
- catch (error) {
26
- console.error('Error processing PAF file:', error);
27
- throw error;
67
+ return out;
68
+ }
69
+ // The coarse row's `cr:Z:` value, or nothing when the row has no CIGAR or the
70
+ // fold is a single run that closes on the coordinate columns, which then say it
71
+ // all. A fold of several runs with no kept indel is still written: a lopsided
72
+ // cluster of small indels bends the path by their sum. So is a fold whose walk
73
+ // misses the row's own far corner (clipping ops, a hand-made cg, a cs whose
74
+ // spans don't add up): the fine tier walks that CIGAR from the start corner
75
+ // without checking, and the coarse tier draws the same path only if it walks
76
+ // the same fold.
77
+ function coarseFold({ cigar, coarseGap, ownLen, mateLen, }) {
78
+ if (cigar === undefined) {
79
+ return undefined;
28
80
  }
29
- finally {
30
- rl1.close();
81
+ const coarse = coarsenCigar(cigar, coarseGap);
82
+ const closed = coarse.ownLen === ownLen && coarse.mateLen === mateLen;
83
+ return !closed || coarse.gapCount > 0 || coarse.opCount > 1
84
+ ? coarse.ops
85
+ : undefined;
86
+ }
87
+ // A PIF row carries exactly ONE alignment string — `cg:Z:`, in the orientation
88
+ // of the perspective it is indexed under. A minimap2 `cs:Z:` is folded into that
89
+ // CIGAR and never emitted, for two reasons:
90
+ //
91
+ // - `cs` has its own orientation, and reorienting it means reversing op order
92
+ // AND reverse-complementing the spelled-out bases. Keeping an unflipped `cs`
93
+ // beside a flipped `cg` is silently wrong rather than merely lossy:
94
+ // `SyntenyFeature.forEachMismatch` prefers `cs`, so a row from
95
+ // `minimap2 -c --cs` (which emits both tags) drew every indel with reversed
96
+ // sense from the query perspective.
97
+ // - `csToCigar` yields `=`/`X`, so folding a `cs` in is strictly MORE
98
+ // informative than minimap2's own M-style `cg` — which is why `cs` wins when
99
+ // a row carries both. The substituted base letters are the only thing lost;
100
+ // mismatch positions survive as `X`.
101
+ //
102
+ // Returns the rewritten optional-tag list and where the surviving alignment
103
+ // string sits in it (-1 when the row carries none).
104
+ function foldCsIntoCg(tags) {
105
+ const csIdx = tags.findIndex(f => f.startsWith('cs:Z'));
106
+ if (csIdx === -1) {
107
+ return { tags, cigarIdx: tags.findIndex(f => f.startsWith('cg:Z')) };
31
108
  }
109
+ const folded = `cg:Z:${csToCigar(tags[csIdx].slice(5))}`;
110
+ const rewritten = tags.flatMap((f, i) => i === csIdx ? [folded] : f.startsWith('cg:Z') ? [] : [f]);
111
+ return { tags: rewritten, cigarIdx: rewritten.indexOf(folded) };
112
+ }
113
+ function pifRow(fields) {
114
+ return `${fields.join('\t')}\n`;
32
115
  }
33
- export function spawnSortProcess(outputFile, useCsi) {
34
- // Use a more portable approach to avoid E2BIG errors
35
- 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',
116
+ function processLine(rawLine, coarseGap, stats) {
117
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
118
+ if (line.startsWith('#')) {
119
+ return '';
120
+ }
121
+ const parts = line.split('\t');
122
+ // A valid PAF row has 12 mandatory columns; anything shorter (blank, or
123
+ // truncated) would produce NaN coords, so skip it rather than emit garbage.
124
+ if (parts.length < 12) {
125
+ stats.skipped++;
126
+ return '';
127
+ }
128
+ stats.rows++;
129
+ // rest[0]=num_matches, rest[1]=block_len, rest[2]=mapq, rest[3+]=optional tags
130
+ const [c1, l1, s1, e1, strand, c2, l2, s2, e2, ...rest] = parts;
131
+ addPanSNPair(stats, c1, c2);
132
+ // an incoming cr:Z: or pi:i: (a PIF turned back into PAF, or a tool that
133
+ // adopted the tags) is dropped: the coarse tier writes its own fold below,
134
+ // and every row of this alignment gets this pass's row index as `pi`, the
135
+ // one id its four rows share across perspectives and tiers
136
+ const { tags, cigarIdx } = foldCsIntoCg([
137
+ ...rest.filter(f => !f.startsWith('cr:') && !f.startsWith('pi:')),
138
+ `pi:i:${stats.rows - 1}`,
139
+ ]);
140
+ const cigar = cigarIdx === -1 ? undefined : tags[cigarIdx].slice(5);
141
+ if (cigar !== undefined) {
142
+ stats.cigarRows++;
143
+ }
144
+ // the t-row keeps the CIGAR as PAF spelled it (target perspective); the q-row
145
+ // re-orients it for the query perspective it is indexed under
146
+ const queryTags = cigar === undefined
147
+ ? tags
148
+ : tags.map((f, i) => i === cigarIdx
149
+ ? `cg:Z:${strand === '-' ? flipCigar(cigar) : swapIndelCigar(cigar)}`
150
+ : f);
151
+ const fineRows = pifRow([`t${c2}`, l2, s2, e2, strand, c1, l1, s1, e1, ...tags]) +
152
+ pifRow([`q${c1}`, l1, s1, e1, strand, c2, l2, s2, e2, ...queryTags]);
153
+ if (coarseGap === undefined) {
154
+ return fineRows;
155
+ }
156
+ // the coarse row is the fine row with its CIGAR replaced by the fold and
157
+ // every other column and tag verbatim, so a click and the identity coloring
158
+ // read the same on both tiers
159
+ const coarse = cigarIdx === -1 ? tags : tags.filter((_, i) => i !== cigarIdx);
160
+ // the T row's own axis is the target, as the PAF CIGAR is written; the Q row
161
+ // re-orients it for the query, the way the fine tier's cg is
162
+ const cr = coarseFold({
163
+ cigar,
164
+ coarseGap,
165
+ ownLen: +e2 - +s2,
166
+ mateLen: +e1 - +s1,
167
+ });
168
+ const tCr = cr === undefined ? [] : [`cr:Z:${cr}`];
169
+ const qCr = cr === undefined
170
+ ? []
171
+ : [`cr:Z:${strand === '-' ? flipCoarseCigar(cr) : swapCoarseCigar(cr)}`];
172
+ return (fineRows +
173
+ pifRow([`T${c2}`, l2, s2, e2, strand, c1, l1, s1, e1, ...coarse, ...tCr]) +
174
+ pifRow([`Q${c1}`, l1, s1, e1, strand, c2, l2, s2, e2, ...coarse, ...qCr]));
175
+ }
176
+ function makePifTransform(coarseGap, stats) {
177
+ let tail = '';
178
+ return new Transform({
179
+ transform(chunk, _enc, callback) {
180
+ const data = tail + chunk.toString('utf8');
181
+ const lastNl = data.lastIndexOf('\n');
182
+ if (lastNl === -1) {
183
+ tail = data;
184
+ callback();
185
+ return;
186
+ }
187
+ tail = data.slice(lastNl + 1);
188
+ callback(null, data
189
+ .slice(0, lastNl)
190
+ .split('\n')
191
+ .filter(Boolean)
192
+ .map(l => processLine(l, coarseGap, stats))
193
+ .join(''));
194
+ },
195
+ flush(callback) {
196
+ // the header goes out last and sorts first: the counts it states are
197
+ // only known once every row has been seen, and `#` precedes every tier
198
+ // letter in the C locale
199
+ callback(null, (tail ? processLine(tail, coarseGap, stats) : '') +
200
+ pifHeader(coarseGap, stats));
201
+ },
202
+ });
203
+ }
204
+ // resolves to what the pass observed: the PanSN samples (empty for a pairwise
205
+ // PAF), so the caller can suggest the right adapter and assembly names, and the
206
+ // row/skip counts so it can warn on a file that was not PAF at all
207
+ export async function createPIF(filename, stream, coarseGap) {
208
+ const stats = {
209
+ samples: new Set(),
210
+ pairs: new Set(),
211
+ rows: 0,
212
+ cigarRows: 0,
213
+ skipped: 0,
42
214
  };
43
- return spawn('sh', ['-c', fullCommand], {
44
- env: minimalEnv,
215
+ const transform = makePifTransform(coarseGap, stats);
216
+ if (filename) {
217
+ const source = createReadStream(filename);
218
+ await (/\.b?gz$/i.test(filename)
219
+ ? pipeline(source, createGunzip(), transform, stream)
220
+ : pipeline(source, transform, stream));
221
+ }
222
+ else {
223
+ await pipeline(process.stdin, transform, stream);
224
+ }
225
+ return stats;
226
+ }
227
+ // bgzip is the only stage of this pipeline that cannot use more than one core on
228
+ // its own: sort parallelizes itself and tabix is I/O bound. 4 is enough to take
229
+ // compression off the critical path on the files this command is pointed at
230
+ // without claiming a whole machine.
231
+ export const DEFAULT_BGZIP_THREADS = 4;
232
+ export function spawnSortProcess(outputFile, useCsi, threads = DEFAULT_BGZIP_THREADS) {
233
+ // -s: tabix wants the rows ordered by (refName, start) and by nothing else,
234
+ // so the whole-line comparison a non-stable sort falls back to on a tie is
235
+ // comparing multi-kb CIGARs to pick an order no reader can observe
236
+ const sortCmd = `sort -s -t"\`printf '\\t'\`" -k1,1 -k3,3n`;
237
+ const bgzipCommand = `bgzip -@ ${threads} > "$1"`;
238
+ const tabixCommand = `tabix ${useCsi ? '-C ' : ''}-s1 -b3 -e4 -0 "$1"`;
239
+ // `&&` (not `;`) so a bgzip failure aborts before tabix runs and propagates
240
+ // as the pipeline's exit code. The output path is passed as the shell's "$1"
241
+ // positional rather than interpolated into the command string, so a path
242
+ // with shell metacharacters (`"`, `$(...)`, backticks) can't break out and
243
+ // execute — same technique as sort-utils.ts. useCsi is a fixed literal, and
244
+ // the caller has already checked threads is a positive integer.
245
+ const fullCommand = `${sortCmd} | ${bgzipCommand} && ${tabixCommand}`;
246
+ return spawn('sh', ['-c', fullCommand, 'sh', outputFile], {
247
+ env: { ...process.env, LC_ALL: 'C' },
45
248
  stdio: ['pipe', process.stdout, process.stderr],
46
249
  });
47
250
  }
48
251
  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
- });
252
+ // strip .paf or .paf.gz so a gzipped input doesn't yield input.paf.gz.pif.gz
253
+ const base = path.basename(file || 'output').replace(/\.paf(\.gz)?$/i, '');
254
+ return out || `${base}.pif.gz`;
55
255
  }
@@ -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
  }
@@ -1,7 +1,5 @@
1
- import { promises as fsPromises } from 'fs';
2
- import { parseArgs } from 'util';
3
- import parseJSON from 'json-parse-better-errors';
4
- import { printHelp, readJsonFile, resolveConfigPath, writeJsonFile, } from "../utils.js";
1
+ import { parseArgs } from 'node:util';
2
+ import { isRecord, printHelp, readConfigFile, readJsonFile, resolveConfigPath, writeJsonFile, } from '../utils.js';
5
3
  const description = 'Set a default session with views and tracks';
6
4
  const examples = [
7
5
  '# set default session for the config.json in your current directory',
@@ -10,20 +8,28 @@ const examples = [
10
8
  '# make session.json the defaultSession on the specified target config.json file',
11
9
  '$ jbrowse set-default-session --target /path/to/jb2/installation/config.json --session session.json',
12
10
  '',
13
- '# print current default session',
14
- '$ jbrowse set-default-session --currentSession # Prints out current default session',
11
+ '# override the name stored in the session file',
12
+ '$ jbrowse set-default-session --session session.json --name "My default view"',
13
+ '',
14
+ '# pipe a session in from another program',
15
+ `$ jq '.defaultSession' other/config.json | jbrowse set-default-session --session -`,
16
+ '',
17
+ '# print the current default session',
18
+ '$ jbrowse set-default-session --currentSession',
19
+ '',
20
+ '# remove the existing default session',
21
+ '$ jbrowse set-default-session --delete',
15
22
  ];
16
23
  const options = {
17
24
  session: {
18
25
  type: 'string',
19
26
  short: 's',
20
- description: 'set path to a file containing session in json format (required, unless using delete/currentSession flags)',
27
+ description: 'set path to a file containing session in json format, or "-" to read it from stdin (required, unless using delete/currentSession flags)',
21
28
  },
22
29
  name: {
23
30
  type: 'string',
24
31
  short: 'n',
25
- description: 'Give a name for the default session',
26
- default: 'New Default Session',
32
+ description: 'Give a name for the default session (overrides any name in the session file; defaults to "New Default Session")',
27
33
  },
28
34
  currentSession: {
29
35
  type: 'boolean',
@@ -47,14 +53,14 @@ export async function run(args) {
47
53
  printHelp({
48
54
  description,
49
55
  examples,
50
- usage: 'jbrowse add-track <track> [options]',
56
+ usage: 'jbrowse set-default-session [options]',
51
57
  options,
52
58
  });
53
59
  return;
54
60
  }
55
- const { session, currentSession, delete: deleteDefaultSession } = runFlags;
61
+ const { session, name, currentSession, delete: deleteDefaultSession, } = runFlags;
56
62
  const target = await resolveConfigPath(runFlags.target, runFlags.out);
57
- const configContents = await readJsonFile(target);
63
+ const configContents = await readConfigFile(target);
58
64
  if (deleteDefaultSession) {
59
65
  configContents.defaultSession = undefined;
60
66
  await writeJsonFile(target, configContents);
@@ -65,30 +71,28 @@ export async function run(args) {
65
71
  else if (!session) {
66
72
  throw new Error('Please provide a --session file');
67
73
  }
68
- else if (session) {
74
+ else {
75
+ const fileSession = await readDefaultSessionFile(session);
69
76
  await writeJsonFile(target, {
70
77
  ...configContents,
71
- defaultSession: await readDefaultSessionFile(session),
78
+ // precedence: explicit --name > the session file's own name > fallback
79
+ defaultSession: {
80
+ name: 'New Default Session',
81
+ ...fileSession,
82
+ ...(name !== undefined ? { name } : {}),
83
+ },
72
84
  });
73
85
  }
74
86
  }
75
87
  async function readDefaultSessionFile(defaultSessionFile) {
76
- let defaultSessionJson;
77
- try {
78
- defaultSessionJson = await fsPromises.readFile(defaultSessionFile, {
79
- encoding: 'utf8',
80
- });
81
- }
82
- catch (error) {
83
- throw new Error('Could not read the provided file');
84
- }
85
- try {
86
- const session = parseJSON(defaultSessionJson);
87
- // return top-level "session" if it exists, such as in files created by
88
- // "File -> Export session"
89
- return session.session || session;
90
- }
91
- catch (error) {
92
- throw new Error('Could not parse the given default session file');
88
+ const contents = await readJsonFile(defaultSessionFile);
89
+ // unwrap the top-level "session" if it exists, such as in files created by
90
+ // "File -> Export session"
91
+ const session = contents.session ?? contents;
92
+ // spreading a string or an array here silently wrote a defaultSession of
93
+ // numeric keys, so say what is wrong with the file instead
94
+ if (!isRecord(session)) {
95
+ throw new Error(`${defaultSessionFile} does not contain a session object${contents.session === undefined ? '' : ' under its "session" key'}`);
93
96
  }
97
+ return session;
94
98
  }
@@ -1,14 +1,10 @@
1
- import { debug, writeJsonFile } from "../../utils.js";
2
- /**
3
- * Generic function to find and update or add an item to a config array
4
- * Returns the updated config and whether an item was overwritten
5
- */
6
- export function findAndUpdateOrAdd({ items, newItem, idField, getId, allowOverwrite, itemType, }) {
1
+ import { debug, writeJsonFile } from '../../utils.js';
2
+ export function findAndUpdateOrAdd({ items, newItem, idField, getId, force, itemType, }) {
7
3
  const newId = getId(newItem);
8
4
  const idx = items.findIndex(item => getId(item) === newId);
9
5
  if (idx !== -1) {
10
6
  debug(`Found existing ${itemType} ${newId} in configuration`);
11
- if (allowOverwrite) {
7
+ if (force) {
12
8
  debug(`Overwriting ${itemType} ${newId} in configuration`);
13
9
  const updatedItems = [...items];
14
10
  updatedItems[idx] = newItem;
@@ -22,9 +18,6 @@ export function findAndUpdateOrAdd({ items, newItem, idField, getId, allowOverwr
22
18
  return { updatedItems: [...items, newItem], wasOverwritten: false };
23
19
  }
24
20
  }
25
- /**
26
- * Saves a config file and reports the result to the user
27
- */
28
21
  export async function saveConfigAndReport({ config, target, itemType, itemName, itemId, wasOverwritten, }) {
29
22
  debug(`Writing configuration to file ${target}`);
30
23
  await writeJsonFile(target, config);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Where the density sidecar for a data file lives: `<file>.density.bw`, with a
3
+ * `.gz`/`.bgz` stripped first so `genes.gff3.gz` yields `genes.gff3.density.bw`
4
+ * rather than a name carrying the compression of a file it is not compressed
5
+ * like.
6
+ *
7
+ * One rule, two commands: `make-density` writes here by default and `add-track`
8
+ * probes here, so a sidecar built with no `--out` is the one the track picks up.
9
+ */
10
+ export function densitySidecarPath(file) {
11
+ return `${file.replace(/\.b?gz$/i, '')}.density.bw`;
12
+ }
@@ -1,7 +1,11 @@
1
- import { spawn } from 'child_process';
2
- import tmp from 'tmp';
1
+ import { spawn } from 'node:child_process';
2
+ import { parseArgs } from 'node:util';
3
+ import { fileSync } from 'tmp';
4
+ import { printHelp } from '../../utils.js';
5
+ import { describeExit, diedOfSigpipe, waitForProcessClose, } from '../process-utils.js';
6
+ import { validateFileArgument, validateRequiredCommands } from './validators.js';
3
7
  export const BED_CONFIG = {
4
- description: 'Helper utility to sort BED files for tabix. Moves all lines starting with # to the top of the file, and sort by refname and start position using unix utilities sort and grep',
8
+ description: 'Sort a BED file for tabix. It is `sort -k1,1 -k2,2n` with LC_ALL=C and a tab separator, plus every line starting with # kept at the top rather than sorted into the data. Takes a file, or the same data on stdin',
5
9
  examples: [
6
10
  '# sort bed and pipe to bgzip',
7
11
  '$ jbrowse sort-bed input.bed | bgzip > sorted.bed.gz',
@@ -13,7 +17,7 @@ export const BED_CONFIG = {
13
17
  fileType: 'bed',
14
18
  };
15
19
  export const GFF_CONFIG = {
16
- description: 'Helper utility to sort GFF files for tabix. Moves all lines starting with # to the top of the file, and sort by refname and start position using unix utilities sort and grep',
20
+ description: 'Sort a GFF or GTF for tabix — the two share a refname/start column layout. It is `sort -k1,1 -k4,4n` with LC_ALL=C and a tab separator, plus every line starting with # kept at the top rather than sorted into the data. Takes a file, or the same data on stdin',
17
21
  examples: [
18
22
  '# sort gff and pipe to bgzip',
19
23
  '$ jbrowse sort-gff input.gff | bgzip > sorted.gff.gz',
@@ -21,6 +25,10 @@ export const GFF_CONFIG = {
21
25
  '',
22
26
  '# sort gff from stdin',
23
27
  '$ cat input.gff | jbrowse sort-gff | bgzip > sorted.gff.gz',
28
+ '',
29
+ '# also works on GTF',
30
+ '$ jbrowse sort-gff input.gtf | bgzip > sorted.gtf.gz',
31
+ '$ tabix -p gff sorted.gtf.gz',
24
32
  ],
25
33
  sortColumn: 4,
26
34
  fileType: 'gff',
@@ -31,20 +39,69 @@ function getMinimalEnvironment() {
31
39
  LC_ALL: 'C',
32
40
  };
33
41
  }
42
+ // The file path is passed to the shell as the positional argument "$1" rather
43
+ // than interpolated into the command string, so a path containing shell
44
+ // metacharacters (`"`, `$(...)`, backticks) cannot break out and execute. Only
45
+ // sortColumn, an integer from the BED/GFF configs, is interpolated.
46
+ function sortPipeline(sortColumn) {
47
+ return `grep "^#" "$1"; grep -v "^#" "$1" | sort -t"\`printf '\\t'\`" -k1,1 -k${sortColumn},${sortColumn}n`;
48
+ }
34
49
  function createSortCommandForStdin(sortColumn) {
35
- const tmpFile = tmp.fileSync({ prefix: 'jbrowse-sort' }).name;
36
- const sortCmd = `sort -t"\`printf '\\t'\`" -k1,1 -k${sortColumn},${sortColumn}n`;
37
- return `cat > ${tmpFile} && (grep "^#" ${tmpFile}; grep -v "^#" ${tmpFile} | ${sortCmd}) && rm -f ${tmpFile}`;
50
+ // discardDescriptor: we only want the path — the shell below opens the file
51
+ // itself, so tmp's own handle would just be a descriptor left open for the
52
+ // life of the process
53
+ const tmpFile = fileSync({
54
+ prefix: 'jbrowse-sort',
55
+ discardDescriptor: true,
56
+ }).name;
57
+ // trap on EXIT removes the temp file whether or not the sort pipeline
58
+ // succeeds (a bare `&& rm` leaks the file when the pipeline fails)
59
+ return {
60
+ command: `trap 'rm -f "$1"' EXIT; cat > "$1" && (${sortPipeline(sortColumn)})`,
61
+ pathArg: tmpFile,
62
+ };
38
63
  }
39
64
  function createSortCommandForFile(file, sortColumn) {
40
- return `(grep "^#" "${file}"; grep -v "^#" "${file}" | sort -t"\`printf '\\t'\`" -k1,1 -k${sortColumn},${sortColumn}n)`;
65
+ return { command: `(${sortPipeline(sortColumn)})`, pathArg: file };
41
66
  }
42
67
  export function spawnSortProcess(file, sortColumn) {
43
- const command = file
68
+ const { command, pathArg } = file
44
69
  ? createSortCommandForFile(file, sortColumn)
45
70
  : createSortCommandForStdin(sortColumn);
46
- return spawn('sh', ['-c', command], {
71
+ // 'sh' becomes $0, pathArg becomes $1 inside the command
72
+ return spawn('sh', ['-c', command, 'sh', pathArg], {
47
73
  env: getMinimalEnvironment(),
48
74
  stdio: 'inherit',
49
75
  });
50
76
  }
77
+ export async function runSort(config, commandName, args) {
78
+ const options = { help: { type: 'boolean', short: 'h' } };
79
+ const { values: flags, positionals } = parseArgs({
80
+ args,
81
+ options,
82
+ allowPositionals: true,
83
+ });
84
+ if (flags.help) {
85
+ printHelp({
86
+ description: config.description,
87
+ examples: config.examples,
88
+ usage: `jbrowse ${commandName} [file] [options]`,
89
+ options,
90
+ });
91
+ return;
92
+ }
93
+ const file = positionals[0];
94
+ validateFileArgument(file, commandName, config.fileType);
95
+ validateRequiredCommands(['sh', 'sort', 'grep']);
96
+ const child = spawnSortProcess(file, config.sortColumn);
97
+ const exit = await waitForProcessClose(child);
98
+ // These commands exist to be piped (`jbrowse sort-bed in.bed | bgzip > …`), so
99
+ // a consumer that stops reading early — `| head`, `| grep -q` — must not be
100
+ // reported as a sort failure. It used to exit 1 with "exited with code 141".
101
+ if (diedOfSigpipe(exit)) {
102
+ return;
103
+ }
104
+ if (exit.code !== 0) {
105
+ throw new Error(`Sort process exited with ${describeExit(exit)}`);
106
+ }
107
+ }