@fleetkeep/cli 0.8.1 → 0.10.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:
@@ -60,6 +94,32 @@ not enter them again. Account JSON exports include jobs, journeys and additional
60
94
  Xero authorisation and bill review take place in the web portal. Saved SHVL grants and
61
95
  credentials are not transferred between products or customer workspaces.
62
96
 
97
+ ## Daily defects and notification preferences
98
+
99
+ With CLI 0.10.0 and the matching API deployment:
100
+
101
+ ```sh
102
+ fleetkeep api call getNotificationPreferences
103
+ fleetkeep api call getWalkaroundSummary
104
+ fleetkeep api call listDefects
105
+ ```
106
+
107
+ The notification overview reads renewal reminder settings, walkaround defect email mode
108
+ (`all`, `off_road` or `off`) and the count of enabled push-device registrations. It never
109
+ changes preferences or sends an alert. Change choices in Fleetkeep's Notifications centre.
110
+ An enabled registration does not prove OS permission or delivery. No device tokens are returned.
111
+
112
+ For daily-check follow-up, read today's summary and the reported defects before preparing a
113
+ maintenance task or completing a defect. A completed check is not a roadworthiness certificate,
114
+ and marking a defect complete requires the operator's actual repair details and approval.
115
+ The published API's confirmation, account and permission checks still apply.
116
+
117
+ The expanded API MCP connector uses the same operation catalogue and can read this overview
118
+ when its review/account access and `reminders:read` scope permit it. The public read-only `/mcp`
119
+ connection remains limited to its three fleet brief/search/detail tools. Household sharing and
120
+ team administration stay in the secure app/web flows; this command does not invite people or
121
+ change access. The n8n package has its separate vehicles/maintenance contract.
122
+
63
123
  ## Service intervals and equipment
64
124
 
65
125
  Record completed servicing by registration without looking up an internal vehicle ID first:
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/operations.mjs CHANGED
@@ -62,6 +62,7 @@ const definitions = [
62
62
  ['getDriverActivity', 'GET', '/api/v1/drivers/activity', 'operations:read', { query: ['q'] }],
63
63
  ['listDriverMemberships', 'GET', '/api/v1/driver-workspace', 'vehicles:read'],
64
64
  ['getDriverWorkspace', 'GET', '/api/v1/driver-workspace/{id}', 'operations:read'],
65
+ ['getNotificationPreferences', 'GET', '/api/v1/settings/notification-preferences', 'reminders:read'],
65
66
  ['getPushNotificationStatus', 'GET', '/api/v1/settings/push-notifications', 'reminders:read'],
66
67
  ['importProviderJobs', 'POST', '/api/v1/jobs/import', 'operations:write'],
67
68
  ['listFleetJobs', 'GET', '/api/v1/jobs', 'operations:read', { query: ['q'] }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetkeep/cli",
3
- "version": "0.8.1",
3
+ "version": "0.10.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"