@apollo-annotation/cli 0.3.9 → 0.3.11

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.
@@ -0,0 +1,323 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { Args, Flags } from '@oclif/core';
3
+ import { ObjectId } from 'bson';
4
+ import { fetch } from 'undici';
5
+ import { BaseCommand } from '../../baseCommand.js';
6
+ import { createFetchErrorMessage, localhostToAddress } from '../../utils.js';
7
+ export default class Add extends BaseCommand {
8
+ static summary = 'Add one or more features to Apollo';
9
+ static description = `A single simple feature can be added using the --min, --max, etc. flags.
10
+
11
+ To add multiple features, features with more details, or features with children, you can pass in JSON via argument or stdin or use the --feature-json-file options.
12
+ `;
13
+ static examples = [
14
+ {
15
+ description: 'Add a single feature by specifying its location and type',
16
+ command: '<%= config.bin %> <%= command.id %> --assembly hg19 --refSeq chr3 --min 1000 --max 5000 --type remark',
17
+ },
18
+ {
19
+ description: 'Add a single feature from inline JSON',
20
+ command: '<%= config.bin %> <%= command.id %> \'{"assembly":"<assemblyNameOrId>","refseq":"<refSeqNameOrId>","min":1,"max":100,"type":"<featureType>"}\'',
21
+ },
22
+ {
23
+ description: 'Add mutilple features from stdin JSON',
24
+ command: 'echo \'[{"assembly":"<assemblyNameOrId>","refseq":"<refSeqNameOrId>","min":1,"max":100,"type":"<featureType>"},{"assembly":"<assemblyNameOrId>","refseq":"<refSeqNameOrId>","min":101,"max":200,"type":"<featureType>"}]\' | <%= config.bin %> <%= command.id %>',
25
+ },
26
+ {
27
+ description: 'Add a feature with children from inline JSON',
28
+ command: '<%= config.bin %> <%= command.id %> \'{"assembly":"<assemblyNameOrId>","refseq":"<refSeqNameOrId>","min":1,"max":100,"type":"<featureType>","children":[{"min":1,"max":50,"type":"<featureType>"}]}\'',
29
+ },
30
+ ];
31
+ static flags = {
32
+ assembly: Flags.string({
33
+ char: 'a',
34
+ description: 'Name or ID of target assembly. Not required if refseq is unique in the database',
35
+ }),
36
+ refSeq: Flags.string({
37
+ char: 'r',
38
+ description: 'Name or ID of target reference sequence',
39
+ dependsOn: ['min', 'max', 'type'],
40
+ exclusive: ['feature-json-file'],
41
+ }),
42
+ min: Flags.integer({
43
+ char: 's',
44
+ description: 'Start position in target reference sequence',
45
+ dependsOn: ['refSeq', 'max', 'type'],
46
+ exclusive: ['feature-json-file'],
47
+ }),
48
+ max: Flags.integer({
49
+ char: 'e',
50
+ description: 'End position in target reference sequence',
51
+ dependsOn: ['refSeq', 'min', 'type'],
52
+ exclusive: ['feature-json-file'],
53
+ }),
54
+ type: Flags.string({
55
+ char: 't',
56
+ description: 'Type of child feature',
57
+ dependsOn: ['refSeq', 'min', 'max'],
58
+ exclusive: ['feature-json-file'],
59
+ }),
60
+ 'feature-json-file': Flags.file({
61
+ char: 'F',
62
+ description: 'File with JSON describing the feature(s) to add',
63
+ exists: true,
64
+ }),
65
+ };
66
+ static args = {
67
+ 'feature-json': Args.string({
68
+ description: 'Inline JSON describing the feature(s) to add. Can also be provided via stdin.',
69
+ }),
70
+ };
71
+ async run() {
72
+ const { args, flags } = this;
73
+ const { 'feature-json': featureJSONString } = args;
74
+ const { assembly, refSeq, min, max, type, 'feature-json-file': featureJSONFile, } = flags;
75
+ if (featureJSONString) {
76
+ if (assembly !== undefined ||
77
+ refSeq !== undefined ||
78
+ min !== undefined ||
79
+ max !== undefined ||
80
+ type !== undefined ||
81
+ featureJSONFile !== undefined) {
82
+ this.error('Cannot use the following flags when providing a feature JSON: --assembly, --refSeq, --min, --max, --type, --feature-json-file');
83
+ }
84
+ await this.addFeatureFromJSON(featureJSONString);
85
+ return;
86
+ }
87
+ if (featureJSONFile) {
88
+ const fileText = await readFile(featureJSONFile, 'utf8');
89
+ await this.addFeatureFromJSON(fileText);
90
+ return;
91
+ }
92
+ if (refSeq === undefined ||
93
+ min === undefined ||
94
+ max === undefined ||
95
+ type === undefined) {
96
+ this.error('Must provide all of: --refSeq, --min, --max, and --type');
97
+ }
98
+ await this.addFeatureFromFlags(refSeq, min, max, type, assembly);
99
+ }
100
+ async addFeatureFromJSON(featureJSONString) {
101
+ let featureJSON;
102
+ try {
103
+ featureJSON = parseFeatureJSON(featureJSONString);
104
+ }
105
+ catch (error) {
106
+ this.logToStderr('Error: feature JSON is not valid');
107
+ if (error instanceof Error || typeof error === 'string') {
108
+ this.error(error);
109
+ }
110
+ throw error;
111
+ }
112
+ if (Array.isArray(featureJSON)) {
113
+ const firstFeature = featureJSON.at(0);
114
+ if (!firstFeature) {
115
+ throw new Error('Feature array is empty');
116
+ }
117
+ const { assembly, refSeq } = firstFeature;
118
+ if (!featureJSON.every((feature) => feature.assembly === assembly)) {
119
+ throw new Error('Cannot add features to multiple assemblies at the same time');
120
+ }
121
+ const [assemblyId] = await this.getAssemblyAndRefSeqIds(refSeq, assembly);
122
+ const changedIds = [];
123
+ const changes = await Promise.all(featureJSON.map(async (singleFeatureJSON) => {
124
+ const { children, assembly, refSeq, ...rest } = singleFeatureJSON;
125
+ const refSeqDocument = await this.getRefSeq(refSeq);
126
+ return {
127
+ addedFeature: this.makeFeatureSnapshot({
128
+ assembly: assemblyId,
129
+ refSeq: refSeqDocument._id,
130
+ ...rest,
131
+ }, changedIds),
132
+ };
133
+ }));
134
+ const change = {
135
+ changedIds,
136
+ typeName: 'AddFeatureChange',
137
+ assembly: assemblyId,
138
+ changes,
139
+ };
140
+ return this.submitChange(change);
141
+ }
142
+ const { assembly, refSeq, ...rest } = featureJSON;
143
+ const [assemblyId, refSeqId] = await this.getAssemblyAndRefSeqIds(refSeq, assembly);
144
+ const changedIds = [];
145
+ const details = {
146
+ addedFeature: this.makeFeatureSnapshot({
147
+ assembly: assemblyId,
148
+ refSeq: refSeqId,
149
+ ...rest,
150
+ }, changedIds),
151
+ };
152
+ const change = {
153
+ changedIds,
154
+ typeName: 'AddFeatureChange',
155
+ assembly: assemblyId,
156
+ ...details,
157
+ };
158
+ return this.submitChange(change);
159
+ }
160
+ makeFeatureSnapshot(details, ids) {
161
+ const { children, ...rest } = details;
162
+ let childrenDetails = undefined;
163
+ if (children) {
164
+ childrenDetails = {};
165
+ const { refSeq } = rest;
166
+ for (const child of children) {
167
+ const childDetails = this.makeFeatureSnapshot({ refSeq, ...child }, ids);
168
+ childrenDetails[childDetails._id] = childDetails;
169
+ }
170
+ }
171
+ const _id = new ObjectId().toHexString();
172
+ ids.push(_id);
173
+ return {
174
+ _id,
175
+ ...rest,
176
+ ...(childrenDetails ? { children: childrenDetails } : {}),
177
+ };
178
+ }
179
+ async addFeatureFromFlags(refSeqNameOrId, min, max, type, assemblyNameOrId) {
180
+ const [assemblyId, refSeqId] = await this.getAssemblyAndRefSeqIds(refSeqNameOrId, assemblyNameOrId);
181
+ const changedIds = [];
182
+ const details = {
183
+ addedFeature: this.makeFeatureSnapshot({ refSeq: refSeqId, min: min - 1, max, type }, changedIds),
184
+ };
185
+ const change = {
186
+ changedIds,
187
+ typeName: 'AddFeatureChange',
188
+ assembly: assemblyId,
189
+ ...details,
190
+ };
191
+ return this.submitChange(change);
192
+ }
193
+ async getAssemblyAndRefSeqIds(refSeqNameOrId, assemblyNameOrId) {
194
+ const refSeqIsObjectId = ObjectId.isValid(refSeqNameOrId);
195
+ const assemblyIsObjectId = assemblyNameOrId
196
+ ? ObjectId.isValid(assemblyNameOrId)
197
+ : false;
198
+ if (assemblyNameOrId && assemblyIsObjectId && refSeqIsObjectId) {
199
+ return [assemblyNameOrId, refSeqNameOrId];
200
+ }
201
+ if (refSeqIsObjectId) {
202
+ if (assemblyNameOrId) {
203
+ this.warn('Ignoring provided --assembly because it is not an ID');
204
+ }
205
+ const refSeqDocument = await this.getRefSeq(refSeqNameOrId);
206
+ return [refSeqDocument.assembly, refSeqNameOrId];
207
+ }
208
+ if (!assemblyNameOrId) {
209
+ this.error(`If provided refSeq (${refSeqNameOrId}) is not an ID, assembly must also be provided`);
210
+ }
211
+ const assemblyDocument = await this.getAssembly(assemblyNameOrId);
212
+ const refSeqDocument = await this.getRefSeq(refSeqNameOrId, assemblyDocument._id);
213
+ return [assemblyDocument._id, refSeqDocument._id];
214
+ }
215
+ async getAssembly(assemblyNameOrId) {
216
+ if (ObjectId.isValid(assemblyNameOrId)) {
217
+ const response = await this.fetch(`assemblies/${assemblyNameOrId}`);
218
+ if (!response.ok) {
219
+ const errorMessage = await createFetchErrorMessage(response, `Could not find assembly: "${assemblyNameOrId}"`);
220
+ this.error(errorMessage);
221
+ }
222
+ return response.json();
223
+ }
224
+ const response = await this.fetch('assemblies');
225
+ if (!response.ok) {
226
+ const errorMessage = await createFetchErrorMessage(response, `Could not find assembly: "${assemblyNameOrId}"`);
227
+ this.error(errorMessage);
228
+ }
229
+ const assemblies = (await response.json());
230
+ for (const assembly of assemblies) {
231
+ if (assembly.name === assemblyNameOrId ||
232
+ assembly.aliases?.includes(assemblyNameOrId)) {
233
+ return assembly;
234
+ }
235
+ }
236
+ throw new Error(`Could not find assembly: "${assemblyNameOrId}"`);
237
+ }
238
+ async getRefSeq(refSeqNameOrId, assemblyId) {
239
+ if (ObjectId.isValid(refSeqNameOrId)) {
240
+ const response = await this.fetch(`refSeqs/${refSeqNameOrId}`);
241
+ if (!response.ok) {
242
+ const errorMessage = await createFetchErrorMessage(response, `Could not find refSeq: "${refSeqNameOrId}"`);
243
+ this.error(errorMessage);
244
+ }
245
+ return response.json();
246
+ }
247
+ let endpoint = 'refSeqs';
248
+ if (assemblyId) {
249
+ const searchParams = new URLSearchParams({ assembly: assemblyId });
250
+ endpoint = `${endpoint}?${searchParams.toString()}`;
251
+ }
252
+ const response = await this.fetch(endpoint);
253
+ if (!response.ok) {
254
+ const errorMessage = await createFetchErrorMessage(response, `Could not find refSeq: "${refSeqNameOrId}"`);
255
+ this.error(errorMessage);
256
+ }
257
+ const refSeqs = (await response.json());
258
+ for (const refSeq of refSeqs) {
259
+ if (refSeq.name === refSeqNameOrId ||
260
+ refSeq.aliases?.includes(refSeqNameOrId)) {
261
+ return refSeq;
262
+ }
263
+ }
264
+ throw new Error(`Could not find refSeq: "${refSeqNameOrId}"`);
265
+ }
266
+ async submitChange(change) {
267
+ const options = { method: 'POST', body: JSON.stringify(change) };
268
+ const response = await this.fetch('changes', options);
269
+ if (!response.ok) {
270
+ const errorMessage = await createFetchErrorMessage(response, 'Could not add feature');
271
+ this.error(errorMessage);
272
+ }
273
+ this.log(await response.text());
274
+ }
275
+ async fetch(endpoint, options) {
276
+ const { address, accessToken } = await this.getAccess();
277
+ const url = new URL(localhostToAddress(`${address}/${endpoint}`));
278
+ const optionsWithAuth = {
279
+ ...options,
280
+ headers: {
281
+ authorization: `Bearer ${accessToken}`,
282
+ 'Content-Type': 'application/json',
283
+ ...options?.headers,
284
+ },
285
+ };
286
+ return fetch(url, optionsWithAuth);
287
+ }
288
+ }
289
+ function parseFeatureJSON(featureJSONString) {
290
+ const featureJSON = JSON.parse(featureJSONString);
291
+ if (Array.isArray(featureJSON)) {
292
+ return featureJSON.map((feature) => {
293
+ assertFeatureIsValid(feature, true);
294
+ return feature;
295
+ });
296
+ }
297
+ assertFeatureIsValid(featureJSON, true);
298
+ return featureJSON;
299
+ }
300
+ function assertFeatureIsValid(feature, topLevel = false) {
301
+ if (typeof feature !== 'object' ||
302
+ feature === null ||
303
+ Array.isArray(feature)) {
304
+ throw new TypeError(`Feature is not a key-value record: '${JSON.stringify(feature)}'`);
305
+ }
306
+ for (const attribute of ['min', 'max', 'type']) {
307
+ if (!(attribute in feature)) {
308
+ throw new Error(`Feature does not contain "${attribute}": '${JSON.stringify(feature)}'`);
309
+ }
310
+ }
311
+ if (topLevel && !('refSeq' in feature)) {
312
+ throw new Error(`Top-level feature does not contain "refSeq": '${JSON.stringify(feature)}'`);
313
+ }
314
+ if ('children' in feature) {
315
+ const { children } = feature;
316
+ if (!Array.isArray(children)) {
317
+ throw new TypeError(`"children" is not an array of features '${JSON.stringify(feature)}'`);
318
+ }
319
+ for (const child of children) {
320
+ assertFeatureIsValid(child);
321
+ }
322
+ }
323
+ }
@@ -0,0 +1,17 @@
1
+ import { BaseCommand } from '../../baseCommand.js';
2
+ export default class Get extends BaseCommand<typeof Get> {
3
+ static summary: string;
4
+ static description: string;
5
+ static examples: {
6
+ description: string;
7
+ command: string;
8
+ }[];
9
+ static args: {
10
+ id: import("@oclif/core/lib/interfaces/parser.js").Arg<string, Record<string, unknown>>;
11
+ };
12
+ static flags: {
13
+ assembly: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string[] | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
14
+ topLevel: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
15
+ };
16
+ run(): Promise<void>;
17
+ }
@@ -0,0 +1,66 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { fetch } from 'undici';
3
+ import { BaseCommand } from '../../baseCommand.js';
4
+ import { convertAssemblyNameToId, createFetchErrorMessage, idReader, localhostToAddress, } from '../../utils.js';
5
+ export default class Get extends BaseCommand {
6
+ static summary = 'Get features given an indexed identifier';
7
+ static description = 'Get features that match a given indexed identifier, such as the ID of a feature from an imported GFF3 file';
8
+ static examples = [
9
+ {
10
+ description: 'Get features for this indexed identifier:',
11
+ command: '<%= config.bin %> <%= command.id %> -i abc...zyz def...foo',
12
+ },
13
+ ];
14
+ static args = {
15
+ id: Args.string({
16
+ description: 'Indexed identifier to search for',
17
+ required: true,
18
+ }),
19
+ };
20
+ static flags = {
21
+ assembly: Flags.string({
22
+ char: 'a',
23
+ multiple: true,
24
+ description: 'Assembly names or IDs to search; use "-" to read it from stdin. If omitted search all assemblies',
25
+ }),
26
+ topLevel: Flags.boolean({
27
+ description: 'Return the top-level parent of the feature instead of the feature itself',
28
+ }),
29
+ };
30
+ async run() {
31
+ const { args, flags } = await this.parse(Get);
32
+ const access = await this.getAccess();
33
+ const { topLevel } = flags;
34
+ const { id } = args;
35
+ const assembly = flags.assembly && (await idReader(flags.assembly));
36
+ const assemblyIds = assembly &&
37
+ (await convertAssemblyNameToId(access.address, access.accessToken, assembly));
38
+ if (assemblyIds?.length === 0) {
39
+ this.log(JSON.stringify([], null, 2));
40
+ this.exit(0);
41
+ }
42
+ const url = new URL(localhostToAddress(`${access.address}/features/getByIndexedId`));
43
+ const searchParams = new URLSearchParams({ id });
44
+ if (assemblyIds) {
45
+ searchParams.append('assemblies', assemblyIds.join(','));
46
+ }
47
+ if (topLevel) {
48
+ searchParams.append('topLevel', 'true');
49
+ }
50
+ url.search = searchParams.toString();
51
+ const uri = url.toString();
52
+ const auth = {
53
+ headers: {
54
+ authorization: `Bearer ${access.accessToken}`,
55
+ 'Content-Type': 'application/json',
56
+ },
57
+ };
58
+ const response = await fetch(uri, auth);
59
+ if (!response.ok) {
60
+ const errorMessage = await createFetchErrorMessage(response, 'Failed to access Apollo with the current address and/or access token\nThe server returned:\n');
61
+ throw new Error(errorMessage);
62
+ }
63
+ const results = (await response.json());
64
+ this.log(JSON.stringify(results, null, 2));
65
+ }
66
+ }
package/dist/test/test.js CHANGED
@@ -18,14 +18,19 @@
18
18
  import assert from 'node:assert';
