@fleetkeep/cli 0.6.0 → 0.8.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 +66 -0
- package/auth.mjs +1 -1
- package/fleetkeep.mjs +247 -0
- package/operations.mjs +89 -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
|
|
|
@@ -72,6 +105,28 @@ maintenance updates cannot edit calculated forecast dates. There are no automati
|
|
|
72
105
|
fuel-card feeds in the CLI. Wex credentials and Xero authorisation are entered in
|
|
73
106
|
the website, never through CLI command arguments.
|
|
74
107
|
|
|
108
|
+
## Toll statements and email forwarding
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
fleetkeep tolls forwarding
|
|
112
|
+
fleetkeep tolls forwarding enable
|
|
113
|
+
fleetkeep tolls import preview statement.csv
|
|
114
|
+
fleetkeep tolls import apply statement.csv --confirm <digest-from-preview>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The forwarding command shows your private account address, recent imports and
|
|
118
|
+
email delivery results. `enable` creates the address if needed. Forward CSV
|
|
119
|
+
statement attachments to that address; the inbox is checked every 60 minutes.
|
|
120
|
+
Each email accepts up to five attachments of 512 KiB each. Review unmatched
|
|
121
|
+
vehicles and delivery messages in Fleetkeep. Keep the address private.
|
|
122
|
+
|
|
123
|
+
For a local CSV, preview first and review the importable, duplicate and unmatched
|
|
124
|
+
counts. Apply requires the exact preview digest and rejects a changed file before
|
|
125
|
+
sending it. The CLI sends a stable idempotency key in the request header; use
|
|
126
|
+
`--idempotency-key <key>` to choose your own and reuse it for an identical retry.
|
|
127
|
+
Add `--json` to any command for the full API response. These commands require the
|
|
128
|
+
matching toll API deployment. Statement preview requires `tolls:read`; applying an import and both forwarding commands require `tolls:write`.
|
|
129
|
+
|
|
75
130
|
## Safety model
|
|
76
131
|
|
|
77
132
|
- `api operations` is generated as a fixed allowlist of the published OpenAPI operation IDs.
|
|
@@ -115,6 +170,9 @@ fleetkeep api call listTrackingSnapshots
|
|
|
115
170
|
fleetkeep api call getWexRouteHistory --path vehicleId=<vehicle-id> --query period=7d
|
|
116
171
|
fleetkeep api call createServiceRecord --path id=<vehicle-id> \
|
|
117
172
|
--idempotency-key <unique-value> --data-file service-record.json
|
|
173
|
+
fleetkeep api call createServiceRecord --path id=<vehicle-id> \
|
|
174
|
+
--idempotency-key <same-unique-value> --confirmation <server-token> \
|
|
175
|
+
--data-file service-record.json
|
|
118
176
|
fleetkeep api call uploadVehiclePhoto --path id=<vehicle-id> --file photo=van.jpg
|
|
119
177
|
fleetkeep api call downloadFleetSpreadsheet --output fleetkeep-export.xlsx
|
|
120
178
|
```
|
|
@@ -128,3 +186,11 @@ Set `FLEETKEEP_API_URL` to an alternate origin for local or staging verification
|
|
|
128
186
|
automation, set `FLEETKEEP_ACCESS_TOKEN` in the process environment. OAuth access and refresh tokens
|
|
129
187
|
are never accepted in command arguments. Short-lived action confirmations are supplied separately
|
|
130
188
|
with `--confirmation`.
|
|
189
|
+
|
|
190
|
+
Service-record creation is a two-step operator-confirmed action. The first call returns a 409
|
|
191
|
+
challenge. Retry the identical body and idempotency key with its `--confirmation` token. A safe
|
|
192
|
+
retry with the same key returns the original record instead of creating a duplicate.
|
|
193
|
+
|
|
194
|
+
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
|
+
|
|
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.
|
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]
|
|
@@ -35,6 +40,10 @@ Usage:
|
|
|
35
40
|
fleetkeep jobs reopen <job-id> [--json]
|
|
36
41
|
fleetkeep import preview <file.csv|file.xlsx> [--json]
|
|
37
42
|
fleetkeep import apply <file.csv|file.xlsx> --confirm <digest> [--idempotency-key <key>] [--json]
|
|
43
|
+
fleetkeep tolls forwarding [--json]
|
|
44
|
+
fleetkeep tolls forwarding enable [--json]
|
|
45
|
+
fleetkeep tolls import preview <file.csv> [--json]
|
|
46
|
+
fleetkeep tolls import apply <file.csv> --confirm <digest> [--idempotency-key <key>] [--json]
|
|
38
47
|
fleetkeep api operations [--json]
|
|
39
48
|
fleetkeep api call <operation-id> [API options]
|
|
40
49
|
|
|
@@ -260,6 +269,129 @@ function printJsonOr(payload, jsonOutput, line) {
|
|
|
260
269
|
process.stdout.write(jsonOutput ? `${JSON.stringify(payload, null, 2)}\n` : `${line}\n`);
|
|
261
270
|
}
|
|
262
271
|
|
|
272
|
+
function normaliseRegistration(value) {
|
|
273
|
+
return value.toUpperCase().replace(/\s+/g, '');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function serviceRecordBody(args) {
|
|
277
|
+
const allowed = new Set([
|
|
278
|
+
'--date',
|
|
279
|
+
'--mileage',
|
|
280
|
+
'--work',
|
|
281
|
+
'--supplier',
|
|
282
|
+
'--invoice',
|
|
283
|
+
'--notes',
|
|
284
|
+
'--next-date',
|
|
285
|
+
'--next-mileage',
|
|
286
|
+
'--confirmation',
|
|
287
|
+
'--json'
|
|
288
|
+
]);
|
|
289
|
+
for (const arg of args) {
|
|
290
|
+
if (arg.startsWith('--') && !allowed.has(arg)) throw new Error(`Unknown option: ${arg}.`);
|
|
291
|
+
}
|
|
292
|
+
const servicedAt = option(args, '--date');
|
|
293
|
+
const mileage = option(args, '--mileage');
|
|
294
|
+
const workSummary = option(args, '--work');
|
|
295
|
+
if (!servicedAt) throw new Error('--date is required.');
|
|
296
|
+
parseVehicleValue('--date', servicedAt, { type: 'date' });
|
|
297
|
+
if (!mileage) throw new Error('--mileage is required.');
|
|
298
|
+
const odometerMileage = parseVehicleValue('--mileage', mileage, {
|
|
299
|
+
type: 'integer',
|
|
300
|
+
min: 0,
|
|
301
|
+
max: 1500000
|
|
302
|
+
});
|
|
303
|
+
if (!workSummary) throw new Error('--work is required.');
|
|
304
|
+
const body = { servicedAt, odometerMileage, workSummary };
|
|
305
|
+
for (const [name, field] of [
|
|
306
|
+
['--supplier', 'supplier'],
|
|
307
|
+
['--invoice', 'invoiceReference'],
|
|
308
|
+
['--notes', 'notes'],
|
|
309
|
+
['--next-date', 'nextServiceDueDate']
|
|
310
|
+
]) {
|
|
311
|
+
const value = option(args, name);
|
|
312
|
+
if (value) {
|
|
313
|
+
if (name === '--next-date') parseVehicleValue(name, value, { type: 'date' });
|
|
314
|
+
body[field] = value;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const nextMileage = option(args, '--next-mileage');
|
|
318
|
+
if (nextMileage) {
|
|
319
|
+
body.nextServiceDueMileage = parseVehicleValue('--next-mileage', nextMileage, {
|
|
320
|
+
type: 'integer',
|
|
321
|
+
min: 0,
|
|
322
|
+
max: 1500000
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return body;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function recordServiceByRegistration(registration, args, jsonOutput) {
|
|
329
|
+
if (!registration || registration.startsWith('--'))
|
|
330
|
+
throw new Error('A registration is required.');
|
|
331
|
+
const body = serviceRecordBody(args);
|
|
332
|
+
const listed = await request('/api/v1/vehicles', {});
|
|
333
|
+
const wanted = normaliseRegistration(registration);
|
|
334
|
+
const matches = (listed.vehicles || []).filter(
|
|
335
|
+
(vehicle) => normaliseRegistration(vehicle.registration || '') === wanted
|
|
336
|
+
);
|
|
337
|
+
if (matches.length === 0) throw new Error(`No vehicle matched registration ${registration}.`);
|
|
338
|
+
if (matches.length > 1)
|
|
339
|
+
throw new Error(`More than one vehicle matched registration ${registration}.`);
|
|
340
|
+
const vehicle = matches[0];
|
|
341
|
+
const idempotencyKey = `service:${createHash('sha256')
|
|
342
|
+
.update(JSON.stringify({ vehicleId: vehicle.id, body }))
|
|
343
|
+
.digest('hex')
|
|
344
|
+
.slice(0, 32)}`;
|
|
345
|
+
const confirmation = option(args, '--confirmation');
|
|
346
|
+
try {
|
|
347
|
+
const payload = await request(
|
|
348
|
+
`/api/v1/vehicles/${encodeURIComponent(vehicle.id)}/service-records`,
|
|
349
|
+
{
|
|
350
|
+
method: 'POST',
|
|
351
|
+
body,
|
|
352
|
+
headers: {
|
|
353
|
+
'idempotency-key': idempotencyKey,
|
|
354
|
+
...(confirmation ? { 'x-fleetkeep-confirmation': confirmation } : {})
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
printJsonOr(
|
|
359
|
+
payload,
|
|
360
|
+
jsonOutput,
|
|
361
|
+
`${payload.replay ? 'Service record already saved' : 'Service record saved'}: ${vehicle.registration}, ${body.servicedAt}, ${body.odometerMileage.toLocaleString('en-GB')} miles.`
|
|
362
|
+
);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (
|
|
365
|
+
error instanceof FleetkeepApiError &&
|
|
366
|
+
error.status === 409 &&
|
|
367
|
+
error.payload?.error === 'confirmation_required' &&
|
|
368
|
+
typeof error.payload.confirmationToken === 'string'
|
|
369
|
+
) {
|
|
370
|
+
if (jsonOutput)
|
|
371
|
+
process.stdout.write(
|
|
372
|
+
`${JSON.stringify({ ...error.payload, vehicle, proposedRecord: body }, null, 2)}\n`
|
|
373
|
+
);
|
|
374
|
+
else {
|
|
375
|
+
process.stdout.write(
|
|
376
|
+
[
|
|
377
|
+
'No record was saved.',
|
|
378
|
+
`Vehicle: ${vehicle.registration}${vehicle.label ? ` (${vehicle.label})` : ''}`,
|
|
379
|
+
`Service date: ${body.servicedAt}`,
|
|
380
|
+
`Mileage: ${body.odometerMileage.toLocaleString('en-GB')}`,
|
|
381
|
+
`Work: ${body.workSummary}`,
|
|
382
|
+
'',
|
|
383
|
+
'After checking those details, repeat the same command with:',
|
|
384
|
+
`--confirmation ${error.payload.confirmationToken}`
|
|
385
|
+
].join('\n') + '\n'
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
process.exitCode = 2;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
263
395
|
async function spreadsheetPayload(fileArg) {
|
|
264
396
|
if (!fileArg || fileArg.startsWith('--')) throw new Error('A .csv or .xlsx file is required.');
|
|
265
397
|
const path = resolve(fileArg);
|
|
@@ -317,6 +449,93 @@ function printImport(payload, jsonOutput) {
|
|
|
317
449
|
}
|
|
318
450
|
}
|
|
319
451
|
|
|
452
|
+
function printTollForwarding(payload, jsonOutput) {
|
|
453
|
+
const lines = [
|
|
454
|
+
`Email forwarding: ${payload.available === false ? 'not available' : payload.enabled ? 'enabled' : 'not enabled'}`,
|
|
455
|
+
...(payload.address ? [`Forward CSV statements to: ${payload.address}`] : []),
|
|
456
|
+
`Inbox checked every ${payload.pollIntervalMinutes.toLocaleString('en-GB')} minutes.`,
|
|
457
|
+
...(payload.enabled || payload.available === false
|
|
458
|
+
? []
|
|
459
|
+
: ['To create your forwarding address: fleetkeep tolls forwarding enable']),
|
|
460
|
+
...payload.recentImports.map(
|
|
461
|
+
(item) =>
|
|
462
|
+
`${item.fileName}: ${item.importedCount.toLocaleString('en-GB')} imported, ${item.unmatchedCount.toLocaleString('en-GB')} unmatched, ${item.duplicateCount.toLocaleString('en-GB')} duplicates (${item.status})`
|
|
463
|
+
),
|
|
464
|
+
...payload.recentDeliveries.map((item) => `${item.createdAt}: ${item.message}`)
|
|
465
|
+
];
|
|
466
|
+
printJsonOr(payload, jsonOutput, lines.join('\n'));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function tollImport(action, fileArg, args, jsonOutput) {
|
|
470
|
+
const allowed = new Set(
|
|
471
|
+
action === 'apply' ? ['--json', '--confirm', '--idempotency-key'] : ['--json']
|
|
472
|
+
);
|
|
473
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
474
|
+
const name = args[index];
|
|
475
|
+
if (!allowed.has(name)) throw new Error(`Unknown option: ${name}.`);
|
|
476
|
+
if (name !== '--json') {
|
|
477
|
+
requiredValue(args, name);
|
|
478
|
+
index += 1;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const confirmDigest = requiredValue(args, '--confirm');
|
|
482
|
+
if (action === 'apply' && !/^[0-9a-f]{64}$/.test(confirmDigest || '')) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
'Apply is blocked until --confirm contains the exact digest returned by preview.'
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
const idempotencyKey = requiredValue(args, '--idempotency-key');
|
|
488
|
+
if (!fileArg || fileArg.startsWith('--') || extname(fileArg).toLowerCase() !== '.csv') {
|
|
489
|
+
throw new Error('A .csv toll statement is required.');
|
|
490
|
+
}
|
|
491
|
+
const body = await spreadsheetPayload(fileArg);
|
|
492
|
+
if (Buffer.byteLength(body.csv, 'utf8') > 256 * 1024) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
'CLI toll imports accept CSV files up to 256 KiB. Split a larger statement first.'
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
if (
|
|
498
|
+
action === 'apply' &&
|
|
499
|
+
createHash('sha256').update(body.csv, 'utf8').digest('hex') !== confirmDigest
|
|
500
|
+
) {
|
|
501
|
+
throw new Error('The file does not match --confirm. Preview this file again before applying.');
|
|
502
|
+
}
|
|
503
|
+
const payload = await request(`/api/v1/tolls/imports/${action}`, {
|
|
504
|
+
method: 'POST',
|
|
505
|
+
body: action === 'apply' ? { ...body, confirmDigest } : body,
|
|
506
|
+
...(action === 'apply'
|
|
507
|
+
? { headers: { 'idempotency-key': idempotencyKey || `cli:tolls:${confirmDigest}` } }
|
|
508
|
+
: {})
|
|
509
|
+
});
|
|
510
|
+
if (jsonOutput) {
|
|
511
|
+
printJsonOr(payload, true, '');
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const count = (value) => value.toLocaleString('en-GB');
|
|
515
|
+
const lines =
|
|
516
|
+
action === 'preview'
|
|
517
|
+
? [
|
|
518
|
+
`Source: ${payload.sourceName}`,
|
|
519
|
+
`Digest: ${payload.digest}`,
|
|
520
|
+
`Rows: ${count(payload.rowCount)}`,
|
|
521
|
+
`Importable: ${count(payload.importableCount)}`,
|
|
522
|
+
`Duplicates: ${count(payload.duplicateCount)}`,
|
|
523
|
+
`Unmatched vehicles: ${count(payload.unknownCount)}`,
|
|
524
|
+
'',
|
|
525
|
+
'No data was changed. To apply this exact file:',
|
|
526
|
+
`fleetkeep tolls import apply ${JSON.stringify(fileArg)} --confirm ${payload.digest}`
|
|
527
|
+
]
|
|
528
|
+
: [
|
|
529
|
+
payload.replay
|
|
530
|
+
? 'Toll import result replayed from the original request.'
|
|
531
|
+
: 'Toll import completed.',
|
|
532
|
+
`Imported: ${count(payload.imported)}`,
|
|
533
|
+
`Duplicates: ${count(payload.duplicates)}`,
|
|
534
|
+
`Unmatched vehicles: ${count(payload.unknown)}`
|
|
535
|
+
];
|
|
536
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
537
|
+
}
|
|
538
|
+
|
|
320
539
|
async function main() {
|
|
321
540
|
const args = process.argv.slice(2);
|
|
322
541
|
const jsonOutput = args.includes('--json');
|
|
@@ -325,6 +544,29 @@ async function main() {
|
|
|
325
544
|
return;
|
|
326
545
|
}
|
|
327
546
|
|
|
547
|
+
if (args[0] === 'setup') {
|
|
548
|
+
await runSetup(args.slice(1));
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (args[0] === 'tolls' && args[1] === 'forwarding') {
|
|
553
|
+
const rest = args.slice(2).filter((arg) => arg !== '--json');
|
|
554
|
+
if (rest.length > 1 || (rest.length === 1 && rest[0] !== 'enable')) {
|
|
555
|
+
throw new Error('Use fleetkeep tolls forwarding [enable] [--json].');
|
|
556
|
+
}
|
|
557
|
+
const payload = await request(
|
|
558
|
+
'/api/v1/tolls/forwarding',
|
|
559
|
+
rest[0] === 'enable' ? { method: 'POST', body: {} } : {}
|
|
560
|
+
);
|
|
561
|
+
printTollForwarding(payload, jsonOutput);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (args[0] === 'tolls' && args[1] === 'import' && ['preview', 'apply'].includes(args[2])) {
|
|
566
|
+
await tollImport(args[2], args[3], args.slice(4), jsonOutput);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
328
570
|
if (args[0] === 'costs' && args[1] === 'list') {
|
|
329
571
|
const query = [];
|
|
330
572
|
for (const name of ['from', 'to']) {
|
|
@@ -398,6 +640,11 @@ async function main() {
|
|
|
398
640
|
return;
|
|
399
641
|
}
|
|
400
642
|
|
|
643
|
+
if (args[0] === 'service' && args[1] === 'record') {
|
|
644
|
+
await recordServiceByRegistration(args[2], args.slice(3), jsonOutput);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
401
648
|
if (args[0] === 'vehicles' && args[1] === 'get') {
|
|
402
649
|
const payload = await request(vehiclePath(args[2]), {});
|
|
403
650
|
if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
package/operations.mjs
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
/** @type {Array<[string, string, string, string, Record<string, unknown>?]>} */
|
|
2
2
|
const definitions = [
|
|
3
|
+
['getFuelPrices', 'GET', '/api/fuel-prices', 'operations:read'],
|
|
4
|
+
[
|
|
5
|
+
'getFuelEconomy',
|
|
6
|
+
'GET',
|
|
7
|
+
'/api/fuel-economy',
|
|
8
|
+
'operations:read',
|
|
9
|
+
{ query: ['year', 'make', 'model'] }
|
|
10
|
+
],
|
|
11
|
+
[
|
|
12
|
+
'listFuelEconomyModels',
|
|
13
|
+
'GET',
|
|
14
|
+
'/api/fuel-economy/models',
|
|
15
|
+
'operations:read',
|
|
16
|
+
{ query: ['year', 'make'] }
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
'getChargingPrices',
|
|
20
|
+
'GET',
|
|
21
|
+
'/api/charging-prices',
|
|
22
|
+
'operations:read',
|
|
23
|
+
{ query: ['region', 'days'] }
|
|
24
|
+
],
|
|
25
|
+
['getPublicChargingPrices', 'GET', '/api/public-charging-prices', 'operations:read'],
|
|
26
|
+
[
|
|
27
|
+
'replaceServiceSchedule',
|
|
28
|
+
'POST',
|
|
29
|
+
'/api/v1/vehicles/{id}/service-schedules',
|
|
30
|
+
'maintenance:write',
|
|
31
|
+
{ idempotency: true }
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
'prepareServiceEvidenceRequest',
|
|
35
|
+
'POST',
|
|
36
|
+
'/api/v1/vehicles/{id}/service-evidence-requests',
|
|
37
|
+
'maintenance:write',
|
|
38
|
+
{ idempotency: true }
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
'recordServiceEvidenceContact',
|
|
42
|
+
'POST',
|
|
43
|
+
'/api/v1/vehicles/{id}/service-evidence-requests/{requestId}/contacts',
|
|
44
|
+
'maintenance:write',
|
|
45
|
+
{ idempotency: true }
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
'recordServiceEvidenceSubmission',
|
|
49
|
+
'POST',
|
|
50
|
+
'/api/v1/vehicles/{id}/service-evidence-requests/{requestId}/submissions',
|
|
51
|
+
'maintenance:write',
|
|
52
|
+
{ idempotency: true }
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
'reviewServiceEvidenceSubmission',
|
|
56
|
+
'POST',
|
|
57
|
+
'/api/v1/vehicles/{id}/service-evidence-requests/{requestId}/submissions/{submissionId}/review',
|
|
58
|
+
'maintenance:write',
|
|
59
|
+
{ idempotency: true }
|
|
60
|
+
],
|
|
61
|
+
['listDrivers', 'GET', '/api/v1/drivers', 'operations:read'],
|
|
62
|
+
['getDriverActivity', 'GET', '/api/v1/drivers/activity', 'operations:read', { query: ['q'] }],
|
|
63
|
+
['listDriverMemberships', 'GET', '/api/v1/driver-workspace', 'vehicles:read'],
|
|
64
|
+
['getDriverWorkspace', 'GET', '/api/v1/driver-workspace/{id}', 'operations:read'],
|
|
65
|
+
['getPushNotificationStatus', 'GET', '/api/v1/settings/push-notifications', 'reminders:read'],
|
|
3
66
|
['importProviderJobs', 'POST', '/api/v1/jobs/import', 'operations:write'],
|
|
4
67
|
['listFleetJobs', 'GET', '/api/v1/jobs', 'operations:read', { query: ['q'] }],
|
|
5
68
|
['saveFleetJob', 'POST', '/api/v1/jobs', 'operations:write'],
|
|
@@ -14,6 +77,7 @@ const definitions = [
|
|
|
14
77
|
],
|
|
15
78
|
['listVehicles', 'GET', '/api/v1/vehicles', 'vehicles:read'],
|
|
16
79
|
['addVehicle', 'POST', '/api/v1/vehicles', 'vehicles:write'],
|
|
80
|
+
['searchVehicles', 'GET', '/api/v1/vehicles/search', 'vehicles:read', { query: ['q', 'limit'] }],
|
|
17
81
|
['getVehicle', 'GET', '/api/v1/vehicles/{id}', 'vehicles:read'],
|
|
18
82
|
['updateVehicle', 'PATCH', '/api/v1/vehicles/{id}', 'vehicles:write'],
|
|
19
83
|
['deleteVehicle', 'DELETE', '/api/v1/vehicles/{id}', 'vehicles:write', { confirmation: true }],
|
|
@@ -92,6 +156,27 @@ const definitions = [
|
|
|
92
156
|
'vehicles:write',
|
|
93
157
|
{ confirmation: true }
|
|
94
158
|
],
|
|
159
|
+
[
|
|
160
|
+
'listServiceSchedules',
|
|
161
|
+
'GET',
|
|
162
|
+
'/api/v1/vehicles/{id}/service-schedules',
|
|
163
|
+
'maintenance:read',
|
|
164
|
+
{ query: ['limit', 'offset'] }
|
|
165
|
+
],
|
|
166
|
+
[
|
|
167
|
+
'listServiceEvidenceRequests',
|
|
168
|
+
'GET',
|
|
169
|
+
'/api/v1/vehicles/{id}/service-evidence-requests',
|
|
170
|
+
'maintenance:read',
|
|
171
|
+
{ query: ['limit', 'offset'] }
|
|
172
|
+
],
|
|
173
|
+
[
|
|
174
|
+
'getServiceEvidenceTimeline',
|
|
175
|
+
'GET',
|
|
176
|
+
'/api/v1/vehicles/{id}/service-evidence-requests/{requestId}/contacts',
|
|
177
|
+
'maintenance:read',
|
|
178
|
+
{ query: ['limit'] }
|
|
179
|
+
],
|
|
95
180
|
[
|
|
96
181
|
'listServiceRecords',
|
|
97
182
|
'GET',
|
|
@@ -104,7 +189,7 @@ const definitions = [
|
|
|
104
189
|
'POST',
|
|
105
190
|
'/api/v1/vehicles/{id}/service-records',
|
|
106
191
|
'maintenance:write',
|
|
107
|
-
{ idempotency: true }
|
|
192
|
+
{ confirmation: true, idempotency: true }
|
|
108
193
|
],
|
|
109
194
|
[
|
|
110
195
|
'getServiceRecord',
|
|
@@ -215,6 +300,8 @@ const definitions = [
|
|
|
215
300
|
'tolls:write',
|
|
216
301
|
{ idempotency: true }
|
|
217
302
|
],
|
|
303
|
+
['getTollForwarding', 'GET', '/api/v1/tolls/forwarding', 'tolls:write'],
|
|
304
|
+
['enableTollForwarding', 'POST', '/api/v1/tolls/forwarding', 'tolls:write'],
|
|
218
305
|
['previewTollImport', 'POST', '/api/v1/tolls/imports/preview', 'tolls:read'],
|
|
219
306
|
['applyTollImport', 'POST', '/api/v1/tolls/imports/apply', 'tolls:write', { idempotency: true }],
|
|
220
307
|
['listTrackingSnapshots', 'GET', '/api/v1/operations/tracking', 'operations:read'],
|
|
@@ -225,6 +312,7 @@ const definitions = [
|
|
|
225
312
|
'operations:read',
|
|
226
313
|
{ query: ['period'] }
|
|
227
314
|
],
|
|
315
|
+
['getSetupStatus', 'GET', '/api/v1/setup', 'profile:read'],
|
|
228
316
|
['getProfile', 'GET', '/api/v1/profile', 'profile:read'],
|
|
229
317
|
['getDriverGuidance', 'GET', '/api/v1/driver-guidance', 'guidance:read'],
|
|
230
318
|
[
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fleetkeep/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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
|
+
}
|