@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.
- package/README.md +334 -60
- package/bin/run +1 -1
- package/bundle/index.js +493 -2555
- package/dist/base.js +0 -3
- package/dist/bin.js +2 -1
- package/dist/cliFetch.js +3 -0
- package/dist/commands/add-assembly/index.js +46 -19
- package/dist/commands/add-assembly/utils.js +204 -290
- package/dist/commands/add-connection.js +73 -79
- package/dist/commands/add-track-json.js +24 -12
- package/dist/commands/add-track-utils/adapter-utils.js +173 -283
- package/dist/commands/add-track-utils/file-operations.js +27 -11
- package/dist/commands/add-track-utils/multiwig.js +47 -0
- package/dist/commands/add-track-utils/track-config.js +68 -25
- package/dist/commands/add-track-utils/validators.js +36 -20
- package/dist/commands/add-track.js +178 -62
- package/dist/commands/admin-server/index.js +30 -49
- package/dist/commands/admin-server/utils.js +235 -125
- package/dist/commands/create.js +18 -34
- package/dist/commands/make-pif/cigar-utils.js +62 -19
- package/dist/commands/make-pif/index.js +98 -11
- package/dist/commands/make-pif/pif-generator.js +276 -42
- package/dist/commands/process-utils.js +13 -2
- package/dist/commands/remove-track.js +20 -13
- package/dist/commands/set-default-session.js +35 -31
- package/dist/commands/shared/config-operations.js +3 -10
- package/dist/commands/shared/sort-utils.js +67 -10
- package/dist/commands/shared/validators.js +38 -3
- package/dist/commands/sort-bed.js +2 -33
- package/dist/commands/sort-gff.js +2 -33
- package/dist/commands/text-index/adapter-utils.js +6 -23
- package/dist/commands/text-index/aggregate.js +40 -34
- package/dist/commands/text-index/command.js +35 -7
- package/dist/commands/text-index/config-utils.js +60 -83
- package/dist/commands/text-index/file-list.js +32 -18
- package/dist/commands/text-index/index.js +4 -4
- package/dist/commands/text-index/indexing-utils.js +49 -73
- package/dist/commands/text-index/per-track.js +44 -31
- package/dist/commands/text-index/validators.js +1 -1
- package/dist/commands/upgrade.js +20 -32
- package/dist/commands/validate/configManifest.generated.js +5394 -0
- package/dist/commands/validate/displayDefaultKeys.js +28 -0
- package/dist/commands/validate/index.js +97 -0
- package/dist/commands/validate/suggest.js +35 -0
- package/dist/commands/validate/types.js +6 -0
- package/dist/commands/validate/validateConfig.js +457 -0
- package/dist/index.js +99 -50
- package/dist/types/common.js +3 -27
- package/dist/utils.js +309 -128
- package/dist/version.js +1 -1
- package/package.json +8 -10
- package/dist/commands/make-pif/file-utils.js +0 -30
- package/dist/commands/track-utils.js +0 -74
- 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
|
|
5
|
-
import {
|
|
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 (!
|
|
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(
|
|
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
|
-
*
|
|
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
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
70
|
+
chunks.push(buffer);
|
|
73
71
|
}
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
app.post('/updateConfig', (req, res) => {
|
|
89
|
-
const { body } = req;
|
|
143
|
+
if (validation.ok) {
|
|
90
144
|
const config = body.config;
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
133
|
-
res.send('Error: Failed to read config');
|
|
181
|
+
sendText(res, 500, 'Error: Failed to read config');
|
|
134
182
|
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
*
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|
package/dist/commands/create.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { parseArgs } from 'util';
|
|
3
|
-
import
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
81
|
+
await mkdir(argsPath, { recursive: true });
|
|
89
82
|
if (!force) {
|
|
90
83
|
await checkPath(argsPath);
|
|
91
84
|
}
|
|
92
|
-
const locationUrl =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
62
|
+
emit();
|
|
63
|
+
return {
|
|
64
|
+
segments: out,
|
|
65
|
+
closed: tCursor === tend && qCursor === (strand === '-' ? qstart : qend),
|
|
66
|
+
};
|
|
24
67
|
}
|