@fleetkeep/cli 0.6.0 → 0.7.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 +40 -0
- package/auth.mjs +1 -1
- package/fleetkeep.mjs +138 -0
- package/operations.mjs +23 -1
- package/package.json +2 -1
- package/setup.mjs +94 -0
package/README.md
CHANGED
|
@@ -5,6 +5,27 @@ API. Friendly commands cover common vehicle and spreadsheet work. The allowliste
|
|
|
5
5
|
command covers equipment, maintenance, defects, walkarounds, files, reminders, contacts, bookings,
|
|
6
6
|
tolls, tracking, guidance and exports without opening arbitrary or unpublished paths.
|
|
7
7
|
|
|
8
|
+
## Start here
|
|
9
|
+
|
|
10
|
+
Run `npx @fleetkeep/cli setup` after installation. It signs you in through your
|
|
11
|
+
browser, checks your existing account and shows the next step. Return to the
|
|
12
|
+
terminal after each website step and refresh to see the current status.
|
|
13
|
+
|
|
14
|
+
Choose your intended fleet size on the website. Eligible new accounts can start
|
|
15
|
+
a 30-day trial without a card; the price is shown before confirmation. Existing
|
|
16
|
+
customers keep their account and subscription. App-store subscribers should
|
|
17
|
+
contact Fleetkeep about Fleet access before purchasing a separate web plan.
|
|
18
|
+
|
|
19
|
+
Once Fleet access is active, use `fleetkeep import preview "fleet.xlsx"` to
|
|
20
|
+
review a spreadsheet before saving. The preview supplies the exact confirmation
|
|
21
|
+
command. Review missing dates, reminder timing and recipients on the dashboard.
|
|
22
|
+
Add a card only through the website after checking the first payment date.
|
|
23
|
+
|
|
24
|
+
`fleetkeep setup --json` returns the current setup state for automation without
|
|
25
|
+
opening a browser or starting an interactive login. `--no-browser` prints links
|
|
26
|
+
for you to open yourself. Setup never starts a subscription, imports records or
|
|
27
|
+
enables reminders on its own. It requires the matching setup API deployment.
|
|
28
|
+
|
|
8
29
|
## Shared jobs and running costs
|
|
9
30
|
|
|
10
31
|
Friendly commands return JSON, including missing-data and stale-tracking fields:
|
|
@@ -41,6 +62,18 @@ credentials are not transferred between products or customer workspaces.
|
|
|
41
62
|
|
|
42
63
|
## Service intervals and equipment
|
|
43
64
|
|
|
65
|
+
Record completed servicing by registration without looking up an internal vehicle ID first:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
fleetkeep service record "BD15 KFG" --date 2026-09-27 --mileage 48212 \
|
|
69
|
+
--work "Scheduled service"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Fleetkeep matches the exact normalised registration and shows the proposed record. Nothing is
|
|
73
|
+
saved until the operator repeats the same command with the short-lived confirmation token. The
|
|
74
|
+
CLI derives a stable idempotency key from the matched vehicle and record, so an identical retry
|
|
75
|
+
returns the original result rather than creating a duplicate.
|
|
76
|
+
|
|
44
77
|
Set one service interval in days, weeks or months. The vehicle command clears the
|
|
45
78
|
alternative unit when switching, and converts weeks to days:
|
|
46
79
|
|
|
@@ -115,6 +148,9 @@ fleetkeep api call listTrackingSnapshots
|
|
|
115
148
|
fleetkeep api call getWexRouteHistory --path vehicleId=<vehicle-id> --query period=7d
|
|
116
149
|
fleetkeep api call createServiceRecord --path id=<vehicle-id> \
|
|
117
150
|
--idempotency-key <unique-value> --data-file service-record.json
|
|
151
|
+
fleetkeep api call createServiceRecord --path id=<vehicle-id> \
|
|
152
|
+
--idempotency-key <same-unique-value> --confirmation <server-token> \
|
|
153
|
+
--data-file service-record.json
|
|
118
154
|
fleetkeep api call uploadVehiclePhoto --path id=<vehicle-id> --file photo=van.jpg
|
|
119
155
|
fleetkeep api call downloadFleetSpreadsheet --output fleetkeep-export.xlsx
|
|
120
156
|
```
|
|
@@ -128,3 +164,7 @@ Set `FLEETKEEP_API_URL` to an alternate origin for local or staging verification
|
|
|
128
164
|
automation, set `FLEETKEEP_ACCESS_TOKEN` in the process environment. OAuth access and refresh tokens
|
|
129
165
|
are never accepted in command arguments. Short-lived action confirmations are supplied separately
|
|
130
166
|
with `--confirmation`.
|
|
167
|
+
|
|
168
|
+
Service-record creation is a two-step operator-confirmed action. The first call returns a 409
|
|
169
|
+
challenge. Retry the identical body and idempotency key with its `--confirmation` token. A safe
|
|
170
|
+
retry with the same key returns the original record instead of creating a duplicate.
|
package/auth.mjs
CHANGED
package/fleetkeep.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
4
5
|
import { basename, extname, resolve } from 'node:path';
|
|
5
6
|
import { runApiCommand } from './api-command.mjs';
|
|
7
|
+
import { runSetup } from './setup.mjs';
|
|
6
8
|
import { authStatus, getAccessToken, login, logout } from './auth.mjs';
|
|
7
9
|
|
|
8
10
|
const DEFAULT_API_URL = 'https://fleetkeep.co.uk';
|
|
@@ -11,6 +13,7 @@ function usage() {
|
|
|
11
13
|
return `Fleetkeep CLI
|
|
12
14
|
|
|
13
15
|
Usage:
|
|
16
|
+
fleetkeep setup [--no-browser] [--json]
|
|
14
17
|
fleetkeep auth login [--no-browser]
|
|
15
18
|
fleetkeep auth status
|
|
16
19
|
fleetkeep auth logout
|
|
@@ -20,6 +23,8 @@ Usage:
|
|
|
20
23
|
fleetkeep vehicles update <vehicle-id> [vehicle options] [--clear <field,...>] [--json]
|
|
21
24
|
fleetkeep vehicles delete <vehicle-id> [--confirmation <server-token>] [--json]
|
|
22
25
|
fleetkeep vehicles bulk-add <registration>... [--json]
|
|
26
|
+
fleetkeep service record <registration> --date <YYYY-MM-DD> --mileage <integer> --work <text>
|
|
27
|
+
[--supplier <text>] [--invoice <text>] [--confirmation <server-token>] [--json]
|
|
23
28
|
fleetkeep drivers list [--json]
|
|
24
29
|
fleetkeep drivers activity [--search <job-driver-registration>] [--json]
|
|
25
30
|
fleetkeep costs list [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--json]
|
|
@@ -260,6 +265,129 @@ function printJsonOr(payload, jsonOutput, line) {
|
|
|
260
265
|
process.stdout.write(jsonOutput ? `${JSON.stringify(payload, null, 2)}\n` : `${line}\n`);
|
|
261
266
|
}
|
|
262
267
|
|
|
268
|
+
function normaliseRegistration(value) {
|
|
269
|
+
return value.toUpperCase().replace(/\s+/g, '');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function serviceRecordBody(args) {
|
|
273
|
+
const allowed = new Set([
|
|
274
|
+
'--date',
|
|
275
|
+
'--mileage',
|
|
276
|
+
'--work',
|
|
277
|
+
'--supplier',
|
|
278
|
+
'--invoice',
|
|
279
|
+
'--notes',
|
|
280
|
+
'--next-date',
|
|
281
|
+
'--next-mileage',
|
|
282
|
+
'--confirmation',
|
|
283
|
+
'--json'
|
|
284
|
+
]);
|
|
285
|
+
for (const arg of args) {
|
|
286
|
+
if (arg.startsWith('--') && !allowed.has(arg)) throw new Error(`Unknown option: ${arg}.`);
|
|
287
|
+
}
|
|
288
|
+
const servicedAt = option(args, '--date');
|
|
289
|
+
const mileage = option(args, '--mileage');
|
|
290
|
+
const workSummary = option(args, '--work');
|
|
291
|
+
if (!servicedAt) throw new Error('--date is required.');
|
|
292
|
+
parseVehicleValue('--date', servicedAt, { type: 'date' });
|
|
293
|
+
if (!mileage) throw new Error('--mileage is required.');
|
|
294
|
+
const odometerMileage = parseVehicleValue('--mileage', mileage, {
|
|
295
|
+
type: 'integer',
|
|
296
|
+
min: 0,
|
|
297
|
+
max: 1500000
|
|
298
|
+
});
|
|
299
|
+
if (!workSummary) throw new Error('--work is required.');
|
|
300
|
+
const body = { servicedAt, odometerMileage, workSummary };
|
|
301
|
+
for (const [name, field] of [
|
|
302
|
+
['--supplier', 'supplier'],
|
|
303
|
+
['--invoice', 'invoiceReference'],
|
|
304
|
+
['--notes', 'notes'],
|
|
305
|
+
['--next-date', 'nextServiceDueDate']
|
|
306
|
+
]) {
|
|
307
|
+
const value = option(args, name);
|
|
308
|
+
if (value) {
|
|
309
|
+
if (name === '--next-date') parseVehicleValue(name, value, { type: 'date' });
|
|
310
|
+
body[field] = value;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const nextMileage = option(args, '--next-mileage');
|
|
314
|
+
if (nextMileage) {
|
|
315
|
+
body.nextServiceDueMileage = parseVehicleValue('--next-mileage', nextMileage, {
|
|
316
|
+
type: 'integer',
|
|
317
|
+
min: 0,
|
|
318
|
+
max: 1500000
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return body;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function recordServiceByRegistration(registration, args, jsonOutput) {
|
|
325
|
+
if (!registration || registration.startsWith('--'))
|
|
326
|
+
throw new Error('A registration is required.');
|
|
327
|
+
const body = serviceRecordBody(args);
|
|
328
|
+
const listed = await request('/api/v1/vehicles', {});
|
|
329
|
+
const wanted = normaliseRegistration(registration);
|
|
330
|
+
const matches = (listed.vehicles || []).filter(
|
|
331
|
+
(vehicle) => normaliseRegistration(vehicle.registration || '') === wanted
|
|
332
|
+
);
|
|
333
|
+
if (matches.length === 0) throw new Error(`No vehicle matched registration ${registration}.`);
|
|
334
|
+
if (matches.length > 1)
|
|
335
|
+
throw new Error(`More than one vehicle matched registration ${registration}.`);
|
|
336
|
+
const vehicle = matches[0];
|
|
337
|
+
const idempotencyKey = `service:${createHash('sha256')
|
|
338
|
+
.update(JSON.stringify({ vehicleId: vehicle.id, body }))
|
|
339
|
+
.digest('hex')
|
|
340
|
+
.slice(0, 32)}`;
|
|
341
|
+
const confirmation = option(args, '--confirmation');
|
|
342
|
+
try {
|
|
343
|
+
const payload = await request(
|
|
344
|
+
`/api/v1/vehicles/${encodeURIComponent(vehicle.id)}/service-records`,
|
|
345
|
+
{
|
|
346
|
+
method: 'POST',
|
|
347
|
+
body,
|
|
348
|
+
headers: {
|
|
349
|
+
'idempotency-key': idempotencyKey,
|
|
350
|
+
...(confirmation ? { 'x-fleetkeep-confirmation': confirmation } : {})
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
);
|
|
354
|
+
printJsonOr(
|
|
355
|
+
payload,
|
|
356
|
+
jsonOutput,
|
|
357
|
+
`${payload.replay ? 'Service record already saved' : 'Service record saved'}: ${vehicle.registration}, ${body.servicedAt}, ${body.odometerMileage.toLocaleString('en-GB')} miles.`
|
|
358
|
+
);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (
|
|
361
|
+
error instanceof FleetkeepApiError &&
|
|
362
|
+
error.status === 409 &&
|
|
363
|
+
error.payload?.error === 'confirmation_required' &&
|
|
364
|
+
typeof error.payload.confirmationToken === 'string'
|
|
365
|
+
) {
|
|
366
|
+
if (jsonOutput)
|
|
367
|
+
process.stdout.write(
|
|
368
|
+
`${JSON.stringify({ ...error.payload, vehicle, proposedRecord: body }, null, 2)}\n`
|
|
369
|
+
);
|
|
370
|
+
else {
|
|
371
|
+
process.stdout.write(
|
|
372
|
+
[
|
|
373
|
+
'No record was saved.',
|
|
374
|
+
`Vehicle: ${vehicle.registration}${vehicle.label ? ` (${vehicle.label})` : ''}`,
|
|
375
|
+
`Service date: ${body.servicedAt}`,
|
|
376
|
+
`Mileage: ${body.odometerMileage.toLocaleString('en-GB')}`,
|
|
377
|
+
`Work: ${body.workSummary}`,
|
|
378
|
+
'',
|
|
379
|
+
'After checking those details, repeat the same command with:',
|
|
380
|
+
`--confirmation ${error.payload.confirmationToken}`
|
|
381
|
+
].join('\n') + '\n'
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
process.exitCode = 2;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
263
391
|
async function spreadsheetPayload(fileArg) {
|
|
264
392
|
if (!fileArg || fileArg.startsWith('--')) throw new Error('A .csv or .xlsx file is required.');
|
|
265
393
|
const path = resolve(fileArg);
|
|
@@ -325,6 +453,11 @@ async function main() {
|
|
|
325
453
|
return;
|
|
326
454
|
}
|
|
327
455
|
|
|
456
|
+
if (args[0] === 'setup') {
|
|
457
|
+
await runSetup(args.slice(1));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
328
461
|
if (args[0] === 'costs' && args[1] === 'list') {
|
|
329
462
|
const query = [];
|
|
330
463
|
for (const name of ['from', 'to']) {
|
|
@@ -398,6 +531,11 @@ async function main() {
|
|
|
398
531
|
return;
|
|
399
532
|
}
|
|
400
533
|
|
|
534
|
+
if (args[0] === 'service' && args[1] === 'record') {
|
|
535
|
+
await recordServiceByRegistration(args[2], args.slice(3), jsonOutput);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
401
539
|
if (args[0] === 'vehicles' && args[1] === 'get') {
|
|
402
540
|
const payload = await request(vehiclePath(args[2]), {});
|
|
403
541
|
if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
package/operations.mjs
CHANGED
|
@@ -92,6 +92,27 @@ const definitions = [
|
|
|
92
92
|
'vehicles:write',
|
|
93
93
|
{ confirmation: true }
|
|
94
94
|
],
|
|
95
|
+
[
|
|
96
|
+
'listServiceSchedules',
|
|
97
|
+
'GET',
|
|
98
|
+
'/api/v1/vehicles/{id}/service-schedules',
|
|
99
|
+
'maintenance:read',
|
|
100
|
+
{ query: ['limit', 'offset'] }
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
'listServiceEvidenceRequests',
|
|
104
|
+
'GET',
|
|
105
|
+
'/api/v1/vehicles/{id}/service-evidence-requests',
|
|
106
|
+
'maintenance:read',
|
|
107
|
+
{ query: ['limit', 'offset'] }
|
|
108
|
+
],
|
|
109
|
+
[
|
|
110
|
+
'getServiceEvidenceTimeline',
|
|
111
|
+
'GET',
|
|
112
|
+
'/api/v1/vehicles/{id}/service-evidence-requests/{requestId}/contacts',
|
|
113
|
+
'maintenance:read',
|
|
114
|
+
{ query: ['limit'] }
|
|
115
|
+
],
|
|
95
116
|
[
|
|
96
117
|
'listServiceRecords',
|
|
97
118
|
'GET',
|
|
@@ -104,7 +125,7 @@ const definitions = [
|
|
|
104
125
|
'POST',
|
|
105
126
|
'/api/v1/vehicles/{id}/service-records',
|
|
106
127
|
'maintenance:write',
|
|
107
|
-
{ idempotency: true }
|
|
128
|
+
{ confirmation: true, idempotency: true }
|
|
108
129
|
],
|
|
109
130
|
[
|
|
110
131
|
'getServiceRecord',
|
|
@@ -225,6 +246,7 @@ const definitions = [
|
|
|
225
246
|
'operations:read',
|
|
226
247
|
{ query: ['period'] }
|
|
227
248
|
],
|
|
249
|
+
['getSetupStatus', 'GET', '/api/v1/setup', 'profile:read'],
|
|
228
250
|
['getProfile', 'GET', '/api/v1/profile', 'profile:read'],
|
|
229
251
|
['getDriverGuidance', 'GET', '/api/v1/driver-guidance', 'guidance:read'],
|
|
230
252
|
[
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fleetkeep/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Operate every published Fleetkeep API workflow with guarded writes and machine-readable output",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"auth.mjs",
|
|
12
12
|
"fleetkeep.mjs",
|
|
13
13
|
"operations.mjs",
|
|
14
|
+
"setup.mjs",
|
|
14
15
|
"README.md"
|
|
15
16
|
],
|
|
16
17
|
"engines": {
|
package/setup.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { getAccessToken, loadStoredTokens, login, oauthConfig, openBrowser } from './auth.mjs';
|
|
3
|
+
|
|
4
|
+
/** @param {string[]} args */
|
|
5
|
+
export async function runSetup(args) {
|
|
6
|
+
for (const arg of args) {
|
|
7
|
+
if (!['--json', '--no-browser'].includes(arg)) throw new Error(`Unknown setup option: ${arg}`);
|
|
8
|
+
}
|
|
9
|
+
const json = args.includes('--json');
|
|
10
|
+
const interactive = !json && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
11
|
+
const origin = oauthConfig().origin;
|
|
12
|
+
if (!process.env.FLEETKEEP_ACCESS_TOKEN?.trim() && !(await loadStoredTokens())) {
|
|
13
|
+
if (!interactive) {
|
|
14
|
+
const next = { nextStep: 'sign_in', command: 'fleetkeep auth login', url: `${origin}/login` };
|
|
15
|
+
process.stdout.write(
|
|
16
|
+
json
|
|
17
|
+
? `${JSON.stringify(next)}\n`
|
|
18
|
+
: `Sign in or create your Fleetkeep account in the browser:\n${next.command}\nThen run fleetkeep setup again.\n`
|
|
19
|
+
);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
process.stdout.write(
|
|
23
|
+
'Sign in or create your account in the browser. Existing plans stay on the same account.\n'
|
|
24
|
+
);
|
|
25
|
+
await login({ noBrowser: args.includes('--no-browser') });
|
|
26
|
+
}
|
|
27
|
+
const input = interactive
|
|
28
|
+
? createInterface({ input: process.stdin, output: process.stdout })
|
|
29
|
+
: null;
|
|
30
|
+
try {
|
|
31
|
+
do {
|
|
32
|
+
const response = await fetch(new URL('/api/v1/setup', origin), {
|
|
33
|
+
headers: { authorization: `Bearer ${await getAccessToken()}`, accept: 'application/json' },
|
|
34
|
+
redirect: 'error',
|
|
35
|
+
signal: AbortSignal.timeout(25_000)
|
|
36
|
+
});
|
|
37
|
+
if (!response.ok)
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Setup status unavailable (${response.status}). No changes were made. Try fleetkeep auth login if your session expired.`
|
|
40
|
+
);
|
|
41
|
+
const state = await response.json();
|
|
42
|
+
if (json) {
|
|
43
|
+
process.stdout.write(`${JSON.stringify(state, null, 2)}\n`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
process.stdout.write(
|
|
47
|
+
`\nFleetkeep setup\n${Number(state.vehicleCount).toLocaleString('en-GB')} vehicles saved\n${state.nextAction}\n`
|
|
48
|
+
);
|
|
49
|
+
if (state.billing.trialEndsAt)
|
|
50
|
+
process.stdout.write(
|
|
51
|
+
`Trial ends: ${new Date(state.billing.trialEndsAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/London' })}\n`
|
|
52
|
+
);
|
|
53
|
+
process.stdout.write(`${state.paymentNote}\n`);
|
|
54
|
+
const steps = /** @type {Record<string, string>} */ ({
|
|
55
|
+
plan: 'plans',
|
|
56
|
+
fleet_access: 'billing',
|
|
57
|
+
vehicles: 'addVehicle',
|
|
58
|
+
reminders: 'reminders',
|
|
59
|
+
review: 'dashboard'
|
|
60
|
+
});
|
|
61
|
+
const key = steps[state.nextStep];
|
|
62
|
+
// Only open the known website paths, never an arbitrary returned URL.
|
|
63
|
+
const paths = /** @type {Record<string, string>} */ ({
|
|
64
|
+
plans: '/dashboard/choose-plan',
|
|
65
|
+
billing: '/dashboard/settings#billing',
|
|
66
|
+
addVehicle: '/dashboard/vehicles/new',
|
|
67
|
+
reminders: '/dashboard/settings#reminders',
|
|
68
|
+
dashboard: '/dashboard'
|
|
69
|
+
});
|
|
70
|
+
const url = new URL(paths[key] || '/dashboard', origin).href;
|
|
71
|
+
process.stdout.write(`Next: ${url}\n`);
|
|
72
|
+
if (state.canUseApi)
|
|
73
|
+
process.stdout.write(
|
|
74
|
+
'Spreadsheet: fleetkeep import preview "fleet.xlsx"\nReview the preview, then use its confirmation command to save.\n'
|
|
75
|
+
);
|
|
76
|
+
if (!input) return;
|
|
77
|
+
const answer = (
|
|
78
|
+
await input.question(
|
|
79
|
+
'\n[o] Open next step [r] Refresh after completing it [Enter] Finish: '
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
.trim()
|
|
83
|
+
.toLowerCase();
|
|
84
|
+
if (answer === 'o') {
|
|
85
|
+
if (!args.includes('--no-browser')) openBrowser(url);
|
|
86
|
+
await input.question(
|
|
87
|
+
'Complete the step in your browser, then press Enter to check again. '
|
|
88
|
+
);
|
|
89
|
+
} else if (answer !== 'r') return;
|
|
90
|
+
} while (true);
|
|
91
|
+
} finally {
|
|
92
|
+
input?.close();
|
|
93
|
+
}
|
|
94
|
+
}
|