19
19
  import * as crypto from 'node:crypto';
20
20
  import fs from 'node:fs';
21
- import { afterEach, before, beforeEach, describe } from 'node:test';
21
+ import { after, afterEach, before, beforeEach, describe } from 'node:test';
22
+ import { MongoClient } from 'mongodb';
22
23
  import { Shell, deleteAllChecks } from './utils.js';
23
24
  const apollo = 'yarn dev';
24
25
  const P = '--profile testAdmin';
26
+ // let client = MongoClient
27
+ let client;
25
28
  let configFile = '';
26
29
  let configFileBak = '';
27
30
  void describe('Test CLI', () => {
28
31
  before(() => {
32
+ const uri = 'mongodb://localhost:27017/apolloTestCliDb?directConnection=true';
33
+ client = new MongoClient(uri);
29
34
  configFile = new Shell(`${apollo} config --get-config-file`).stdout.trim();
30
35
  configFileBak = `${configFile}.bak`;
31
36
  if (fs.existsSync(configFileBak)) {
@@ -36,11 +41,24 @@ void describe('Test CLI', () => {
36
41
  new Shell(`${apollo} config ${P} rootPassword pass`);
37
42
  new Shell(`${apollo} login ${P} -f`);
38
43
  });
44
+ after(async () => {
45
+ await client.close();
46
+ });
39
47
  beforeEach(() => {
40
48
  // Backup starting config file
41
49
  fs.copyFileSync(configFile, configFileBak);
42
50
  });
43
- afterEach(() => {
51
+ afterEach(async () => {
52
+ const database = client.db('apolloTestCliDb');
53
+ await Promise.all([
54
+ 'assemblies',
55
+ 'changes',
56
+ 'counters',
57
+ 'features',
58
+ 'files',
59
+ 'refseqchunks',
60
+ 'refseqs',
61
+ ].map((collectionName) => database.collection(collectionName).deleteMany({})));
44
62
  // Put back starting config file
45
63
  fs.renameSync(configFileBak, configFile);
46
64
  });
@@ -501,6 +519,111 @@ void describe('Test CLI', () => {
501
519
  p = new Shell(`${apollo} feature search ${P} -a vv1 -t Q`);
502
520
  assert.ok(p.stdout.includes('"Q"'));
503
521
  });
522
+ void globalThis.itName('Get feature by indexed ID', () => {
523
+ new Shell(`${apollo} assembly add-from-gff ${P} test_data/tiny.fasta.gff3 -a vv1 -f`);
524
+ new Shell(`${apollo} assembly add-from-gff ${P} test_data/tiny.fasta.gff3 -a vv2 -f`);
525
+ // Search multiple assemblies
526
+ let p = new Shell(`${apollo} feature get-indexed-id ${P} MyGene -a vv1 vv2`);
527
+ let out = JSON.parse(p.stdout);
528
+ assert.strictEqual(out.length, 2);
529
+ assert.ok(p.stdout.includes('MyGene'));
530
+ // Specifying no assembly defaults to searching all assemblies
531
+ p = new Shell(`${apollo} feature get-indexed-id ${P} MyGene`);
532
+ out = JSON.parse(p.stdout);
533
+ assert.strictEqual(out.length, 2);
534
+ assert.ok(p.stdout.includes('MyGene'));
535
+ // Search single assembly
536
+ p = new Shell(`${apollo} feature get-indexed-id ${P} MyGene -a vv1`);
537
+ out = JSON.parse(p.stdout);
538
+ assert.strictEqual(out.length, 1);
539
+ assert.ok(p.stdout.includes('MyGene'));
540
+ // Warn on unknown assembly
541
+ p = new Shell(`${apollo} feature get-indexed-id ${P} EDEN -a foobar`);
542
+ assert.strictEqual('[]', p.stdout.trim());
543
+ assert.ok(p.stderr.includes('Warning'));
544
+ // Return empty array with no matches
545
+ p = new Shell(`${apollo} feature get-indexed-id ${P} foobarspam -a vv1`);
546
+ assert.deepStrictEqual(p.stdout.trim(), '[]');
547
+ // Gets subfeature
548
+ p = new Shell(`${apollo} feature get-indexed-id ${P} myCDS.1 -a vv1`);
549
+ out = JSON.parse(p.stdout);
550
+ assert.strictEqual(out.length, 1);
551
+ assert.ok(out.at(0)?.type === 'CDS');
552
+ // Gets top-level feature from subfeature id
553
+ p = new Shell(`${apollo} feature get-indexed-id ${P} myCDS.1 -a vv1 --topLevel`);
554
+ out = JSON.parse(p.stdout);
555
+ assert.strictEqual(out.length, 1);
556
+ assert.ok(out.at(0)?.type === 'gene');
557
+ // Gets feature and child feature that were added manually (not imported)
558
+ p = new Shell(`${apollo} feature add ${P} <<EOF
559
+ {
560
+ "assembly": "vv1",
561
+ "refSeq": "ctgA",
562
+ "min": 301,
563
+ "max": 310,
564
+ "type": "match",
565
+ "attributes": {"gff_id": ["match1"]},
566
+ "children": [
567
+ {
568
+ "min": 301,
569
+ "max": 305,
570
+ "type": "match_part",
571
+ "attributes": {"gff_id": ["matchPart1"]}
572
+ }
573
+ ]
574
+ }
575
+ EOF`);
576
+ p = new Shell(`${apollo} feature get-indexed-id ${P} match1 -a vv1`);
577
+ out = JSON.parse(p.stdout);
578
+ assert.strictEqual(out.length, 1);
579
+ assert.ok(p.stdout.includes('match1'));
580
+ p = new Shell(`${apollo} feature get-indexed-id ${P} matchPart1 -a vv1`);
581
+ out = JSON.parse(p.stdout);
582
+ assert.strictEqual(out.length, 1);
583
+ assert.ok(p.stdout.includes('matchPart1'));
584
+ // Doesn't get child feature after it was deleted
585
+ const idToDelete = out[0]._id;
586
+ new Shell(`${apollo} feature delete ${P} -i ${idToDelete}`);
587
+ p = new Shell(`${apollo} feature get-indexed-id ${P} matchPart1 -a vv1`);
588
+ assert.deepStrictEqual(p.stdout.trim(), '[]');
589
+ // Gets feature after ID was manually added
590
+ p = new Shell(`${apollo} feature add ${P} <<EOF
591
+ {
592
+ "assembly": "vv1",
593
+ "refSeq": "ctgA",
594
+ "min": 311,
595
+ "max": 320,
596
+ "type": "match"
597
+ }
598
+ EOF`);
599
+ out = JSON.parse(p.stdout);
600
+ const { assembly, changes } = out;
601
+ const { _id, refSeq } = changes[0].addedFeature;
602
+ new Shell(`${apollo} feature edit-attribute ${P} -i ${_id} -a gff_id -v match2`);
603
+ p = new Shell(`${apollo} feature get-indexed-id ${P} match2 -a vv1`);
604
+ out = JSON.parse(p.stdout);
605
+ assert.strictEqual(out.length, 1);
606
+ assert.ok(p.stdout.includes('match2'));
607
+ // Gets child featuer after it was added with an ID
608
+ // add-child CLI command doesn't support adding attributes yet, so we'll
609
+ // manually do it with curl for testing for neow
610
+ p = new Shell(`${apollo} config ${P} accessToken`);
611
+ const token = p.stdout.trim();
612
+ const newChildFeatureID = '69408088d502fc21aea1bb0a';
613
+ new Shell(`curl -X POST http://127.0.0.1:3999/changes -d '{"typeName":"AddFeatureChange","changedIds":["${newChildFeatureID}"],"assembly":"${assembly}","addedFeature":{"_id":"${newChildFeatureID}","refSeq":"${refSeq}","min":311,"max":315,"type":"match_part","attributes":{"gff_id":["matchPart2"]}},"parentFeatureId":"${_id}"}' -H "Content-Type: application/json" -H "Authorization: Bearer ${token}"`);
614
+ p = new Shell(`${apollo} feature get-indexed-id ${P} matchPart2 -a vv1`);
615
+ out = JSON.parse(p.stdout);
616
+ assert.strictEqual(out.length, 1);
617
+ assert.ok(p.stdout.includes('matchPart2'));
618
+ // Doesn't get feature or child after IDs were manually removed
619
+ new Shell(`${apollo} feature edit-attribute ${P} -i ${_id} -a gff_id -d`);
620
+ const childId = out[0]._id;
621
+ new Shell(`${apollo} feature edit-attribute ${P} -i ${childId} -a gff_id -d`);
622
+ p = new Shell(`${apollo} feature get-indexed-id ${P} match2 -a vv1`);
623
+ assert.deepStrictEqual(p.stdout.trim(), '[]');
624
+ p = new Shell(`${apollo} feature get-indexed-id ${P} matchPart2 -a vv1`);
625
+ assert.deepStrictEqual(p.stdout.trim(), '[]');
626
+ });
504
627
  void globalThis.itName('Delete features', () => {
505
628
  new Shell(`${apollo} assembly add-from-gff ${P} test_data/tiny.fasta.gff3 -a vv1 -f`);
506
629
  let p = new Shell(`${apollo} feature search ${P} -a vv1 -t EDEN`);
@@ -516,6 +639,75 @@ void describe('Test CLI', () => {
516
639
  p = new Shell(`${apollo} feature delete ${P} --force -i ${fid}`);
517
640
  assert.strictEqual(p.returncode, 0);
518
641
  });
642
+ void globalThis.itName('Add features', () => {
643
+ let p = new Shell(`${apollo} assembly add-from-fasta ${P} test_data/tiny.fasta.gz -a tiny -f`);
644
+ let out = JSON.parse(p.stdout);
645
+ const assemblyId = out._id;
646
+ p = new Shell(`${apollo} feature get ${P} -a tiny`);
647
+ assert.deepStrictEqual(p.stdout.trim(), '[]');
648
+ // Can add a feature using flags
649
+ p = new Shell(`${apollo} feature add ${P} -a tiny -r ctgA -s 1 -e 10 -t remark`);
650
+ out = JSON.parse(p.stdout);
651
+ p = new Shell(`${apollo} feature get ${P} -a tiny`);
652
+ out = JSON.parse(p.stdout);
653
+ assert.strictEqual(out.length, 1);
654
+ const refSeqId = out[0].refSeq;
655
+ // Can add a feature using assembly and refSeq ids
656
+ p = new Shell(`${apollo} feature add ${P} -a ${assemblyId} -r ${refSeqId} -s 11 -e 20 -t remark`);
657
+ p = new Shell(`${apollo} feature get ${P} -a ${assemblyId}`);
658
+ out = JSON.parse(p.stdout);
659
+ assert.strictEqual(out.length, 2);
660
+ // Can add a feature using JSON arg
661
+ p = new Shell(`${apollo} feature add ${P} '{"assembly":"${assemblyId}","refSeq":"${refSeqId}","min":21,"max":30,"type":"remark"}'`);
662
+ p = new Shell(`${apollo} feature get ${P} -a ${assemblyId}`);
663
+ out = JSON.parse(p.stdout);
664
+ assert.strictEqual(out.length, 3);
665
+ // Can add a feature using JSON from stdin
666
+ p = new Shell(`${apollo} feature add ${P} <<EOF
667
+ {
668
+ "assembly": "${assemblyId}",
669
+ "refSeq": "${refSeqId}",
670
+ "min": 31,
671
+ "max": 40,
672
+ "type": "remark"
673
+ }
674
+ EOF`);
675
+ p = new Shell(`${apollo} feature get ${P} -a ${assemblyId}`);
676
+ out = JSON.parse(p.stdout);
677
+ assert.strictEqual(out.length, 4);
678
+ // Can add a feature using JSON from a file
679
+ fs.writeFileSync('test_data/tmp.json', `{"assembly":"${assemblyId}","refSeq":"${refSeqId}","min":41,"max":50,"type":"remark"}\n`);
680
+ p = new Shell(`${apollo} feature add ${P} --feature-json-file test_data/tmp.json`);
681
+ fs.unlinkSync('test_data/tmp.json');
682
+ p = new Shell(`${apollo} feature get ${P} -a ${assemblyId}`);
683
+ out = JSON.parse(p.stdout);
684
+ assert.strictEqual(out.length, 5);
685
+ // Can add multiple features using JSON
686
+ p = new Shell(`${apollo} feature add ${P} '[{"assembly":"${assemblyId}","refSeq":"${refSeqId}","min":51,"max":60,"type":"remark"},{"assembly":"${assemblyId}","refSeq":"${refSeqId}","min":61,"max":70,"type":"remark"}]'`);
687
+ p = new Shell(`${apollo} feature get ${P} -a ${assemblyId}`);
688
+ out = JSON.parse(p.stdout);
689
+ assert.strictEqual(out.length, 7);
690
+ // Can add a feature with children from JSON
691
+ p = new Shell(`${apollo} feature add ${P} '{"assembly":"${assemblyId}","refSeq":"${refSeqId}","min":71,"max":80,"type":"match","children":[{"min":71,"max":75,"type":"match_part"}]}'`);
692
+ p = new Shell(`${apollo} feature get ${P} -a ${assemblyId} -r ${refSeqId} -s 71 -e 80`);
693
+ out = JSON.parse(p.stdout);
694
+ let feature = out.at(0);
695
+ assert.strictEqual(Object.keys(feature?.children).length, 1);
696
+ // Can add a feature with attributes from JSON
697
+ p = new Shell(`${apollo} feature add ${P} '{"assembly":"${assemblyId}","refSeq":"${refSeqId}","min":81,"max":90,"type":"remark","attributes":{"key1":["val1"]}}'`);
698
+ p = new Shell(`${apollo} feature get ${P} -a ${assemblyId} -r ${refSeqId} -s 81 -e 90`);
699
+ out = JSON.parse(p.stdout);
700
+ feature = out.at(0);
701
+ assert.strictEqual(feature?.attributes?.key1?.[0], 'val1');
702
+ // Can add a feature with children from JSON
703
+ p = new Shell(`${apollo} feature add ${P} '{"assembly":"${assemblyId}","refSeq":"${refSeqId}","min":91,"max":100,"type":"match","children":[{"min":91,"max":95,"type":"match_part","attributes":{"key2":["val2"]}}]}'`);
704
+ p = new Shell(`${apollo} feature get ${P} -a ${assemblyId} -r ${refSeqId} -s 91 -e 100`);
705
+ out = JSON.parse(p.stdout);
706
+ feature = out.at(0);
707
+ const keys = Object.keys(feature?.children);
708
+ assert.strictEqual(keys.length, 1);
709
+ assert.strictEqual(feature.children[keys[0]].attributes.key2[0], 'val2');
710
+ });
519
711
  void globalThis.itName('Add child features', () => {
520
712
  new Shell(`${apollo} assembly add-from-gff ${P} test_data/tiny.fasta.gff3 -a vv1 -f`);
521
713
  let p = new Shell(`${apollo} feature search ${P} -a vv1 -t contig`);