@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,36 +1,22 @@
1
- import crypto from 'crypto';
2
- import fs from 'fs';
3
- import path from 'path';
4
- import { debug, resolveConfigPath, writeJsonFile } from "../../utils.js";
5
- import { createDefaultConfig } from "../add-assembly/utils.js";
6
- /**
7
- * Validates if a port number is in the valid range
8
- */
9
- export function isValidPort(port) {
10
- return port > 0 && port < 65535;
11
- }
12
- /**
13
- * Parses and validates a port string
14
- */
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import handler from 'serve-handler';
5
+ import { debug, isRecord, resolveConfigPath, writeJsonFile, } from '../../utils.js';
6
+ import { createDefaultConfig } from '../add-assembly/utils.js';
15
7
  export function parsePort({ portStr, defaultPort = 9090, }) {
16
8
  if (!portStr) {
17
9
  return defaultPort;
18
10
  }
19
11
  const parsedPort = Number.parseInt(portStr, 10);
20
- if (!isValidPort(parsedPort)) {
12
+ if (!(parsedPort > 0 && parsedPort <= 65535)) {
21
13
  throw new Error(`${portStr} is not a valid port`);
22
14
  }
23
15
  return parsedPort;
24
16
  }
25
- /**
26
- * Generates a random alphanumeric string to serve as admin key
27
- */
28
17
  export function generateKey() {
29
- return crypto.randomBytes(5).toString('hex');
18
+ return crypto.randomBytes(16).toString('hex');
30
19
  }
31
- /**
32
- * Sets up the configuration file
33
- */
34
20
  export async function setupConfigFile({ root = '.', } = {}) {
35
21
  const outFile = await resolveConfigPath(root);
36
22
  const baseDir = path.dirname(outFile);
@@ -43,121 +29,257 @@ export async function setupConfigFile({ root = '.', } = {}) {
43
29
  }
44
30
  return { outFile, baseDir };
45
31
  }
32
+ const BYTE_UNITS = {
33
+ b: 1,
34
+ kb: 1024,
35
+ mb: 1024 ** 2,
36
+ gb: 1024 ** 3,
37
+ };
46
38
  /**
47
- * Validates admin key and extracts config path from request
39
+ * `--bodySizeLimit` as a byte count. Replaces the `bytes` package that
40
+ * `express.json` used, and covers the suffixes a config update could plausibly
41
+ * be given; anything else is a mistake at the command line and says so, rather
42
+ * than silently becoming a default the user did not ask for.
48
43
  */
49
- function validateAndExtractParams({ req, key, baseDir, outFile, }) {
50
- const { body } = req;
51
- // Check for adminKey in both query parameters and request body for backward compatibility
52
- const adminKeyFromQuery = req.query.adminKey;
53
- const adminKeyFromBody = body?.adminKey;
54
- const adminKey = adminKeyFromBody || adminKeyFromQuery;
55
- if (adminKey !== key) {
56
- return { isValid: false, error: 'Invalid admin key' };
44
+ export function parseByteLimit(limit) {
45
+ const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i.exec(limit.trim());
46
+ const amount = match?.[1];
47
+ const scale = BYTE_UNITS[(match?.[2] ?? 'b').toLowerCase()];
48
+ if (amount === undefined || scale === undefined) {
49
+ throw new Error(`${limit} is not a valid body size limit; use a number optionally followed by b, kb, mb or gb`);
57
50
  }
58
- // Get configPath from either body or query parameters
59
- const configPathParam = body?.configPath || req.query.config;
60
- try {
61
- // Normalize the config path
62
- const configPath = configPathParam
63
- ? path.normalize(path.join(baseDir, configPathParam))
64
- : outFile;
65
- // Check for directory traversal attempts
66
- const normalizedBaseDir = path.normalize(baseDir);
67
- const relPath = path.relative(normalizedBaseDir, configPath);
68
- // Ensure the config path is within the base directory and doesn't contain path traversal
69
- if (relPath.startsWith('..') || path.isAbsolute(relPath)) {
70
- return { isValid: false, error: 'Cannot perform directory traversal' };
51
+ return Math.floor(Number(amount) * scale);
52
+ }
53
+ /** A request body that was over the limit, so the handler can answer 413. */
54
+ class PayloadTooLargeError extends Error {
55
+ }
56
+ /**
57
+ * The JSON body, or `undefined` when the request carried none. The size is
58
+ * counted as it streams rather than after, so an oversized upload is refused
59
+ * without being buffered first — which is the only reason the limit exists.
60
+ */
61
+ async function readJsonBody(req, limitBytes) {
62
+ const chunks = [];
63
+ let size = 0;
64
+ for await (const chunk of req) {
65
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
66
+ size += buffer.length;
67
+ if (size > limitBytes) {
68
+ throw new PayloadTooLargeError(`request body exceeds ${limitBytes} bytes`);
71
69
  }
72
- return { isValid: true, configPath };
70
+ chunks.push(buffer);
73
71
  }
74
- catch (error) {
75
- return { isValid: false, error: 'Failed to validate config path' };
72
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
73
+ const parsed = raw === '' ? undefined : JSON.parse(raw);
74
+ return parsed;
75
+ }
76
+ // A missing or non-object body yields no properties instead of throwing, so a
77
+ // route reading it before checking the key cannot turn an unauthorized request
78
+ // into a 500.
79
+ function requestBody(body) {
80
+ return isRecord(body) ? body : {};
81
+ }
82
+ // body and query values are untrusted input: a repeated query param arrives as
83
+ // an array, a nested one as an object, and neither is a key or a path
84
+ function asString(value) {
85
+ return typeof value === 'string' ? value : undefined;
86
+ }
87
+ // The shape `express` gave `req.query`, which the checks below are written
88
+ // against: absent, one value, or an array when the param was repeated. An array
89
+ // is not a string, so `asString` rejects it and a repeated `adminKey` or
90
+ // `config` fails rather than quietly using whichever copy came first.
91
+ function queryValue(params, name) {
92
+ const values = params.getAll(name);
93
+ return values.length === 0
94
+ ? undefined
95
+ : values.length === 1
96
+ ? values[0]
97
+ : values;
98
+ }
99
+ function validateAndExtractParams({ body, query, key, baseDir, outFile, }) {
100
+ const adminKey = asString(body.adminKey) || asString(queryValue(query, 'adminKey'));
101
+ if (adminKey !== key) {
102
+ return { ok: false, error: 'Invalid admin key' };
103
+ }
104
+ // a configPath that is there but is not a string (a JSON number, an object, a
105
+ // repeated query param) is a malformed request, not an absent one — it must not
106
+ // silently fall through to writing the default config
107
+ const rawConfigPath = body.configPath || queryValue(query, 'config');
108
+ const configPathParam = asString(rawConfigPath);
109
+ if (rawConfigPath !== undefined && configPathParam === undefined) {
110
+ return { ok: false, error: 'Failed to validate config path' };
111
+ }
112
+ const configPath = configPathParam
113
+ ? path.normalize(path.join(baseDir, configPathParam))
114
+ : outFile;
115
+ const relPath = path.relative(path.normalize(baseDir), configPath);
116
+ if (relPath.startsWith('..') || path.isAbsolute(relPath)) {
117
+ return { ok: false, error: 'Cannot perform directory traversal' };
76
118
  }
119
+ return { ok: true, configPath };
77
120
  }
78
- /**
79
- * Sets up the API routes for the server
80
- */
81
- export function setupRoutes({ app, baseDir, outFile, key, serverRef, }) {
82
- // Root route
83
- app.get('/', (_req, res) => {
84
- res.setHeader('Content-Type', 'text/plain');
85
- res.send('JBrowse Admin Server');
121
+ function sendText(res, status, body) {
122
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
123
+ res.end(body);
124
+ }
125
+ // What `cors()` sent. The server binds to localhost for a browser tab on the
126
+ // same machine to talk to, and the admin key is what authorizes a write — the
127
+ // origin never was.
128
+ function applyCorsHeaders(res) {
129
+ res.setHeader('Access-Control-Allow-Origin', '*');
130
+ res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, POST, OPTIONS');
131
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
132
+ }
133
+ function updateConfig({ res, body, query, key, baseDir, outFile, }) {
134
+ // the key is checked before the payload is touched, so a request with no JSON
135
+ // body gets the 401 it earned rather than a 500 from reading it
136
+ const validation = validateAndExtractParams({
137
+ body,
138
+ query,
139
+ key,
140
+ baseDir,
141
+ outFile,
86
142
  });
87
- // Update config route
88
- app.post('/updateConfig', (req, res) => {
89
- const { body } = req;
143
+ if (validation.ok) {
90
144
  const config = body.config;
91
- const validation = validateAndExtractParams({ req, key, baseDir, outFile });
92
- if (!validation.isValid) {
93
- res.status(401).setHeader('Content-Type', 'text/plain');
94
- res.send(`Error: ${validation.error}`);
95
- return;
145
+ if (config === undefined) {
146
+ sendText(res, 400, 'Error: Missing config in request body');
96
147
  }
97
- // Remove adminKey from body before saving to config
98
- if (body.adminKey) {
99
- delete body.adminKey;
100
- }
101
- try {
102
- fs.writeFileSync(validation.configPath, JSON.stringify(config, null, 2));
103
- res.setHeader('Content-Type', 'text/plain');
104
- res.send('Config updated successfully');
105
- }
106
- catch (error) {
107
- res.status(500).setHeader('Content-Type', 'text/plain');
108
- res.send('Error: Failed to update config');
148
+ else {
149
+ try {
150
+ fs.writeFileSync(validation.configPath, JSON.stringify(config, null, 2));
151
+ sendText(res, 200, 'Config updated successfully');
152
+ }
153
+ catch {
154
+ sendText(res, 500, 'Error: Failed to update config');
155
+ }
109
156
  }
157
+ }
158
+ else {
159
+ sendText(res, 401, `Error: ${validation.error}`);
160
+ }
161
+ }
162
+ function readConfig({ res, body, query, key, baseDir, outFile, }) {
163
+ const validation = validateAndExtractParams({
164
+ body,
165
+ query,
166
+ key,
167
+ baseDir,
168
+ outFile,
110
169
  });
111
- // Get config route
112
- app.get('/config', (req, res) => {
113
- const validation = validateAndExtractParams({ req, key, baseDir, outFile });
114
- if (!validation.isValid) {
115
- res.status(401).setHeader('Content-Type', 'text/plain');
116
- res.send(`Error: ${validation.error}`);
117
- return;
118
- }
170
+ if (validation.ok) {
119
171
  try {
120
172
  if (fs.existsSync(validation.configPath)) {
121
- const config = fs.readFileSync(validation.configPath, 'utf8');
122
- res.setHeader('Content-Type', 'text/plain');
123
- res.send(config);
173
+ sendText(res, 200, fs.readFileSync(validation.configPath, 'utf8'));
124
174
  }
125
175
  else {
126
- res.status(404).setHeader('Content-Type', 'text/plain');
127
- res.send('Error: Config file not found');
176
+ sendText(res, 404, 'Error: Config file not found');
128
177
  }
129
178
  }
130
179
  catch (error) {
131
180
  console.error('Error reading config:', error);
132
- res.status(500).setHeader('Content-Type', 'text/plain');
133
- res.send('Error: Failed to read config');
181
+ sendText(res, 500, 'Error: Failed to read config');
134
182
  }
135
- });
136
- // Shutdown route for testing
137
- app.post('/shutdown', (req, res) => {
138
- const { body } = req;
139
- const adminKey = body?.adminKey;
140
- if (adminKey !== key) {
141
- res.status(401).setHeader('Content-Type', 'text/plain');
142
- res.send('Error: Invalid admin key');
143
- return;
144
- }
145
- res.setHeader('Content-Type', 'text/plain');
146
- res.send('Server shutting down');
147
- // Shutdown the server after sending response
148
- setImmediate(() => {
149
- if (serverRef.current) {
150
- serverRef.current.close();
151
- }
152
- });
153
- });
183
+ }
184
+ else {
185
+ sendText(res, 401, `Error: ${validation.error}`);
186
+ }
187
+ }
188
+ /**
189
+ * The static half, and why it runs for `/` before the greeting below does: an
190
+ * admin server is pointed at a JBrowse install, so `/` has to load the app the
191
+ * user came for. `serve-handler` owns the parts worth not writing — content
192
+ * types, byte ranges (the same directory holds the BAM and CRAM files the app
193
+ * fetches by range), directory handling and the traversal guard.
194
+ */
195
+ async function serveStatic({ req, res, baseDir, }) {
196
+ await handler(req, res, { public: baseDir });
197
+ }
198
+ async function hasIndexHtml(baseDir) {
199
+ return fs.promises
200
+ .stat(path.join(baseDir, 'index.html'))
201
+ .then(stats => stats.isFile())
202
+ .catch(() => false);
154
203
  }
155
204
  /**
156
- * Starts the server and sets up shutdown handlers
205
+ * The whole server, as one request handler.
206
+ *
207
+ * Replaces express + cors + express.json. The four routes are exact paths with
208
+ * no parameters, so the dispatch is a switch; the rest of what express supplied
209
+ * is the body reader, the CORS headers and the static handler above.
210
+ *
211
+ * One deliberate difference: `GET /config` is always the admin route. Under
212
+ * `express.static` a file literally named `config` sitting in the JBrowse
213
+ * directory would have shadowed it, which is not a thing anyone wants and was
214
+ * never intended.
157
215
  */
158
- export function startServer({ app, port, key, outFile, keyPath, serverRef, }) {
159
- // Start the server
160
- const server = app.listen(port, () => {
216
+ export function createRequestHandler({ baseDir, outFile, key, serverRef, bodySizeLimit, }) {
217
+ return async (req, res) => {
218
+ try {
219
+ applyCorsHeaders(res);
220
+ const url = new URL(req.url ?? '/', 'http://localhost');
221
+ const route = `${req.method} ${url.pathname}`;
222
+ if (req.method === 'OPTIONS') {
223
+ res.writeHead(204);
224
+ res.end();
225
+ }
226
+ else if (route === 'POST /updateConfig' || route === 'GET /config') {
227
+ const body = requestBody(await readJsonBody(req, bodySizeLimit));
228
+ const args = {
229
+ res,
230
+ body,
231
+ query: url.searchParams,
232
+ key,
233
+ baseDir,
234
+ outFile,
235
+ };
236
+ if (route === 'GET /config') {
237
+ readConfig(args);
238
+ }
239
+ else {
240
+ updateConfig(args);
241
+ }
242
+ }
243
+ else if (route === 'POST /shutdown') {
244
+ const body = requestBody(await readJsonBody(req, bodySizeLimit));
245
+ if (asString(body.adminKey) === key) {
246
+ sendText(res, 200, 'Server shutting down');
247
+ setImmediate(() => {
248
+ serverRef.current?.close();
249
+ });
250
+ }
251
+ else {
252
+ sendText(res, 401, 'Error: Invalid admin key');
253
+ }
254
+ }
255
+ else if (url.pathname === '/' && !(await hasIndexHtml(baseDir))) {
256
+ sendText(res, 200, 'JBrowse Admin Server');
257
+ }
258
+ else {
259
+ await serveStatic({ req, res, baseDir });
260
+ }
261
+ }
262
+ catch (error) {
263
+ // the express error middleware this replaces, and registered after the
264
+ // routes for the same reason: it has to catch what they and the body
265
+ // reader throw
266
+ if (!res.headersSent) {
267
+ if (error instanceof PayloadTooLargeError) {
268
+ sendText(res, 413, 'Error: Request body too large');
269
+ }
270
+ else if (error instanceof SyntaxError) {
271
+ sendText(res, 400, 'Error: Malformed JSON in request body');
272
+ }
273
+ else {
274
+ console.error('Server error:', error);
275
+ sendText(res, 500, 'Internal Server Error');
276
+ }
277
+ }
278
+ }
279
+ };
280
+ }
281
+ export function startServer({ server, port, key, outFile, serverRef, }) {
282
+ server.listen(port, () => {
161
283
  console.log(`Admin server started on port ${port}\n\n` +
162
284
  `To access the admin interface, open your browser to:\n` +
163
285
  `http://localhost:${port}?adminKey=${key}\n\n` +
@@ -165,9 +287,7 @@ export function startServer({ app, port, key, outFile, keyPath, serverRef, }) {
165
287
  `Config file: ${outFile}\n\n` +
166
288
  `To stop the server, press Ctrl+C`);
167
289
  });
168
- // Store server reference for shutdown route
169
290
  serverRef.current = server;
170
- // Handle server errors
171
291
  server.on('error', (error) => {
172
292
  if (error.code === 'EADDRINUSE') {
173
293
  console.error(`Error: Port ${port} is already in use`);
@@ -177,22 +297,12 @@ export function startServer({ app, port, key, outFile, keyPath, serverRef, }) {
177
297
  }
178
298
  process.exit(1);
179
299
  });
180
- // Common shutdown handler
181
300
  const shutdownHandler = () => {
182
301
  console.log('\nShutting down admin server...');
183
302
  server.close(() => {
184
- // Clean up admin key file
185
- try {
186
- fs.unlinkSync(keyPath);
187
- debug(`Removed admin key file: ${keyPath}`);
188
- }
189
- catch (error) {
190
- // Ignore errors when cleaning up
191
- }
192
303
  process.exit(0);
193
304
  });
194
305
  };
195
- // Handle server shutdown
196
306
  process.on('SIGINT', shutdownHandler);
197
307
  process.on('SIGTERM', shutdownHandler);
198
308
  }
@@ -1,9 +1,6 @@
1
- import fs from 'fs';
2
- import { parseArgs } from 'util';
3
- import decompress from 'decompress';
4
- import fetch from "../cliFetch.js";
5
- import { fetchGithubVersions, getBranch, getLatest, getTag, printHelp, } from "../utils.js";
6
- const fsPromises = fs.promises;
1
+ import { mkdir, readdir } from 'node:fs/promises';
2
+ import { parseArgs } from 'node:util';
3
+ import { downloadRelease, extractZip, printHelp, printVersions, } from '../utils.js';
7
4
  const description = 'Downloads and installs the latest JBrowse 2 release';
8
5
  const examples = [
9
6
  '# Download latest release from github, and put in specific path',
@@ -71,44 +68,31 @@ export async function run(args) {
71
68
  });
72
69
  return;
73
70
  }
74
- const argsPath = positionals[0];
75
- if (!argsPath && !runFlags.listVersions) {
76
- console.error(`Missing 1 required arg:
77
- localPath Location where JBrowse 2 will be installed
78
- See more help with --help`);
79
- return;
80
- }
81
71
  const { force, url, listVersions, tag, branch, nightly } = runFlags;
82
72
  if (listVersions) {
83
- const versions = (await fetchGithubVersions()).map(version => version.tag_name);
84
- console.log(`All JBrowse versions:\n${versions.join('\n')}`);
85
- process.exit(0);
73
+ await printVersions();
74
+ return;
75
+ }
76
+ const argsPath = positionals[0];
77
+ if (!argsPath) {
78
+ throw new Error('Missing 1 required arg:\nlocalPath Location where JBrowse 2 will be installed\nSee more help with --help');
86
79
  }
87
80
  // mkdir will do nothing if dir exists
88
- await fsPromises.mkdir(argsPath, { recursive: true });
81
+ await mkdir(argsPath, { recursive: true });
89
82
  if (!force) {
90
83
  await checkPath(argsPath);
91
84
  }
92
- const locationUrl = url ||
93
- (nightly ? await getBranch('main') : '') ||
94
- (branch ? await getBranch(branch) : '') ||
95
- (tag ? await getTag(tag) : await getLatest());
96
- console.log(`Fetching ${locationUrl}...`);
97
- const response = await fetch(locationUrl);
98
- if (!response.ok) {
99
- throw new Error(`HTTP ${response.status} fetching ${locationUrl}: ${response.statusText}`);
100
- }
101
- const type = response.headers.get('content-type');
102
- if (url &&
103
- type !== 'application/zip' &&
104
- type !== 'application/octet-stream') {
105
- throw new Error('The URL provided does not seem to be a JBrowse installation URL');
106
- }
107
- await decompress(Buffer.from(await response.arrayBuffer()), argsPath);
85
+ const { locationUrl, archive } = await downloadRelease({
86
+ url,
87
+ nightly,
88
+ branch,
89
+ tag,
90
+ });
91
+ await extractZip(archive, argsPath);
108
92
  console.log(`Unpacked ${locationUrl} at ${argsPath}`);
109
93
  }
110
94
  async function checkPath(userPath) {
111
- const allFiles = await fsPromises.readdir(userPath);
95
+ const allFiles = await readdir(userPath);
112
96
  if (allFiles.length > 0) {
113
97
  throw new Error(`This directory (${userPath}) has existing files and could cause conflicts with create. Please choose another directory or use the force flag to overwrite existing files`);
114
98
  }
@@ -1,24 +1,67 @@
1
- const cigarRegex = new RegExp(/([MIDNSHPX=])/);
2
- export function parseCigar(cigar = '') {
3
- return cigar.split(cigarRegex).slice(0, -1);
4
- }
5
- export function flipCigar(cigar) {
6
- const arr = [];
7
- for (let i = cigar.length - 2; i >= 0; i -= 2) {
8
- arr.push(cigar[i]);
9
- const op = cigar[i + 1];
10
- if (op === 'D') {
11
- arr.push('I');
1
+ // Walk a CIGAR and split the alignment wherever an insertion or deletion is at
2
+ // least `splitGap` bp, so each coarse-tier row's bounding box stays tight.
3
+ // Returns one segment per contiguous run; `splitGap` must be positive (the
4
+ // caller keeps the whole row when there is nothing to split on).
5
+ // PAF semantics: qstart < qend always; '-' strand CIGAR walks query backward.
6
+ export function splitCigarOnLargeGaps({ cigar, strand, tstart, tend, qstart, qend, splitGap, }) {
7
+ const qStep = strand === '-' ? -1 : 1;
8
+ let tCursor = tstart;
9
+ let qCursor = strand === '-' ? qend : qstart;
10
+ let segTStart = tCursor;
11
+ let segQAnchor = qCursor;
12
+ let segBlockLen = 0;
13
+ const out = [];
14
+ const emit = () => {
15
+ if (tCursor > segTStart || qCursor !== segQAnchor) {
16
+ out.push({
17
+ tstart: segTStart,
18
+ tend: tCursor,
19
+ qstart: Math.min(segQAnchor, qCursor),
20
+ qend: Math.max(segQAnchor, qCursor),
21
+ blockLen: segBlockLen,
22
+ });
23
+ }
24
+ };
25
+ // Parse CIGAR directly to avoid intermediate array + string-to-number conversions
26
+ let i = 0;
27
+ while (i < cigar.length) {
28
+ let len = 0;
29
+ while (i < cigar.length &&
30
+ cigar.charCodeAt(i) >= 48 &&
31
+ cigar.charCodeAt(i) <= 57) {
32
+ len = len * 10 + cigar.charCodeAt(i++) - 48;
12
33
  }
13
- else if (op === 'I') {
14
- arr.push('D');
34
+ const op = cigar[i++];
35
+ if ('M=X'.includes(op)) {
36
+ tCursor += len;
37
+ qCursor += len * qStep;
38
+ segBlockLen += len;
15
39
  }
16
- else {
17
- arr.push(op);
40
+ else if ('DIN'.includes(op)) {
41
+ const isLarge = len >= splitGap;
42
+ if (isLarge) {
43
+ emit();
44
+ }
45
+ if (op === 'I') {
46
+ qCursor += len * qStep;
47
+ }
48
+ else {
49
+ tCursor += len;
50
+ }
51
+ if (isLarge) {
52
+ segTStart = tCursor;
53
+ segQAnchor = qCursor;
54
+ segBlockLen = 0;
55
+ }
56
+ else {
57
+ segBlockLen += len;
58
+ }
18
59
  }
60
+ // S/H/P (clipping/padding) don't consume target or forward-strand query in PAF
19
61
  }
20
- return arr;
21
- }
22
- export function swapIndelCigar(cigar) {
23
- return cigar.replaceAll('D', 'K').replaceAll('I', 'D').replaceAll('K', 'I');
62
+ emit();
63
+ return {
64
+ segments: out,
65
+ closed: tCursor === tend && qCursor === (strand === '-' ? qstart : qend),
66
+ };
24
67
  }