@fleetkeep/cli 0.8.0 → 0.9.0

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 CHANGED
@@ -26,6 +26,40 @@ opening a browser or starting an interactive login. `--no-browser` prints links
26
26
  for you to open yourself. Setup never starts a subscription, imports records or
27
27
  enables reminders on its own. It requires the matching setup API deployment.
28
28
 
29
+ ## Spreadsheet imports
30
+
31
+ CLI 0.9.0 supports files containing up to 1,000 vehicle rows when the matching
32
+ backend is deployed. The account's existing vehicle allowance still applies.
33
+ The preview checks the whole selection's unique new registrations against the
34
+ available capacity before the first batch is saved.
35
+
36
+ ```sh
37
+ fleetkeep import preview vehicles.csv --selected-rows '[0,3,7]'
38
+ fleetkeep import apply vehicles.csv --selected-rows '[0,3,7]' --confirm <digest-from-preview>
39
+ ```
40
+
41
+ `--selected-rows` is a JSON array of original zero-based data-row indices,
42
+ excluding the header. Repeat the same selection on preview and apply. Omit it
43
+ to import all rows. The CLI rejects a changed file locally before contacting
44
+ the API, then obtains fresh preview metadata and saves serial batches of five.
45
+ Original spreadsheet line numbers remain attached to each outcome.
46
+
47
+ Progress goes to stderr; `--json` keeps the result machine-readable on stdout.
48
+ If a batch cannot be confirmed, the command exits unsuccessfully and reports
49
+ only the earlier confirmed outcomes. It never silently skips rows or retries an
50
+ uncertain write. A confirmed provider-throttling outcome also stops further
51
+ batches. Only an explicit pre-write HTTP 429 rejection is retried, at most twice,
52
+ using its bounded `Retry-After` delay.
53
+
54
+ An explicit rerun of the identical file and selection uses stable batch keys;
55
+ keep the same `--idempotency-key` if you supplied one. Replayed batches are
56
+ reported separately from new results. Confirmed provider failures are stored too:
57
+ review their outcomes and use a **new** `--idempotency-key` to deliberately retry
58
+ confirmed failed rows. The same key returns the stored failures without another
59
+ provider lookup. Keep the existing key when resolving an uncertain write. Older backends support the existing
60
+ all-row import for files of at most 50 rows; selected or larger imports require
61
+ the upgraded backend.
62
+
29
63
  ## Shared jobs and running costs
30
64
 
31
65
  Friendly commands return JSON, including missing-data and stale-tracking fields:
@@ -193,4 +227,12 @@ retry with the same key returns the original record instead of creating a duplic
193
227
 
194
228
  The private email-forwarding address requires `tolls:write`, including when reading it: anyone holding that address can submit statements. Read-only integrations do not receive it.
195
229
 
196
- CLI toll statement imports accept CSV files up to 256 KiB, the published API limit. Email forwarding accepts up to five CSV attachments of 512 KiB each. Preview and confirm the exact file digest before a CLI import is applied.
230
+ CLI toll statement imports accept CSV files up to 256 KiB, the published API limit. Email forwarding accepts up to five PDF or CSV attachments of 512 KiB each. PDF charges always wait for approval. Preview and confirm the exact file digest before a CLI import is applied.
231
+
232
+ ### PDF invoices
233
+
234
+ Forward the original PDF invoice to the account’s private forwarding address, or upload it in Toll Control. PDF charges wait for review before saving. CSV statements remain supported. Text-based GBP PDFs from recognised UK toll providers are supported; scanned, encrypted or ambiguous documents return an actionable error rather than guessed charges.
235
+
236
+ `fleetkeep api call listTollPdfReviews` lists the signed-in account’s extracted rows and confirmation digests. Check the rows against the original invoice, then use `fleetkeep api call confirmTollPdfReview --data-file reviewed-pdf.json` with `batchId`, `confirmDigest`, `csv` and `confirmed: true`. The CSV is the reviewed structured data, not a file the customer needs to prepare. Keep the digest from the current review. Repeat confirmation is idempotent; a different account cannot read or confirm the invoice.
237
+
238
+ These operations require a CLI release containing the PDF operation definitions. The backend candidate must be deployed before use.
package/fleetkeep.mjs CHANGED
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto';
5
5
  import { basename, extname, resolve } from 'node:path';
