@fleetkeep/cli 0.8.1 → 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:
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
  );
@@ -804,11 +821,12 @@ async function main() {
804
821
 
805
822
  if (args[0] === 'import' && args[1] === 'preview') {
806
823
  const body = await spreadsheetPayload(args[2]);
824
+ const selectedRows = parseSelectedRows(requiredValue(args, '--selected-rows'));
807
825
  const payload = await request('/api/v1/imports/vehicles/preview', {
808
826
  method: 'POST',
809
- body
827
+ body: { ...body, ...(selectedRows ? { selectedRows } : {}) }
810
828
  });
811
- printPreview(payload, jsonOutput, args[2]);
829
+ printPreview(payload, jsonOutput, args[2], selectedRows);
812
830
  return;
813
831
  }
814
832
 
@@ -820,12 +838,25 @@ async function main() {
820
838
  );
821
839
  }
822
840
  const body = await spreadsheetPayload(args[2]);
823
- const idempotencyKey = option(args, '--idempotency-key') || `cli:${confirmDigest}`;
824
- const payload = await request('/api/v1/imports/vehicles/apply', {
825
- method: 'POST',
826
- 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
+ )
827
853
  });
828
- 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);
829
860
  return;
830
861
  }
831
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetkeep/cli",
3
- "version": "0.8.1",
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"