@jbrowse/cli 4.3.0 → 5.0.0-beta.2

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 (57) hide show
  1. package/README.md +413 -57
  2. package/bin/run +1 -1
  3. package/bundle/index.js +550 -2659
  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 +222 -288
  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 +201 -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-density/density-generator.js +180 -0
  21. package/dist/commands/make-density/index.js +139 -0
  22. package/dist/commands/make-pif/index.js +128 -11
  23. package/dist/commands/make-pif/pif-generator.js +249 -43
  24. package/dist/commands/process-utils.js +13 -2
  25. package/dist/commands/remove-track.js +20 -13
  26. package/dist/commands/set-default-session.js +35 -31
  27. package/dist/commands/shared/config-operations.js +3 -10
  28. package/dist/commands/shared/density.js +12 -0
  29. package/dist/commands/shared/sort-utils.js +67 -10
  30. package/dist/commands/shared/validators.js +38 -3
  31. package/dist/commands/sort-bed.js +2 -33
  32. package/dist/commands/sort-gff.js +2 -33
  33. package/dist/commands/text-index/adapter-utils.js +6 -23
  34. package/dist/commands/text-index/aggregate.js +40 -34
  35. package/dist/commands/text-index/command.js +35 -7
  36. package/dist/commands/text-index/config-utils.js +60 -83
  37. package/dist/commands/text-index/file-list.js +32 -18
  38. package/dist/commands/text-index/index.js +4 -4
  39. package/dist/commands/text-index/indexing-utils.js +49 -73
  40. package/dist/commands/text-index/per-track.js +44 -31
  41. package/dist/commands/text-index/validators.js +1 -1
  42. package/dist/commands/upgrade.js +20 -32
  43. package/dist/commands/validate/configManifest.generated.js +5765 -0
  44. package/dist/commands/validate/displayDefaultKeys.js +28 -0
  45. package/dist/commands/validate/index.js +97 -0
  46. package/dist/commands/validate/suggest.js +35 -0
  47. package/dist/commands/validate/types.js +6 -0
  48. package/dist/commands/validate/validateConfig.js +522 -0
  49. package/dist/index.js +104 -50
  50. package/dist/types/common.js +3 -27
  51. package/dist/utils.js +309 -128
  52. package/dist/version.js +1 -1
  53. package/package.json +8 -10
  54. package/dist/commands/make-pif/cigar-utils.js +0 -24
  55. package/dist/commands/make-pif/file-utils.js +0 -30
  56. package/dist/commands/track-utils.js +0 -74
  57. package/dist/util.js +0 -35
