@fleetkeep/cli 0.1.2 → 0.2.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 +15 -6
- package/fleetkeep.mjs +195 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Fleetkeep CLI
|
|
2
2
|
|
|
3
|
-
The Fleetkeep CLI gives an operator or software agent the
|
|
4
|
-
|
|
5
|
-
previewed file.
|
|
3
|
+
The Fleetkeep CLI gives an operator or software agent the complete published Fleetkeep vehicle API.
|
|
4
|
+
It can list, inspect, add, update and delete vehicles, bulk-add registrations, preview CSV or XLSX
|
|
5
|
+
imports and apply the exact previewed file.
|
|
6
6
|
|
|
7
7
|
## Safety model
|
|
8
8
|
|
|
@@ -11,6 +11,8 @@ previewed file.
|
|
|
11
11
|
- Every apply request has an idempotency key. Retrying the same request does not duplicate vehicles
|
|
12
12
|
or equipment history.
|
|
13
13
|
- Existing vehicle dates and current equipment are not overwritten by spreadsheet import.
|
|
14
|
+
- Vehicle deletion requires the exact vehicle ID as an explicit confirmation value.
|
|
15
|
+
- Updates distinguish omitted fields from fields explicitly cleared with `--clear`.
|
|
14
16
|
- Interactive login uses an OAuth 2.1 public client with S256 PKCE and a loopback callback.
|
|
15
17
|
- Stored access and refresh tokens use an owner-only local file and are never accepted as command
|
|
16
18
|
arguments.
|
|
@@ -24,10 +26,17 @@ Node.js 20 or newer is required. Sign in through Fleetkeep in your browser, then
|
|
|
24
26
|
```sh
|
|
25
27
|
fleetkeep auth login
|
|
26
28
|
fleetkeep vehicles list
|
|
29
|
+
fleetkeep vehicles get <vehicle-id> --json
|
|
30
|
+
fleetkeep vehicles add "AB12 CDE" --label "Service van" --operational-status active --json
|
|
31
|
+
fleetkeep vehicles update <vehicle-id> --operational-status off_road --status-notes "Unsafe tyre"
|
|
32
|
+
fleetkeep vehicles update <vehicle-id> --clear assignedDriverName,assignedDriverContact
|
|
33
|
+
fleetkeep vehicles bulk-add "AB12 CDE" "XY34 ZZZ" --json
|
|
34
|
+
fleetkeep vehicles delete <vehicle-id> --confirm <vehicle-id>
|
|
27
35
|
fleetkeep import preview vehicles.xlsx
|
|
28
36
|
fleetkeep import apply vehicles.xlsx --confirm <digest-from-preview>
|
|
29
37
|
```
|
|
30
38
|
|
|
31
|
-
Use `--json` for machine-readable output.
|
|
32
|
-
or staging verification. For unattended
|
|
33
|
-
environment instead of storing or passing a
|
|
39
|
+
Run `fleetkeep --help` for every supported vehicle field. Use `--json` for machine-readable output.
|
|
40
|
+
Set `FLEETKEEP_API_URL` to an alternate origin for local or staging verification. For unattended
|
|
41
|
+
automation, set `FLEETKEEP_ACCESS_TOKEN` in the process environment instead of storing or passing a
|
|
42
|
+
token in command arguments.
|
package/fleetkeep.mjs
CHANGED
|
@@ -14,9 +14,29 @@ Usage:
|
|
|
14
14
|
fleetkeep auth status
|
|
15
15
|
fleetkeep auth logout
|
|
16
16
|
fleetkeep vehicles list [--json]
|
|
17
|
+
fleetkeep vehicles get <vehicle-id> [--json]
|
|
18
|
+
fleetkeep vehicles add <registration> [vehicle options] [--json]
|
|
19
|
+
fleetkeep vehicles update <vehicle-id> [vehicle options] [--clear <field,...>] [--json]
|
|
20
|
+
fleetkeep vehicles delete <vehicle-id> --confirm <vehicle-id> [--json]
|
|
21
|
+
fleetkeep vehicles bulk-add <registration>... [--json]
|
|
17
22
|
fleetkeep import preview <file.csv|file.xlsx> [--json]
|
|
18
23
|
fleetkeep import apply <file.csv|file.xlsx> --confirm <digest> [--idempotency-key <key>] [--json]
|
|
19
24
|
|
|
25
|
+
Vehicle options:
|
|
26
|
+
--label <text>
|
|
27
|
+
--insurance-due-date <YYYY-MM-DD>
|
|
28
|
+
--last-service-date <YYYY-MM-DD>
|
|
29
|
+
--last-service-mileage <integer>
|
|
30
|
+
--service-interval-months <integer>
|
|
31
|
+
--operational-status <active|standby|in_maintenance|off_road|sold_or_removed>
|
|
32
|
+
--assigned-driver-name <text>
|
|
33
|
+
--assigned-driver-contact <text>
|
|
34
|
+
--status-notes <text>
|
|
35
|
+
|
|
36
|
+
Clearable update fields:
|
|
37
|
+
label, insuranceDueDate, lastServiceDate, lastServiceMileage, serviceIntervalMonths,
|
|
38
|
+
assignedDriverName, assignedDriverContact, statusNotes
|
|
39
|
+
|
|
20
40
|
Environment:
|
|
21
41
|
FLEETKEEP_ACCESS_TOKEN Optional OAuth token override for automation
|
|
22
42
|
FLEETKEEP_API_URL API origin (default: https://fleetkeep.co.uk)
|
|
@@ -37,6 +57,104 @@ function option(args, name) {
|
|
|
37
57
|
return value && !value.startsWith('--') ? value : '';
|
|
38
58
|
}
|
|
39
59
|
|
|
60
|
+
function requiredValue(args, name) {
|
|
61
|
+
const value = option(args, name);
|
|
62
|
+
if (value === '') throw new Error(`${name} requires a value.`);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const VEHICLE_OPTIONS = new Map([
|
|
67
|
+
['--label', { field: 'label', type: 'string' }],
|
|
68
|
+
['--insurance-due-date', { field: 'insuranceDueDate', type: 'date' }],
|
|
69
|
+
['--last-service-date', { field: 'lastServiceDate', type: 'date' }],
|
|
70
|
+
[
|
|
71
|
+
'--last-service-mileage',
|
|
72
|
+
{ field: 'lastServiceMileage', type: 'integer', min: 0, max: 1500000 }
|
|
73
|
+
],
|
|
74
|
+
[
|
|
75
|
+
'--service-interval-months',
|
|
76
|
+
{ field: 'serviceIntervalMonths', type: 'integer', min: 1, max: 120 }
|
|
77
|
+
],
|
|
78
|
+
['--operational-status', { field: 'operationalStatus', type: 'status' }],
|
|
79
|
+
['--assigned-driver-name', { field: 'assignedDriverName', type: 'string' }],
|
|
80
|
+
['--assigned-driver-contact', { field: 'assignedDriverContact', type: 'string' }],
|
|
81
|
+
['--status-notes', { field: 'statusNotes', type: 'string' }]
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const CLEARABLE_FIELDS = new Set(
|
|
85
|
+
[...VEHICLE_OPTIONS.values()]
|
|
86
|
+
.map(({ field }) => field)
|
|
87
|
+
.filter((field) => field !== 'operationalStatus')
|
|
88
|
+
);
|
|
89
|
+
const OPERATIONAL_STATUSES = new Set([
|
|
90
|
+
'active',
|
|
91
|
+
'standby',
|
|
92
|
+
'in_maintenance',
|
|
93
|
+
'off_road',
|
|
94
|
+
'sold_or_removed'
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
function parseVehicleValue(name, value, config) {
|
|
98
|
+
if (config.type === 'string') return value;
|
|
99
|
+
if (config.type === 'date') {
|
|
100
|
+
const date = new Date(`${value}T00:00:00Z`);
|
|
101
|
+
if (
|
|
102
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
|
|
103
|
+
Number.isNaN(date.getTime()) ||
|
|
104
|
+
date.toISOString().slice(0, 10) !== value
|
|
105
|
+
) {
|
|
106
|
+
throw new Error(`${name} must be a valid date in YYYY-MM-DD format.`);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
if (config.type === 'status') {
|
|
111
|
+
if (!OPERATIONAL_STATUSES.has(value)) {
|
|
112
|
+
throw new Error(`${name} must be one of: ${[...OPERATIONAL_STATUSES].join(', ')}.`);
|
|
113
|
+
}
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
if (!/^-?\d+$/.test(value)) throw new Error(`${name} must be an integer.`);
|
|
117
|
+
const number = Number(value);
|
|
118
|
+
if (!Number.isSafeInteger(number) || number < config.min || number > config.max) {
|
|
119
|
+
throw new Error(`${name} must be between ${config.min} and ${config.max}.`);
|
|
120
|
+
}
|
|
121
|
+
return number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function vehicleBody(args, { allowClear = false } = {}) {
|
|
125
|
+
const body = {};
|
|
126
|
+
const recognised = new Set(['--json']);
|
|
127
|
+
for (const [name, config] of VEHICLE_OPTIONS) {
|
|
128
|
+
recognised.add(name);
|
|
129
|
+
const value = requiredValue(args, name);
|
|
130
|
+
if (value !== null) body[config.field] = parseVehicleValue(name, value, config);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (allowClear) {
|
|
134
|
+
recognised.add('--clear');
|
|
135
|
+
const clear = requiredValue(args, '--clear');
|
|
136
|
+
if (clear !== null) {
|
|
137
|
+
for (const field of clear
|
|
138
|
+
.split(',')
|
|
139
|
+
.map((value) => value.trim())
|
|
140
|
+
.filter(Boolean)) {
|
|
141
|
+
if (!CLEARABLE_FIELDS.has(field)) {
|
|
142
|
+
throw new Error(`--clear contains an unknown or non-clearable field: ${field}.`);
|
|
143
|
+
}
|
|
144
|
+
if (Object.hasOwn(body, field)) {
|
|
145
|
+
throw new Error(`${field} cannot be set and cleared in the same request.`);
|
|
146
|
+
}
|
|
147
|
+
body[field] = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const arg of args) {
|
|
153
|
+
if (arg.startsWith('--') && !recognised.has(arg)) throw new Error(`Unknown option: ${arg}.`);
|
|
154
|
+
}
|
|
155
|
+
return body;
|
|
156
|
+
}
|
|
157
|
+
|
|
40
158
|
function apiOrigin() {
|
|
41
159
|
const value = (process.env.FLEETKEEP_API_URL || DEFAULT_API_URL).trim().replace(/\/$/, '');
|
|
42
160
|
let url;
|
|
@@ -62,7 +180,10 @@ async function request(path, { method = 'GET', body }) {
|
|
|
62
180
|
},
|
|
63
181
|
...(body ? { body: JSON.stringify(body) } : {})
|
|
64
182
|
});
|
|
65
|
-
const payload =
|
|
183
|
+
const payload =
|
|
184
|
+
response.status === 204
|
|
185
|
+
? null
|
|
186
|
+
: await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
|
|
66
187
|
if (!response.ok) {
|
|
67
188
|
const code = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`;
|
|
68
189
|
throw new Error(`Fleetkeep API rejected the request (${response.status}): ${code}`);
|
|
@@ -70,6 +191,15 @@ async function request(path, { method = 'GET', body }) {
|
|
|
70
191
|
return payload;
|
|
71
192
|
}
|
|
72
193
|
|
|
194
|
+
function vehiclePath(id) {
|
|
195
|
+
if (!id || id.startsWith('--')) throw new Error('A vehicle ID is required.');
|
|
196
|
+
return `/api/v1/vehicles/${encodeURIComponent(id)}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function printJsonOr(payload, jsonOutput, line) {
|
|
200
|
+
process.stdout.write(jsonOutput ? `${JSON.stringify(payload, null, 2)}\n` : `${line}\n`);
|
|
201
|
+
}
|
|
202
|
+
|
|
73
203
|
async function spreadsheetPayload(fileArg) {
|
|
74
204
|
if (!fileArg || fileArg.startsWith('--')) throw new Error('A .csv or .xlsx file is required.');
|
|
75
205
|
const path = resolve(fileArg);
|
|
@@ -163,6 +293,70 @@ async function main() {
|
|
|
163
293
|
return;
|
|
164
294
|
}
|
|
165
295
|
|
|
296
|
+
if (args[0] === 'vehicles' && args[1] === 'get') {
|
|
297
|
+
const payload = await request(vehiclePath(args[2]), {});
|
|
298
|
+
if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
299
|
+
else {
|
|
300
|
+
const vehicle = payload.vehicle;
|
|
301
|
+
process.stdout.write(
|
|
302
|
+
`${vehicle.registration}\t${vehicle.label || ''}\t${vehicle.make || ''}\t${vehicle.operationalStatus || ''}\n`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (args[0] === 'vehicles' && args[1] === 'add') {
|
|
309
|
+
const registration = args[2];
|
|
310
|
+
if (!registration || registration.startsWith('--'))
|
|
311
|
+
throw new Error('A registration is required.');
|
|
312
|
+
const body = { registration, ...vehicleBody(args.slice(3)) };
|
|
313
|
+
const payload = await request('/api/v1/vehicles', { method: 'POST', body });
|
|
314
|
+
printJsonOr(payload, jsonOutput, `Vehicle added: ${registration} (${payload.id})`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (args[0] === 'vehicles' && args[1] === 'update') {
|
|
319
|
+
const body = vehicleBody(args.slice(3), { allowClear: true });
|
|
320
|
+
if (Object.keys(body).length === 0) {
|
|
321
|
+
throw new Error('Update requires at least one vehicle option or --clear field.');
|
|
322
|
+
}
|
|
323
|
+
const payload = await request(vehiclePath(args[2]), { method: 'PATCH', body });
|
|
324
|
+
printJsonOr(payload, jsonOutput, `Vehicle updated: ${args[2]}`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (args[0] === 'vehicles' && args[1] === 'delete') {
|
|
329
|
+
const id = args[2];
|
|
330
|
+
const confirmId = option(args, '--confirm');
|
|
331
|
+
if (!id || id.startsWith('--')) throw new Error('A vehicle ID is required.');
|
|
332
|
+
if (confirmId !== id) {
|
|
333
|
+
throw new Error('Delete is blocked until --confirm exactly matches the vehicle ID.');
|
|
334
|
+
}
|
|
335
|
+
await request(vehiclePath(id), { method: 'DELETE' });
|
|
336
|
+
printJsonOr({ ok: true, deletedVehicleId: id }, jsonOutput, `Vehicle deleted: ${id}`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (args[0] === 'vehicles' && args[1] === 'bulk-add') {
|
|
341
|
+
const registrations = args.slice(2).filter((arg) => arg !== '--json');
|
|
342
|
+
if (registrations.length === 0) throw new Error('At least one registration is required.');
|
|
343
|
+
if (registrations.length > 50) throw new Error('Bulk add accepts at most 50 registrations.');
|
|
344
|
+
const payload = await request('/api/v1/vehicles/bulk', {
|
|
345
|
+
method: 'POST',
|
|
346
|
+
body: { registrations }
|
|
347
|
+
});
|
|
348
|
+
if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
349
|
+
else {
|
|
350
|
+
for (const vehicle of payload.created || []) {
|
|
351
|
+
process.stdout.write(`Added: ${vehicle.registration} (${vehicle.id})\n`);
|
|
352
|
+
}
|
|
353
|
+
for (const error of payload.errors || []) {
|
|
354
|
+
process.stdout.write(`Not added: ${error.reg} (${error.reason})\n`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
166
360
|
if (args[0] === 'import' && args[1] === 'preview') {
|
|
167
361
|
const body = await spreadsheetPayload(args[2]);
|
|
168
362
|
const payload = await request('/api/v1/imports/vehicles/preview', {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fleetkeep/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Manage Fleetkeep vehicles and guarded fleet spreadsheet imports through the Fleetkeep API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"fleetkeep": "fleetkeep.mjs"
|