6
6
  import { runApiCommand } from './api-command.mjs';
7
7
  import { runSetup } from './setup.mjs';
8
+ import { applySpreadsheetBatches, parseSelectedRows } from './import-batches.mjs';
8
9
  import { authStatus, getAccessToken, login, logout } from './auth.mjs';
9
10
 
10
11
  const DEFAULT_API_URL = 'https://fleetkeep.co.uk';
@@ -38,8 +39,8 @@ Usage:
38
39
  fleetkeep jobs save <reference> --title <title> [--json]
39
40
  fleetkeep jobs close <job-id> [--json]
40
41
  fleetkeep jobs reopen <job-id> [--json]
41
- fleetkeep import preview <file.csv|file.xlsx> [--json]
42
- fleetkeep import apply <file.csv|file.xlsx> --confirm <digest> [--idempotency-key <key>] [--json]
42
+ fleetkeep import preview <file.csv|file.xlsx> [--selected-rows <json>] [--json]
43
+ fleetkeep import apply <file.csv|file.xlsx> --confirm <digest> [--selected-rows <json>] [--idempotency-key <key>] [--json]
43
44
  fleetkeep tolls forwarding [--json]
44
45
  fleetkeep tolls forwarding enable [--json]
45
46
  fleetkeep tolls import preview <file.csv> [--json]