@@ -1,55 +1,261 @@
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
+ // Default gap (bp) of the coarse tier's coarse CIGAR: how far a straight line
9
+ // across one of its runs may be from the alignment's real path. Indels longer
10
+ // than half of it keep their letter; shorter ones fold into the runs. 10kb
11
+ // matches the adapter's default coarseBpPerPxThreshold, so at the zoom where the
12
+ // tier is served the bound is ~1px and a folded indel was sub-pixel anyway.
13
+ export const DEFAULT_COARSE_GAP = 10_000;
14
+ /**
15
+ * The one meta line a PIF carries, sorted first by the C-locale sort and kept
16
+ * by tabix as a header. It states the format generation, the tiers written,
17
+ * the coarse tier's accuracy bound (`--coarse`), and whether every input row
18
+ * had a CIGAR — the facts a reader cannot recover from the rows.
19
+ */
20
+ export function pifHeader(coarseGap, stats) {
21
+ const cigars = stats.cigarRows === 0
22
+ ? 'none'
23
+ : stats.cigarRows === stats.rows && stats.unboundedRows === 0
24
+ ? 'all'
25
+ : 'some';
26
+ const tiers = coarseGap === undefined ? 'fine' : 'fine,coarse';
27
+ const bound = coarseGap === undefined ? '' : `\tcoarse:i:${coarseGap}`;
28
+ return `#pif\tversion:i:1\ttiers:Z:${tiers}${bound}\tcigars:Z:${cigars}\n`;
29
+ }
30
+ function panSNSample(refName) {
31
+ const i = refName.indexOf('#');
32
+ return i === -1 ? undefined : refName.slice(0, i);
33
+ }
34
+ function addPanSNPair(stats, c1, c2) {
35
+ const a = panSNSample(c1);
36
+ const b = panSNSample(c2);
37
+ if (a !== undefined) {
38
+ stats.samples.add(a);
39
+ }
40
+ if (b !== undefined) {
41
+ stats.samples.add(b);
42
+ }
43
+ if (a !== undefined && b !== undefined && a !== b) {
44
+ stats.pairs.add(a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
45
+ }
46
+ }
47
+ /**
48
+ * The sample pairs a complete all-vs-all would state but this file does not.
49
+ * Empty for a pairwise PAF (no PanSN names) and for a genuinely complete file.
50
+ */
51
+ export function missingPairs({ samples, pairs }) {
52
+ const sorted = [...samples].sort();
53
+ const out = [];
54
+ for (let i = 0; i < sorted.length; i++) {
55
+ for (let j = i + 1; j < sorted.length; j++) {
56
+ const a = sorted[i];
57
+ const b = sorted[j];
58
+ if (!pairs.has(`${a}\u0000${b}`)) {
59
+ out.push([a, b]);
20
60
  }
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
61
  }
24
62
  }
25
- catch (error) {
26
- console.error('Error processing PAF file:', error);
27
- throw error;
63
+ return out;
64
+ }
65
+ // The coarse row's `cr:Z:` value, or nothing, and whether withholding it left
66
+ // the row making a claim it cannot keep.
67
+ //
68
+ // `ops` is nothing when the row has no CIGAR, when the fold is a single run
69
+ // (the coordinate columns already say it all), and when the walk does not close
70
+ // on the row's own far corner — clipping ops, a hand-made cg, a cs whose spans
71
+ // don't add up — since the columns are what the fine tier draws and the coarse
72
+ // row must not disagree with them. A fold of several runs with no kept indel is
73
+ // still written: the runs are where a lopsided cluster of small indels bends
74
+ // the path, which a straight ribbon across the row would miss by up to the
75
+ // whole cluster.
76
+ //
77
+ // `unbounded` separates the last of those from the rest. The first two leave a
78
+ // tagless row a reader may take as one run within `--coarse`, and it is one; a
79
+ // walk that misses its own corner leaves a row that reads the same way and is
80
+ // not, so the header has to stop saying `cigars:Z:all` on its account.
81
+ function coarseFold({ cigar, coarseGap, ownLen, mateLen, }) {
82
+ const coarse = cigar === undefined ? undefined : coarsenCigar(cigar, coarseGap);
83
+ const closed = coarse !== undefined &&
84
+ coarse.ownLen === ownLen &&
85
+ coarse.mateLen === mateLen;
86
+ return {
87
+ ops: closed && (coarse.gapCount > 0 || coarse.opCount > 1)
88
+ ? coarse.ops
89
+ : undefined,
90
+ unbounded: cigar !== undefined && !closed,
91
+ };
92
+ }
93
+ // A PIF row carries exactly ONE alignment string — `cg:Z:`, in the orientation
94
+ // of the perspective it is indexed under. A minimap2 `cs:Z:` is folded into that
95
+ // CIGAR and never emitted, for two reasons:
96
+ //
97
+ // - `cs` has its own orientation, and reorienting it means reversing op order
98
+ // AND reverse-complementing the spelled-out bases. Keeping an unflipped `cs`
99
+ // beside a flipped `cg` is silently wrong rather than merely lossy:
100
+ // `SyntenyFeature.forEachMismatch` prefers `cs`, so a row from
101
+ // `minimap2 -c --cs` (which emits both tags) drew every indel with reversed
102
+ // sense from the query perspective.
103
+ // - `csToCigar` yields `=`/`X`, so folding a `cs` in is strictly MORE
104
+ // informative than minimap2's own M-style `cg` — which is why `cs` wins when
105
+ // a row carries both. The substituted base letters are the only thing lost;
106
+ // mismatch positions survive as `X`.
107
+ //
108
+ // Returns the rewritten optional-tag list and where the surviving alignment
109
+ // string sits in it (-1 when the row carries none).
110
+ function foldCsIntoCg(tags) {
111
+ const csIdx = tags.findIndex(f => f.startsWith('cs:Z'));
112
+ if (csIdx === -1) {
113
+ return { tags, cigarIdx: tags.findIndex(f => f.startsWith('cg:Z')) };
114
+ }
115
+ const folded = `cg:Z:${csToCigar(tags[csIdx].slice(5))}`;
116
+ const rewritten = tags.flatMap((f, i) => i === csIdx ? [folded] : f.startsWith('cg:Z') ? [] : [f]);
117
+ return { tags: rewritten, cigarIdx: rewritten.indexOf(folded) };
118
+ }
119
+ function pifRow(fields) {
120
+ return `${fields.join('\t')}\n`;
121
+ }
122
+ function processLine(rawLine, coarseGap, stats) {
123
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
124
+ if (line.startsWith('#')) {
125
+ return '';
126
+ }
127
+ const parts = line.split('\t');
128
+ // A valid PAF row has 12 mandatory columns; anything shorter (blank, or
129
+ // truncated) would produce NaN coords, so skip it rather than emit garbage.
130
+ if (parts.length < 12) {
131
+ stats.skipped++;
132
+ return '';
133
+ }
134
+ stats.rows++;
135
+ // rest[0]=num_matches, rest[1]=block_len, rest[2]=mapq, rest[3+]=optional tags
136
+ const [c1, l1, s1, e1, strand, c2, l2, s2, e2, ...rest] = parts;
137
+ addPanSNPair(stats, c1, c2);
138
+ // an incoming cr:Z: (a PIF turned back into PAF, or a tool that adopted the
139
+ // tag) is dropped from both tiers: the fine tier never carries one, and the
140
+ // coarse tier writes its own below
141
+ const { tags, cigarIdx } = foldCsIntoCg(rest.filter(f => !f.startsWith('cr:Z:')));
142
+ const cigar = cigarIdx === -1 ? undefined : tags[cigarIdx].slice(5);
143
+ if (cigar !== undefined) {
144
+ stats.cigarRows++;
145
+ }
146
+ // the t-row keeps the CIGAR as PAF spelled it (target perspective); the q-row
147
+ // re-orients it for the query perspective it is indexed under
148
+ const queryTags = cigar === undefined
149
+ ? tags
150
+ : tags.map((f, i) => i === cigarIdx
151
+ ? `cg:Z:${strand === '-' ? flipCigar(cigar) : swapIndelCigar(cigar)}`
152
+ : f);
153
+ const fineRows = pifRow([`t${c2}`, l2, s2, e2, strand, c1, l1, s1, e1, ...tags]) +
154
+ pifRow([`q${c1}`, l1, s1, e1, strand, c2, l2, s2, e2, ...queryTags]);
155
+ if (coarseGap === undefined) {
156
+ return fineRows;
28
157
  }
29
- finally {
30
- rl1.close();
158
+ // the coarse row is the fine row with its CIGAR replaced by the fold and
159
+ // every other column and tag verbatim, so a click and the identity coloring
160
+ // read the same on both tiers
161
+ const coarse = cigarIdx === -1 ? tags : tags.filter((_, i) => i !== cigarIdx);
162
+ // the T row's own axis is the target, as the PAF CIGAR is written; the Q row
163
+ // re-orients it for the query, the way the fine tier's cg is
164
+ const { ops: cr, unbounded } = coarseFold({
165
+ cigar,
166
+ coarseGap,
167
+ ownLen: +e2 - +s2,
168
+ mateLen: +e1 - +s1,
169
+ });
170
+ if (unbounded) {
171
+ stats.unboundedRows++;
31
172
  }
173
+ const tCr = cr === undefined ? [] : [`cr:Z:${cr}`];
174
+ const qCr = cr === undefined
175
+ ? []
176
+ : [`cr:Z:${strand === '-' ? flipCoarseCigar(cr) : swapCoarseCigar(cr)}`];
177
+ return (fineRows +
178
+ pifRow([`T${c2}`, l2, s2, e2, strand, c1, l1, s1, e1, ...coarse, ...tCr]) +
179
+ pifRow([`Q${c1}`, l1, s1, e1, strand, c2, l2, s2, e2, ...coarse, ...qCr]));
180
+ }
181
+ function makePifTransform(coarseGap, stats) {
182
+ let tail = '';
183
+ return new Transform({
184
+ transform(chunk, _enc, callback) {
185
+ const data = tail + chunk.toString('utf8');
186
+ const lastNl = data.lastIndexOf('\n');
187
+ if (lastNl === -1) {
188
+ tail = data;
189
+ callback();
190
+ return;
191
+ }
192
+ tail = data.slice(lastNl + 1);
193
+ callback(null, data
194
+ .slice(0, lastNl)
195
+ .split('\n')
196
+ .filter(Boolean)
197
+ .map(l => processLine(l, coarseGap, stats))
198
+ .join(''));
199
+ },
200
+ flush(callback) {
201
+ // the header goes out last and sorts first: the counts it states are
202
+ // only known once every row has been seen, and `#` precedes every tier
203
+ // letter in the C locale
204
+ callback(null, (tail ? processLine(tail, coarseGap, stats) : '') +
205
+ pifHeader(coarseGap, stats));
206
+ },
207
+ });
32
208
  }
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',
209
+ // resolves to what the pass observed: the PanSN samples (empty for a pairwise
210
+ // PAF), so the caller can suggest the right adapter and assembly names, and the
211
+ // row/skip counts so it can warn on a file that was not PAF at all
212
+ export async function createPIF(filename, stream, coarseGap) {
213
+ const stats = {
214
+ samples: new Set(),
215
+ pairs: new Set(),
216
+ rows: 0,
217
+ cigarRows: 0,
218
+ unboundedRows: 0,
219
+ skipped: 0,
42
220
  };
43
- return spawn('sh', ['-c', fullCommand], {
44
- env: minimalEnv,
221
+ const transform = makePifTransform(coarseGap, stats);
222
+ if (filename) {
223
+ const source = createReadStream(filename);
224
+ await (/\.b?gz$/i.test(filename)
225
+ ? pipeline(source, createGunzip(), transform, stream)
226
+ : pipeline(source, transform, stream));
227
+ }
228
+ else {
229
+ await pipeline(process.stdin, transform, stream);
230
+ }
231
+ return stats;
232
+ }
233
+ // bgzip is the only stage of this pipeline that cannot use more than one core on
234
+ // its own: sort parallelizes itself and tabix is I/O bound. 4 is enough to take
235
+ // compression off the critical path on the files this command is pointed at
236
+ // without claiming a whole machine.
237
+ export const DEFAULT_BGZIP_THREADS = 4;
238
+ export function spawnSortProcess(outputFile, useCsi, threads = DEFAULT_BGZIP_THREADS) {
239
+ // -s: tabix wants the rows ordered by (refName, start) and by nothing else,
240
+ // so the whole-line comparison a non-stable sort falls back to on a tie is
241
+ // comparing multi-kb CIGARs to pick an order no reader can observe
242
+ const sortCmd = `sort -s -t"\`printf '\\t'\`" -k1,1 -k3,3n`;
243
+ const bgzipCommand = `bgzip -@ ${threads} > "$1"`;
244
+ const tabixCommand = `tabix ${useCsi ? '-C ' : ''}-s1 -b3 -e4 -0 "$1"`;
245
+ // `&&` (not `;`) so a bgzip failure aborts before tabix runs and propagates
246
+ // as the pipeline's exit code. The output path is passed as the shell's "$1"
247
+ // positional rather than interpolated into the command string, so a path
248
+ // with shell metacharacters (`"`, `$(...)`, backticks) can't break out and
249
+ // execute — same technique as sort-utils.ts. useCsi is a fixed literal, and
250
+ // the caller has already checked threads is a positive integer.
251
+ const fullCommand = `${sortCmd} | ${bgzipCommand} && ${tabixCommand}`;
252
+ return spawn('sh', ['-c', fullCommand, 'sh', outputFile], {
253
+ env: { ...process.env, LC_ALL: 'C' },
45
254
  stdio: ['pipe', process.stdout, process.stderr],
46
255
  });
47
256
  }
48
257
  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
- });
258
+ // strip .paf or .paf.gz so a gzipped input doesn't yield input.paf.gz.pif.gz
259
+ const base = path.basename(file || 'output').replace(/\.paf(\.gz)?$/i, '');
260
+ return out || `${base}.pif.gz`;
55
261
  }
@@ -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
+ }