@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 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
@@ -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]
@@ -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.6.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
+ }