@@ -88,11 +89,12 @@ OAuth access and refresh tokens are never accepted as command arguments.`;
88
89
  }
89
90
 
90
91
  class FleetkeepApiError extends Error {
91
- constructor(status, payload) {
92
+ constructor(status, payload, retryAfter) {
92
93
  const code = typeof payload?.error === 'string' ? payload.error : `HTTP ${status}`;
93
94
  super(`Fleetkeep API rejected the request (${status}): ${code}`);
94
95
  this.status = status;
95
96
  this.payload = payload;
97
+ this.retryAfter = retryAfter;
96
98
  }
97
99
  }
98
100
 
@@ -255,7 +257,7 @@ async function request(path, { method = 'GET', body, headers: extraHeaders = {}
255
257
  ? null
256
258
  : await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
257
259
  if (!response.ok) {
258
- throw new FleetkeepApiError(response.status, payload);
260
+ throw new FleetkeepApiError(response.status, payload, response.headers.get('retry-after'));
259
261
  }
260
262
  return payload;
261
263
  }
@@ -406,7 +408,7 @@ async function spreadsheetPayload(fileArg) {
406
408
  : { sourceName, contentBase64: bytes.toString('base64') };
407
409
  }
408
410
 
409
- function printPreview(payload, jsonOutput, fileArg) {
411
+ function printPreview(payload, jsonOutput, fileArg, selectedRows) {
410
412
  if (jsonOutput) {
411
413
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
412
414
  return;
@@ -420,15 +422,22 @@ function printPreview(payload, jsonOutput, fileArg) {
420
422
  `Valid registrations: ${p.validRegistrations}`,
421
423
  `Invalid registrations: ${p.invalidRegistrations}`,
422
424
  `Duplicate registrations: ${p.duplicateRegistrations}`,
423
- `Over self-serve limit: ${p.overSelfServeLimit ? 'yes' : 'no'}`,
425
+ ...(payload.batching ? [`Selected rows: ${payload.batching.totalRows}`] : []),
426
+ ...(payload.capacity
427
+ ? [
428
+ payload.capacity.canImport
429
+ ? 'Account capacity: available for this selection'
430
+ : `Account capacity: ${payload.capacity.error || 'selection exceeds allowance'}`
431
+ ]
432
+ : [`Over self-serve limit: ${p.overSelfServeLimit ? 'yes' : 'no'}`]),
424
433
  '',
425
434
  'No data was changed. To apply this exact file:',
426
- `fleetkeep import apply ${JSON.stringify(fileArg)} --confirm ${payload.digest}`
435
+ `fleetkeep import apply ${JSON.stringify(fileArg)} --confirm ${payload.digest}${selectedRows ? ` --selected-rows '${JSON.stringify(selectedRows)}'` : ''}`
427
436
  ].join('\n') + '\n'
428
437
  );
429
438
  }
430
439
 
431
- function printImport(payload, jsonOutput) {
440
+ function printImport(payload, jsonOutput, completed = true) {
432
441
  if (jsonOutput) {
433
442
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
434
443
  return;
@@ -436,11 +445,19 @@ function printImport(payload, jsonOutput) {
436
445
  const summary = payload.summary;
437
446
  process.stdout.write(
438
447
  [
439
- payload.replayed ? 'Import result replayed from the original request.' : 'Import completed.',
448
+ !completed
449
+ ? 'Import interrupted. Confirmed results:'
450
+ : payload.replayed
451
+ ? 'Import result replayed from the original request.'
452
+ : 'Import completed.',
453
+ ...(payload.replayedBatches && !payload.replayed
454
+ ? [`Previously confirmed batches: ${payload.replayedBatches}`]
455
+ : []),
440
456
  `Added: ${summary.added}`,
441
457
  `Existing vehicles updated with equipment: ${summary.updated}`,
442
458
  `Duplicates or unchanged: ${summary.duplicates}`,
443
459
  `Invalid: ${summary.invalid}`,
460
+ ...(summary.dvlaErrors !== undefined ? [`DVLA errors: ${summary.dvlaErrors}`] : []),
444
461
  `Database errors: ${summary.dbErrors}`
445
462
  ].join('\n') + '\n'
446
463
  );
@@ -452,7 +469,8 @@ function printImport(payload, jsonOutput) {
452
469
  function printTollForwarding(payload, jsonOutput) {
453
470
  const lines = [
454
471
  `Email forwarding: ${payload.available === false ? 'not available' : payload.enabled ? 'enabled' : 'not enabled'}`,
455
- ...(payload.address ? [`Forward CSV statements to: ${payload.address}`] : []),
472
+ ...(payload.address ? [`Forward PDF invoices or CSV statements to: ${payload.address}`] : []),
473
+ 'PDF charges wait for review: fleetkeep api call listTollPdfReviews',
456
474
  `Inbox checked every ${payload.pollIntervalMinutes.toLocaleString('en-GB')} minutes.`,
457
475
  ...(payload.enabled || payload.available === false
458
476
  ? []
@@ -803,11 +821,12 @@ async function main() {
803
821
 
804
822
  if (args[0] === 'import' && args[1] === 'preview') {
805
823
  const body = await spreadsheetPayload(args[2]);
824
+ const selectedRows = parseSelectedRows(requiredValue(args, '--selected-rows'));
806
825
  const payload = await request('/api/v1/imports/vehicles/preview', {
807
826
  method: 'POST',
808
- body
827
+ body: { ...body, ...(selectedRows ? { selectedRows } : {}) }
809
828
  });
810
- printPreview(payload, jsonOutput, args[2]);
829
+ printPreview(payload, jsonOutput, args[2], selectedRows);
811
830
  return;
812
831
  }
813
832
 
@@ -819,12 +838,25 @@ async function main() {
819
838
  );
820
839
  }
821
840
  const body = await spreadsheetPayload(args[2]);
822
- const idempotencyKey = option(args, '--idempotency-key') || `cli:${confirmDigest}`;
823
- const payload = await request('/api/v1/imports/vehicles/apply', {
824
- method: 'POST',
825
- body: { ...body, confirmDigest, idempotencyKey }
841
+ const idempotencyKey = requiredValue(args, '--idempotency-key') ?? `cli:${confirmDigest}`;
842
+ const selectedRows = parseSelectedRows(requiredValue(args, '--selected-rows'));
843
+ const applied = await applySpreadsheetBatches({
844
+ body,
845
+ confirmDigest,
846
+ idempotencyKey,
847
+ selectedRows,
848
+ request,
849
+ progress: (done, total) =>
850
+ process.stderr.write(
851
+ `Confirmed ${done.toLocaleString('en-GB')} of ${total.toLocaleString('en-GB')} rows.\n`
852
+ )
826
853
  });
827
- printImport(payload, jsonOutput);
854
+ if (applied.error && jsonOutput)
855
+ process.stdout.write(
856
+ `${JSON.stringify({ ...applied.result, completed: false, error: applied.error, confirmedRows: applied.confirmedRows, totalRows: applied.totalRows }, null, 2)}\n`
857
+ );
858
+ else if (applied.result) printImport(applied.result, jsonOutput, !applied.error);
859
+ if (applied.error) fail(applied.error);
828
860
  return;
829
861
  }
830
862
 
@@ -0,0 +1,267 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+
4
+ const summaryKeys = [
5
+ 'total',
6
+ 'added',
7
+ 'updated',
8
+ 'duplicates',
9
+ 'invalid',
10
+ 'dvlaErrors',
11
+ 'dbErrors'
12
+ ];
13
+ const countsByStatus = {
14
+ added: 'added',
15
+ updated: 'updated',
16
+ duplicate: 'duplicates',
17
+ invalid_reg: 'invalid',
18
+ invalid_data: 'invalid',
19
+ dvla_error: 'dvlaErrors',
20
+ db_error: 'dbErrors'
21
+ };
22
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
23
+ const same = (a, b) => Array.isArray(a) && JSON.stringify(a) === JSON.stringify(b);
24
+
25
+ export function parseSelectedRows(value) {
26
+ if (value === null || value === undefined) return undefined;
27
+ let rows;
28
+ try {
29
+ rows = JSON.parse(value);
30
+ } catch {
31
+ throw new Error('--selected-rows requires a JSON array of original zero-based row indices.');
32
+ }
33
+ if (
34
+ !Array.isArray(rows) ||
35
+ !rows.length ||
36
+ rows.length > 1000 ||
37
+ rows.some((row) => !Number.isInteger(row) || row < 0 || row >= 1000) ||
38
+ new Set(rows).size !== rows.length
39
+ ) {
40
+ throw new Error('--selected-rows requires unique original row indices from 0 to 999.');
41
+ }
42
+ return rows.sort((a, b) => a - b);
43
+ }
44
+
45
+ export function spreadsheetDigest(body) {
46
+ return hash(
47
+ typeof body.csv === 'string'
48
+ ? Buffer.from(body.csv, 'utf8')
49
+ : Buffer.from(body.contentBase64, 'base64')
50
+ );
51
+ }
52
+
53
+ function retryMilliseconds(value, now) {
54
+ if (typeof value !== 'string' || !value.trim()) return null;
55
+ const seconds = /^\d+(?:\.\d+)?$/.test(value.trim())
56
+ ? Number(value)
57
+ : (Date.parse(value) - now) / 1000;
58
+ return Number.isFinite(seconds) && seconds >= 0 && seconds <= 300
59
+ ? Math.ceil(seconds * 1000)
60
+ : null;
61
+ }
62
+
63
+ async function quotaRequest(request, path, options, wait, now) {
64
+ for (let retries = 0; ; retries++) {
65
+ try {
66
+ return await request(path, options);
67
+ } catch (error) {
68
+ const milliseconds = retryMilliseconds(error.retryAfter, now());
69
+ if (
70
+ error.status !== 429 ||
71
+ error.payload?.error !== 'rate_limited' ||
72
+ retries >= 2 ||
73
+ milliseconds === null
74
+ )
75
+ throw error;
76
+ await wait(milliseconds);
77
+ }
78
+ }
79
+ }
80
+
81
+ function checkPreview(preview, digest, selectedRows) {
82
+ if (preview?.digest !== digest)
83
+ throw new Error('Server preview does not match the confirmed file. No import was started.');
84
+ const batch = preview.batching;
85
+ if (!batch) {
86
+ const count = preview.preview?.detectedVehicleCount;
87
+ if (selectedRows || !Number.isInteger(count) || count < 1 || count > 50) {
88
+ throw new Error(
89
+ 'This server does not support the required batched/selected import. Update the backend before applying.'
90
+ );
91
+ }
92
+ return null;
93
+ }
94
+ const count = batch.totalRows;
95
+ const originalCount = preview.preview?.detectedVehicleCount;
96
+ if (
97
+ batch.batchSize !== 5 ||
98
+ !Number.isInteger(count) ||
99
+ count < 1 ||
100
+ count > 1000 ||
101
+ !Number.isInteger(originalCount) ||
102
+ originalCount < count ||
103
+ originalCount > 1000 ||
104
+ !Array.isArray(batch.sourceRows) ||
105
+ batch.sourceRows.length !== count ||
106
+ batch.sourceRows.some(
107
+ (row, i, rows) =>
108
+ !Number.isInteger(row) || row < 0 || row >= originalCount || (i > 0 && row <= rows[i - 1])
109
+ ) ||
110
+ !Array.isArray(batch.sourceLines) ||
111
+ batch.sourceLines.length !== count ||
112
+ batch.sourceLines.some(
113
+ (line, i, lines) => !Number.isInteger(line) || line < 1 || (i > 0 && line <= lines[i - 1])
114
+ ) ||
115
+ !/^[a-f0-9]{64}$/.test(preview.selectionDigest ?? '') ||
116
+ !same(batch.sourceRows, selectedRows ?? Array.from({ length: originalCount }, (_, i) => i))
117
+ ) {
118
+ throw new Error(
119
+ 'Import preview has an invalid or incomplete selected-row envelope. No import was started.'
120
+ );
121
+ }
122
+ return batch;
123
+ }
124
+
125
+ function checkBatch(result, preview, offset) {
126
+ const expected = preview.batching;
127
+ const rows = expected.sourceRows.slice(offset, offset + 5);
128
+ const lines = expected.sourceLines.slice(offset, offset + 5);
129
+ const next = offset + rows.length < expected.totalRows ? offset + rows.length : null;
130
+ const batch = result?.batch;
131
+ if (
132
+ !batch ||
133
+ batch.offset !== offset ||
134
+ batch.batchSize !== 5 ||
135
+ batch.totalRows !== expected.totalRows ||
136
+ batch.selectionDigest !== preview.selectionDigest ||
137
+ batch.nextOffset !== next ||
138
+ !same(batch.sourceRows, rows) ||
139
+ !same(batch.sourceLines, lines) ||
140
+ !Array.isArray(result.outcomes) ||
141
+ result.outcomes.length !== rows.length ||
142
+ result.outcomes.some((row) => !row || typeof row !== 'object' || !Number.isInteger(row.line)) ||
143
+ !same(
144
+ result.outcomes.map((row) => row.line).sort((a, b) => a - b),
145
+ lines
146
+ ) ||
147
+ (result.replayed !== undefined && typeof result.replayed !== 'boolean')
148
+ ) {
149
+ throw new Error(
150
+ 'Import batch response is incomplete or belongs to another row selection. Its result is unconfirmed; stopped without advancing.'
151
+ );
152
+ }
153
+ const counts = Object.fromEntries(summaryKeys.map((key) => [key, 0]));
154
+ counts.total = rows.length;
155
+ for (const row of result.outcomes) {
156
+ const key = Object.hasOwn(countsByStatus, row.status) ? countsByStatus[row.status] : null;
157
+ if (!key)
158
+ throw new Error(
159
+ 'Import batch contains an unknown outcome. Its result is unconfirmed; stopped without advancing.'
160
+ );
161
+ counts[key]++;
162
+ }
163
+ if (summaryKeys.some((key) => result.summary?.[key] !== counts[key])) {
164
+ throw new Error(
165
+ 'Import batch summary does not account for its rows. Its result is unconfirmed; stopped without advancing.'
166
+ );
167
+ }
168
+ }
169
+
170
+ export async function applySpreadsheetBatches({
171
+ body,
172
+ confirmDigest,
173
+ idempotencyKey,
174
+ selectedRows,
175
+ request,
176
+ progress = () => {},
177
+ wait = delay,
178
+ now = Date.now
179
+ }) {
180
+ if (!/^[a-f0-9]{64}$/.test(confirmDigest ?? '') || spreadsheetDigest(body) !== confirmDigest) {
181
+ throw new Error('The file does not match --confirm. Preview this file again before applying.');
182
+ }
183
+ const source = { ...body, ...(selectedRows ? { selectedRows } : {}) };
184
+ let result = null;
185
+ let confirmedRows = 0;
186
+ let totalRows = null;
187
+ try {
188
+ const preview = await quotaRequest(
189
+ request,
190
+ '/api/v1/imports/vehicles/preview',
191
+ { method: 'POST', body: source },
192
+ wait,
193
+ now
194
+ );
195
+ const batch = checkPreview(preview, confirmDigest, selectedRows);
196
+ if ((batch || preview.capacity) && preview.capacity?.canImport !== true) {
197
+ throw new Error(
198
+ preview.capacity?.error ||
199
+ 'This selected import exceeds the account vehicle allowance. Review the account before applying.'
200
+ );
201
+ }
202
+ if (!batch) {
203
+ result = await quotaRequest(
204
+ request,
205
+ '/api/v1/imports/vehicles/apply',
206
+ {
207
+ method: 'POST',
208
+ body: { ...source, confirmDigest, idempotencyKey }
209
+ },
210
+ wait,
211
+ now
212
+ );
213
+ return { result, error: null };
214
+ }
215
+ totalRows = batch.totalRows;
216
+ for (let offset = 0; offset < totalRows; offset += 5) {
217
+ const key = `cli:batch:${hash(JSON.stringify([idempotencyKey, confirmDigest, preview.selectionDigest, offset]))}`;
218
+ const applied = await quotaRequest(
219
+ request,
220
+ '/api/v1/imports/vehicles/apply',
221
+ {
222
+ method: 'POST',
223
+ body: {
224
+ ...source,
225
+ confirmDigest,
226
+ confirmSelectionDigest: preview.selectionDigest,
227
+ offset,
228
+ idempotencyKey: key
229
+ }
230
+ },
231
+ wait,
232
+ now
233
+ );
234
+ checkBatch(applied, preview, offset);
235
+ if (!result) {
236
+ const { batch: _batch, ...rest } = applied;
237
+ result = {
238
+ ...rest,
239
+ summary: { ...applied.summary },
240
+ outcomes: [...applied.outcomes],
241
+ replayed: Boolean(applied.replayed),
242
+ replayedBatches: applied.replayed ? 1 : 0
243
+ };
244
+ } else {
245
+ for (const key of summaryKeys) result.summary[key] += applied.summary[key];
246
+ result.outcomes.push(...applied.outcomes);
247
+ result.replayed &&= Boolean(applied.replayed);
248
+ result.replayedBatches += applied.replayed ? 1 : 0;
249
+ }
250
+ confirmedRows += applied.outcomes.length;
251
+ progress(confirmedRows, totalRows);
252
+ if (applied.outcomes.some((row) => row.code === 'rate_limited')) {
253
+ throw new Error(
254
+ 'DVLA is limiting requests. Review the confirmed outcomes. To retry confirmed failed rows later, use a NEW --idempotency-key; the same key replays the stored result.'
255
+ );
256
+ }
257
+ }
258
+ return { result, error: null, confirmedRows, totalRows };
259
+ } catch (error) {
260
+ return {
261
+ result,
262
+ error: `${error instanceof Error ? error.message : 'Import interrupted.'} Confirmed earlier results are kept. No uncertain write was retried.`,
263
+ confirmedRows,
264
+ totalRows
265
+ };
266
+ }
267
+ }
package/operations.mjs CHANGED
@@ -300,6 +300,8 @@ const definitions = [
300
300
  'tolls:write',
301
301
  { idempotency: true }
302
302
  ],
303
+ ['listTollPdfReviews', 'GET', '/api/v1/tolls/pdf-reviews', 'tolls:read'],
304
+ ['confirmTollPdfReview', 'POST', '/api/v1/tolls/pdf-reviews', 'tolls:write'],
303
305
  ['getTollForwarding', 'GET', '/api/v1/tolls/forwarding', 'tolls:write'],
304
306
  ['enableTollForwarding', 'POST', '/api/v1/tolls/forwarding', 'tolls:write'],
305
307
  ['previewTollImport', 'POST', '/api/v1/tolls/imports/preview', 'tolls:read'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetkeep/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Operate every published Fleetkeep API workflow with guarded writes and machine-readable output",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "api-command.mjs",
11
11
  "auth.mjs",
12
12
  "fleetkeep.mjs",
13
+ "import-batches.mjs",
13
14
  "operations.mjs",
14
15
  "setup.mjs",
15
16
  "README.md"