@fleetkeep/cli 0.5.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 +73 -0
- package/api-command.mjs +5 -2
- package/auth.mjs +1 -1
- package/fleetkeep.mjs +163 -2
- package/operations.mjs +24 -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:
|
|
@@ -39,6 +60,51 @@ not enter them again. Account JSON exports include jobs, journeys and additional
|
|
|
39
60
|
Xero authorisation and bill review take place in the web portal. Saved SHVL grants and
|
|
40
61
|
credentials are not transferred between products or customer workspaces.
|
|
41
62
|
|
|
63
|
+
## Service intervals and equipment
|
|
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
|
+
|
|
77
|
+
Set one service interval in days, weeks or months. The vehicle command clears the
|
|
78
|
+
alternative unit when switching, and converts weeks to days:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
fleetkeep vehicles update <vehicle-id> --service-interval-weeks 6
|
|
82
|
+
fleetkeep vehicles update <vehicle-id> --service-interval-months 12
|
|
83
|
+
fleetkeep vehicles update <vehicle-id> --clear serviceIntervalDays,serviceIntervalMonths
|
|
84
|
+
fleetkeep api call getWalkaroundSummary
|
|
85
|
+
fleetkeep api call listVehicleEquipment --path id=<vehicle-id>
|
|
86
|
+
fleetkeep api call updateVehicleEquipment --path id=<vehicle-id> --data-file equipment.json
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The walkaround summary returns vehicles checked today in London time, not a
|
|
90
|
+
roadworthiness certificate. Equipment payloads support `presetKey`, provider,
|
|
91
|
+
reference, cover dates, reminders and up to eight `details` label/value pairs.
|
|
92
|
+
Use an `Arrangement` detail for the confirmed ownership type, such as `Hired`.
|
|
93
|
+
Omitting details preserves existing values; an empty array explicitly clears them.
|
|
94
|
+
Use `previewEquipmentTemplate` before `applyEquipmentTemplate` for fleet-wide setup.
|
|
95
|
+
|
|
96
|
+
With the matching API update deployed, `createMaintenance` also accepts
|
|
97
|
+
`forecastConsent: true` and a `forecast` object containing `baselineDate`,
|
|
98
|
+
`baselineMileage`, `intervalMiles`, `annualMileage` and `reminderDays` (0 means on
|
|
99
|
+
the estimated date). Supply the actual last check, not an assumed MOT check date.
|
|
100
|
+
Duplicate setup does not create another reminder; refresh the maintenance list.
|
|
101
|
+
`completeMaintenance` accepts `completedDate` and `completedMileage` for
|
|
102
|
+
an existing forecast. Supply the identical body for preview and confirmation;
|
|
103
|
+
the next cycle retains the saved mileage interval and reminder timing. Ordinary
|
|
104
|
+
maintenance updates cannot edit calculated forecast dates. There are no automatic
|
|
105
|
+
fuel-card feeds in the CLI. Wex credentials and Xero authorisation are entered in
|
|
106
|
+
the website, never through CLI command arguments.
|
|
107
|
+
|
|
42
108
|
## Safety model
|
|
43
109
|
|
|
44
110
|
- `api operations` is generated as a fixed allowlist of the published OpenAPI operation IDs.
|
|
@@ -82,6 +148,9 @@ fleetkeep api call listTrackingSnapshots
|
|
|
82
148
|
fleetkeep api call getWexRouteHistory --path vehicleId=<vehicle-id> --query period=7d
|
|
83
149
|
fleetkeep api call createServiceRecord --path id=<vehicle-id> \
|
|
84
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
|
|
85
154
|
fleetkeep api call uploadVehiclePhoto --path id=<vehicle-id> --file photo=van.jpg
|
|
86
155
|
fleetkeep api call downloadFleetSpreadsheet --output fleetkeep-export.xlsx
|
|
87
156
|
```
|
|
@@ -95,3 +164,7 @@ Set `FLEETKEEP_API_URL` to an alternate origin for local or staging verification
|
|
|
95
164
|
automation, set `FLEETKEEP_ACCESS_TOKEN` in the process environment. OAuth access and refresh tokens
|
|
96
165
|
are never accepted in command arguments. Short-lived action confirmations are supplied separately
|
|
97
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/api-command.mjs
CHANGED
|
@@ -97,6 +97,9 @@ function operationPath(operation, assignments) {
|
|
|
97
97
|
if (!values.has(name) || values.get(name) === '') {
|
|
98
98
|
throw new Error(`${operation.id} requires --path ${name}=<value>.`);
|
|
99
99
|
}
|
|
100
|
+
if (values.get(name) === '.' || values.get(name) === '..') {
|
|
101
|
+
throw new Error(`--path ${name} must not be a dot segment.`);
|
|
102
|
+
}
|
|
100
103
|
path = path.replace(`{${name}}`, encodeURIComponent(values.get(name)));
|
|
101
104
|
}
|
|
102
105
|
return path;
|
|
@@ -280,9 +283,9 @@ export async function runApiCommand(args) {
|
|
|
280
283
|
throw new Error('Use `fleetkeep api operations` or `fleetkeep api call`.');
|
|
281
284
|
const operationId = args[1];
|
|
282
285
|
if (!operationId || operationId.startsWith('--')) throw new Error('An operation ID is required.');
|
|
283
|
-
|
|
284
|
-
if (!operation) {
|
|
286
|
+
if (!Object.hasOwn(operations, operationId)) {
|
|
285
287
|
throw new Error(`Unknown or unpublished Fleetkeep operation: ${operationId}.`);
|
|
286
288
|
}
|
|
289
|
+
const operation = operations[operationId];
|
|
287
290
|
await callOperation(operation, args.slice(2));
|
|
288
291
|
}
|
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]
|
|
@@ -56,6 +61,8 @@ Vehicle options:
|
|
|
56
61
|
--last-service-date <YYYY-MM-DD>
|
|
57
62
|
--last-service-mileage <integer>
|
|
58
63
|
--service-interval-months <integer>
|
|
64
|
+
--service-interval-days <integer>
|
|
65
|
+
--service-interval-weeks <integer>
|
|
59
66
|
--operational-status <active|standby|in_maintenance|off_road|sold_or_removed>
|
|
60
67
|
--assigned-driver-name <text>
|
|
61
68
|
--assigned-driver-contact <text>
|
|
@@ -63,7 +70,10 @@ Vehicle options:
|
|
|
63
70
|
|
|
64
71
|
Clearable update fields:
|
|
65
72
|
label, insuranceDueDate, lastServiceDate, lastServiceMileage, serviceIntervalMonths,
|
|
66
|
-
assignedDriverName, assignedDriverContact, statusNotes
|
|
73
|
+
serviceIntervalDays, assignedDriverName, assignedDriverContact, statusNotes
|
|
74
|
+
|
|
75
|
+
Choose one service interval unit. Weeks are stored as days. Setting an interval
|
|
76
|
+
clears the alternative unit so an old interval cannot override the new one.
|
|
67
77
|
|
|
68
78
|
Environment:
|
|
69
79
|
FLEETKEEP_ACCESS_TOKEN Optional OAuth token override for automation
|
|
@@ -112,6 +122,11 @@ const VEHICLE_OPTIONS = new Map([
|
|
|
112
122
|
'--service-interval-months',
|
|
113
123
|
{ field: 'serviceIntervalMonths', type: 'integer', min: 1, max: 120 }
|
|
114
124
|
],
|
|
125
|
+
['--service-interval-days', { field: 'serviceIntervalDays', type: 'integer', min: 1, max: 3650 }],
|
|
126
|
+
[
|
|
127
|
+
'--service-interval-weeks',
|
|
128
|
+
{ field: 'serviceIntervalDays', type: 'integer', min: 1, max: 521, multiplier: 7 }
|
|
129
|
+
],
|
|
115
130
|
['--operational-status', { field: 'operationalStatus', type: 'status' }],
|
|
116
131
|
['--assigned-driver-name', { field: 'assignedDriverName', type: 'string' }],
|
|
117
132
|
['--assigned-driver-contact', { field: 'assignedDriverContact', type: 'string' }],
|
|
@@ -160,11 +175,22 @@ function parseVehicleValue(name, value, config) {
|
|
|
160
175
|
|
|
161
176
|
function vehicleBody(args, { allowClear = false } = {}) {
|
|
162
177
|
const body = {};
|
|
178
|
+
const intervalOptions = [
|
|
179
|
+
'--service-interval-days',
|
|
180
|
+
'--service-interval-weeks',
|
|
181
|
+
'--service-interval-months'
|
|
182
|
+
];
|
|
183
|
+
if (intervalOptions.filter((name) => args.includes(name)).length > 1) {
|
|
184
|
+
throw new Error('Choose one service interval unit: days, weeks or months.');
|
|
185
|
+
}
|
|
163
186
|
const recognised = new Set(['--json']);
|
|
164
187
|
for (const [name, config] of VEHICLE_OPTIONS) {
|
|
165
188
|
recognised.add(name);
|
|
166
189
|
const value = requiredValue(args, name);
|
|
167
|
-
if (value !== null)
|
|
190
|
+
if (value !== null) {
|
|
191
|
+
const parsed = parseVehicleValue(name, value, config);
|
|
192
|
+
body[config.field] = config.multiplier ? parsed * config.multiplier : parsed;
|
|
193
|
+
}
|
|
168
194
|
}
|
|
169
195
|
|
|
170
196
|
if (allowClear) {
|
|
@@ -189,6 +215,8 @@ function vehicleBody(args, { allowClear = false } = {}) {
|
|
|
189
215
|
for (const arg of args) {
|
|
190
216
|
if (arg.startsWith('--') && !recognised.has(arg)) throw new Error(`Unknown option: ${arg}.`);
|
|
191
217
|
}
|
|
218
|
+
if (body.serviceIntervalDays != null) body.serviceIntervalMonths = null;
|
|
219
|
+
if (body.serviceIntervalMonths != null) body.serviceIntervalDays = null;
|
|
192
220
|
return body;
|
|
193
221
|
}
|
|
194
222
|
|
|
@@ -237,6 +265,129 @@ function printJsonOr(payload, jsonOutput, line) {
|
|
|
237
265
|
process.stdout.write(jsonOutput ? `${JSON.stringify(payload, null, 2)}\n` : `${line}\n`);
|
|
238
266
|
}
|
|
239
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
|
+
|
|
240
391
|
async function spreadsheetPayload(fileArg) {
|
|
241
392
|
if (!fileArg || fileArg.startsWith('--')) throw new Error('A .csv or .xlsx file is required.');
|
|
242
393
|
const path = resolve(fileArg);
|
|
@@ -302,6 +453,11 @@ async function main() {
|
|
|
302
453
|
return;
|
|
303
454
|
}
|
|
304
455
|
|
|
456
|
+
if (args[0] === 'setup') {
|
|
457
|
+
await runSetup(args.slice(1));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
305
461
|
if (args[0] === 'costs' && args[1] === 'list') {
|
|
306
462
|
const query = [];
|
|
307
463
|
for (const name of ['from', 'to']) {
|
|
@@ -375,6 +531,11 @@ async function main() {
|
|
|
375
531
|
return;
|
|
376
532
|
}
|
|
377
533
|
|
|
534
|
+
if (args[0] === 'service' && args[1] === 'record') {
|
|
535
|
+
await recordServiceByRegistration(args[2], args.slice(3), jsonOutput);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
378
539
|
if (args[0] === 'vehicles' && args[1] === 'get') {
|
|
379
540
|
const payload = await request(vehiclePath(args[2]), {});
|
|
380
541
|
if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
package/operations.mjs
CHANGED
|
@@ -34,6 +34,7 @@ const definitions = [
|
|
|
34
34
|
['previewVehicleImport', 'POST', '/api/v1/imports/vehicles/preview', 'vehicles:read'],
|
|
35
35
|
['applyVehicleImport', 'POST', '/api/v1/imports/vehicles/apply', 'vehicles:write'],
|
|
36
36
|
['getWalkaroundDefinition', 'GET', '/api/v1/operations/walkaround-definition', 'operations:read'],
|
|
37
|
+
['getWalkaroundSummary', 'GET', '/api/v1/operations/walkaround-summary', 'operations:read'],
|
|
37
38
|
[
|
|
38
39
|
'listWalkarounds',
|
|
39
40
|
'GET',
|
|
@@ -91,6 +92,27 @@ const definitions = [
|
|
|
91
92
|
'vehicles:write',
|
|
92
93
|
{ confirmation: true }
|
|
93
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
|
+
],
|
|
94
116
|
[
|
|
95
117
|
'listServiceRecords',
|
|
96
118
|
'GET',
|
|
@@ -103,7 +125,7 @@ const definitions = [
|
|
|
103
125
|
'POST',
|
|
104
126
|
'/api/v1/vehicles/{id}/service-records',
|
|
105
127
|
'maintenance:write',
|
|
106
|
-
{ idempotency: true }
|
|
128
|
+
{ confirmation: true, idempotency: true }
|
|
107
129
|
],
|
|
108
130
|
[
|
|
109
131
|
'getServiceRecord',
|
|
@@ -224,6 +246,7 @@ const definitions = [
|
|
|
224
246
|
'operations:read',
|
|
225
247
|
{ query: ['period'] }
|
|
226
248
|
],
|
|
249
|
+
['getSetupStatus', 'GET', '/api/v1/setup', 'profile:read'],
|
|
227
250
|
['getProfile', 'GET', '/api/v1/profile', 'profile:read'],
|
|
228
251
|
['getDriverGuidance', 'GET', '/api/v1/driver-guidance', 'guidance:read'],
|
|
229
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
|
+
}
|