@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 +60 -0
- package/fleetkeep.mjs +47 -16
- package/import-batches.mjs +267 -0
- package/operations.mjs +1 -0
- package/package.json +2 -1
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
|
-
`
|
|
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
|
-
|
|
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 =
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
body
|
|
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
|
-
|
|
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.
|
|
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"
|