@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 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
@@ -224,7 +224,7 @@ export async function getAccessToken() {
224
224
  }
225
225
 
226
226
  /** @param {string} url */
227
- function openBrowser(url) {
227
+ export function openBrowser(url) {
228
228
  /** @type {[string, string[]]} */
229
229
  const command =
230
230
  process.platform === 'darwin'
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.6.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
+ }