@fleetkeep/cli 0.1.2 → 0.4.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 +42 -12
- package/api-command.mjs +288 -0
- package/auth.mjs +18 -1
- package/fleetkeep.mjs +301 -6
- package/operations.mjs +243 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
# Fleetkeep CLI
|
|
2
2
|
|
|
3
|
-
The Fleetkeep CLI
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The Fleetkeep CLI lets an operator or software agent use every operation in Fleetkeep's published
|
|
4
|
+
API. Friendly commands cover common vehicle and spreadsheet work. The allowlisted `api call`
|
|
5
|
+
command covers equipment, maintenance, defects, walkarounds, files, reminders, contacts, bookings,
|
|
6
|
+
tolls, tracking, guidance and exports without opening arbitrary or unpublished paths.
|
|
6
7
|
|
|
7
8
|
## Safety model
|
|
8
9
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
10
|
+
- `api operations` is generated as a fixed allowlist of the published OpenAPI operation IDs.
|
|
11
|
+
- Consequential actions use short-lived server-issued confirmation tokens. An unconfirmed request
|
|
12
|
+
returns the token without making the change; review it before repeating with `--confirmation`.
|
|
13
|
+
- Retry-safe creates and sends require `--idempotency-key` when the API contract requires it.
|
|
14
|
+
- Vehicle and toll imports remain preview-first and require the exact preview digest before apply.
|
|
15
|
+
- Multipart uploads accept only explicit `--field` and `--file` values. Each file is capped at 10 MB.
|
|
16
|
+
- Downloads require an explicit output path. Existing files are not replaced unless `--overwrite`
|
|
17
|
+
is present.
|
|
18
|
+
- Updates distinguish omitted fields from fields explicitly cleared with `--clear`.
|
|
14
19
|
- Interactive login uses an OAuth 2.1 public client with S256 PKCE and a loopback callback.
|
|
15
20
|
- Stored access and refresh tokens use an owner-only local file and are never accepted as command
|
|
16
21
|
arguments.
|
|
17
22
|
- `FLEETKEEP_ACCESS_TOKEN` remains available as an explicit environment override for automation.
|
|
18
|
-
-
|
|
23
|
+
- OAuth asks only for the published operational scopes. Account deletion, billing, OAuth-client
|
|
24
|
+
administration and telematics credential operations are not available.
|
|
19
25
|
|
|
20
26
|
## Use
|
|
21
27
|
|
|
@@ -24,10 +30,34 @@ Node.js 20 or newer is required. Sign in through Fleetkeep in your browser, then
|
|
|
24
30
|
```sh
|
|
25
31
|
fleetkeep auth login
|
|
26
32
|
fleetkeep vehicles list
|
|
33
|
+
fleetkeep vehicles get <vehicle-id> --json
|
|
34
|
+
fleetkeep vehicles add "AB12 CDE" --label "Service van" --operational-status active --json
|
|
35
|
+
fleetkeep vehicles update <vehicle-id> --operational-status off_road --status-notes "Unsafe tyre"
|
|
36
|
+
fleetkeep vehicles update <vehicle-id> --clear assignedDriverName,assignedDriverContact
|
|
37
|
+
fleetkeep vehicles bulk-add "AB12 CDE" "XY34 ZZZ" --json
|
|
38
|
+
fleetkeep vehicles delete <vehicle-id> --json
|
|
39
|
+
fleetkeep vehicles delete <vehicle-id> --confirmation <server-token> --json
|
|
40
|
+
fleetkeep drivers list
|
|
41
|
+
fleetkeep drivers invite driver@example.com --name "Pat Driver"
|
|
42
|
+
fleetkeep drivers resend <driver-id>
|
|
43
|
+
fleetkeep drivers revoke <driver-id>
|
|
27
44
|
fleetkeep import preview vehicles.xlsx
|
|
28
45
|
fleetkeep import apply vehicles.xlsx --confirm <digest-from-preview>
|
|
46
|
+
fleetkeep api operations --json
|
|
47
|
+
fleetkeep api call listTrackingSnapshots
|
|
48
|
+
fleetkeep api call getWexRouteHistory --path vehicleId=<vehicle-id> --query period=7d
|
|
49
|
+
fleetkeep api call createServiceRecord --path id=<vehicle-id> \
|
|
50
|
+
--idempotency-key <unique-value> --data-file service-record.json
|
|
51
|
+
fleetkeep api call uploadVehiclePhoto --path id=<vehicle-id> --file photo=van.jpg
|
|
52
|
+
fleetkeep api call downloadFleetSpreadsheet --output fleetkeep-export.xlsx
|
|
29
53
|
```
|
|
30
54
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
55
|
+
Run `fleetkeep --help` for API call options and every supported vehicle field. `api call` returns
|
|
56
|
+
JSON for JSON endpoints and a JSON receipt for saved downloads. Use `--path name=value` for each path
|
|
57
|
+
placeholder, `--query name=value` for query values, and `--data` or `--data-file` for JSON bodies.
|
|
58
|
+
Use repeated `--field` and `--file` options for published multipart operations.
|
|
59
|
+
|
|
60
|
+
Set `FLEETKEEP_API_URL` to an alternate origin for local or staging verification. For unattended
|
|
61
|
+
automation, set `FLEETKEEP_ACCESS_TOKEN` in the process environment. OAuth access and refresh tokens
|
|
62
|
+
are never accepted in command arguments. Short-lived action confirmations are supplied separately
|
|
63
|
+
with `--confirmation`.
|
package/api-command.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { extname, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getAccessToken } from './auth.mjs';
|
|
5
|
+
import { operationList, operations } from './operations.mjs';
|
|
6
|
+
|
|
7
|
+
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
const MAX_JSON_BYTES = 15 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
function apiOrigin() {
|
|
11
|
+
const value = (process.env.FLEETKEEP_API_URL || 'https://fleetkeep.co.uk')
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/\/$/, '');
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(value);
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error('FLEETKEEP_API_URL must be a valid HTTP or HTTPS origin.');
|
|
19
|
+
}
|
|
20
|
+
if (
|
|
21
|
+
!['http:', 'https:'].includes(url.protocol) ||
|
|
22
|
+
url.pathname !== '/' ||
|
|
23
|
+
url.search ||
|
|
24
|
+
url.hash ||
|
|
25
|
+
url.username ||
|
|
26
|
+
url.password
|
|
27
|
+
) {
|
|
28
|
+
throw new Error('FLEETKEEP_API_URL must be an HTTP or HTTPS origin without a path.');
|
|
29
|
+
}
|
|
30
|
+
return url.origin;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseAssignment(value, option) {
|
|
34
|
+
const separator = value.indexOf('=');
|
|
35
|
+
if (separator < 1) throw new Error(`${option} requires name=value.`);
|
|
36
|
+
return [value.slice(0, separator), value.slice(separator + 1)];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseCallOptions(args) {
|
|
40
|
+
const parsed = {
|
|
41
|
+
path: [],
|
|
42
|
+
query: [],
|
|
43
|
+
fields: [],
|
|
44
|
+
files: [],
|
|
45
|
+
data: null,
|
|
46
|
+
dataFile: null,
|
|
47
|
+
confirmation: null,
|
|
48
|
+
idempotencyKey: null,
|
|
49
|
+
output: null,
|
|
50
|
+
overwrite: false,
|
|
51
|
+
json: false
|
|
52
|
+
};
|
|
53
|
+
const repeatable = new Map([
|
|
54
|
+
['--path', 'path'],
|
|
55
|
+
['--query', 'query'],
|
|
56
|
+
['--field', 'fields'],
|
|
57
|
+
['--file', 'files']
|
|
58
|
+
]);
|
|
59
|
+
const single = new Map([
|
|
60
|
+
['--data', 'data'],
|
|
61
|
+
['--data-file', 'dataFile'],
|
|
62
|
+
['--confirmation', 'confirmation'],
|
|
63
|
+
['--idempotency-key', 'idempotencyKey'],
|
|
64
|
+
['--output', 'output']
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
for (let index = 0; index < args.length; index++) {
|
|
68
|
+
const arg = args[index];
|
|
69
|
+
if (arg === '--overwrite' || arg === '--json') {
|
|
70
|
+
parsed[arg === '--overwrite' ? 'overwrite' : 'json'] = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const target = repeatable.get(arg) || single.get(arg);
|
|
74
|
+
if (!target) throw new Error(`Unknown API call option: ${arg}.`);
|
|
75
|
+
const value = args[++index];
|
|
76
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`${arg} requires a value.`);
|
|
77
|
+
if (repeatable.has(arg)) parsed[target].push(parseAssignment(value, arg));
|
|
78
|
+
else if (parsed[target] !== null) throw new Error(`${arg} may be supplied only once.`);
|
|
79
|
+
else parsed[target] = value;
|
|
80
|
+
}
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function operationPath(operation, assignments) {
|
|
85
|
+
const values = new Map();
|
|
86
|
+
for (const [name, value] of assignments) {
|
|
87
|
+
if (values.has(name)) throw new Error(`--path ${name} was supplied more than once.`);
|
|
88
|
+
values.set(name, value);
|
|
89
|
+
}
|
|
90
|
+
const expected = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
|
|
91
|
+
for (const name of values.keys()) {
|
|
92
|
+
if (!expected.includes(name))
|
|
93
|
+
throw new Error(`Unknown path parameter for ${operation.id}: ${name}.`);
|
|
94
|
+
}
|
|
95
|
+
let path = operation.path;
|
|
96
|
+
for (const name of expected) {
|
|
97
|
+
if (!values.has(name) || values.get(name) === '') {
|
|
98
|
+
throw new Error(`${operation.id} requires --path ${name}=<value>.`);
|
|
99
|
+
}
|
|
100
|
+
path = path.replace(`{${name}}`, encodeURIComponent(values.get(name)));
|
|
101
|
+
}
|
|
102
|
+
return path;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function mimeType(path) {
|
|
106
|
+
return (
|
|
107
|
+
{
|
|
108
|
+
'.csv': 'text/csv',
|
|
109
|
+
'.doc': 'application/msword',
|
|
110
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
111
|
+
'.jpeg': 'image/jpeg',
|
|
112
|
+
'.jpg': 'image/jpeg',
|
|
113
|
+
'.mov': 'video/quicktime',
|
|
114
|
+
'.mp4': 'video/mp4',
|
|
115
|
+
'.pdf': 'application/pdf',
|
|
116
|
+
'.png': 'image/png',
|
|
117
|
+
'.webm': 'video/webm',
|
|
118
|
+
'.webp': 'image/webp',
|
|
119
|
+
'.xls': 'application/vnd.ms-excel',
|
|
120
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
121
|
+
}[extname(path).toLowerCase()] || 'application/octet-stream'
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readBounded(path, maxBytes, label) {
|
|
126
|
+
const bytes = await readFile(resolve(path));
|
|
127
|
+
if (bytes.length > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes.`);
|
|
128
|
+
return bytes;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function requestBody(operation, options, headers) {
|
|
132
|
+
const hasMultipart = options.fields.length > 0 || options.files.length > 0;
|
|
133
|
+
if ((options.data !== null || options.dataFile !== null) && hasMultipart) {
|
|
134
|
+
throw new Error('--data or --data-file cannot be combined with --field or --file.');
|
|
135
|
+
}
|
|
136
|
+
if (options.data !== null && options.dataFile !== null) {
|
|
137
|
+
throw new Error('--data and --data-file cannot be combined.');
|
|
138
|
+
}
|
|
139
|
+
if (hasMultipart && !operation.multipart) {
|
|
140
|
+
throw new Error(`${operation.id} does not publish a multipart request.`);
|
|
141
|
+
}
|
|
142
|
+
if (hasMultipart) {
|
|
143
|
+
const form = new FormData();
|
|
144
|
+
for (const [name, value] of options.fields) form.append(name, value);
|
|
145
|
+
for (const [name, path] of options.files) {
|
|
146
|
+
const bytes = await readBounded(path, MAX_UPLOAD_BYTES, `Upload ${path}`);
|
|
147
|
+
form.append(
|
|
148
|
+
name,
|
|
149
|
+
new Blob([bytes], { type: mimeType(path) }),
|
|
150
|
+
resolve(path).split('/').pop()
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return form;
|
|
154
|
+
}
|
|
155
|
+
if (options.data === null && options.dataFile === null) return undefined;
|
|
156
|
+
const source =
|
|
157
|
+
options.data !== null
|
|
158
|
+
? options.data
|
|
159
|
+
: (await readBounded(options.dataFile, MAX_JSON_BYTES, 'JSON body')).toString('utf8');
|
|
160
|
+
let value;
|
|
161
|
+
try {
|
|
162
|
+
value = JSON.parse(source);
|
|
163
|
+
} catch {
|
|
164
|
+
throw new Error('The request body must be valid JSON.');
|
|
165
|
+
}
|
|
166
|
+
headers.set('content-type', 'application/json');
|
|
167
|
+
return JSON.stringify(value);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function confirmationResult(payload, operationId) {
|
|
171
|
+
if (
|
|
172
|
+
payload &&
|
|
173
|
+
typeof payload === 'object' &&
|
|
174
|
+
payload.error === 'confirmation_required' &&
|
|
175
|
+
typeof payload.confirmationToken === 'string'
|
|
176
|
+
) {
|
|
177
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
178
|
+
process.stderr.write(
|
|
179
|
+
`No change was made. Review the action, then repeat ${operationId} with --confirmation <confirmationToken>.\n`
|
|
180
|
+
);
|
|
181
|
+
process.exitCode = 2;
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function writeOutput(path, bytes, overwrite) {
|
|
188
|
+
const destination = resolve(path);
|
|
189
|
+
try {
|
|
190
|
+
await writeFile(destination, bytes, overwrite ? undefined : { flag: 'wx' });
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
|
|
193
|
+
throw new Error(`Output already exists: ${destination}. Use --overwrite to replace it.`, {
|
|
194
|
+
cause: error
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
return destination;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function callOperation(operation, args) {
|
|
203
|
+
const options = parseCallOptions(args);
|
|
204
|
+
if (operation.binary && !options.output) {
|
|
205
|
+
throw new Error(`${operation.id} returns a file and requires --output <path>.`);
|
|
206
|
+
}
|
|
207
|
+
if (operation.idempotency && !options.idempotencyKey) {
|
|
208
|
+
throw new Error(`${operation.id} requires --idempotency-key <unique-value>.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const url = new URL(operationPath(operation, options.path), apiOrigin());
|
|
212
|
+
for (const [name, value] of options.query) {
|
|
213
|
+
if (!operation.query?.includes(name)) {
|
|
214
|
+
throw new Error(`Unknown query parameter for ${operation.id}: ${name}.`);
|
|
215
|
+
}
|
|
216
|
+
url.searchParams.append(name, value);
|
|
217
|
+
}
|
|
218
|
+
const headers = new Headers({
|
|
219
|
+
authorization: `Bearer ${await getAccessToken()}`,
|
|
220
|
+
accept: 'application/json, application/octet-stream;q=0.9, */*;q=0.8'
|
|
221
|
+
});
|
|
222
|
+
if (options.confirmation) headers.set('x-fleetkeep-confirmation', options.confirmation);
|
|
223
|
+
if (options.idempotencyKey) headers.set('idempotency-key', options.idempotencyKey);
|
|
224
|
+
const body = await requestBody(operation, options, headers);
|
|
225
|
+
const response = await fetch(url, {
|
|
226
|
+
method: operation.method,
|
|
227
|
+
headers,
|
|
228
|
+
...(body ? { body } : {})
|
|
229
|
+
});
|
|
230
|
+
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
|
231
|
+
|
|
232
|
+
if (!response.ok) {
|
|
233
|
+
const payload = contentType.includes('json')
|
|
234
|
+
? await response.json().catch(() => null)
|
|
235
|
+
: { error: (await response.text()).slice(0, 500) || `HTTP ${response.status}` };
|
|
236
|
+
if (response.status === 409 && confirmationResult(payload, operation.id)) return;
|
|
237
|
+
const code = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`;
|
|
238
|
+
throw new Error(`Fleetkeep API rejected ${operation.id} (${response.status}): ${code}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (options.output) {
|
|
242
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
243
|
+
const destination = await writeOutput(options.output, bytes, options.overwrite);
|
|
244
|
+
process.stdout.write(
|
|
245
|
+
`${JSON.stringify({ ok: true, operationId: operation.id, status: response.status, output: destination, bytes: bytes.length, contentType }, null, 2)}\n`
|
|
246
|
+
);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (response.status === 204) {
|
|
250
|
+
process.stdout.write(`${JSON.stringify({ ok: true, operationId: operation.id }, null, 2)}\n`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (!contentType.includes('json')) {
|
|
254
|
+
throw new Error(`${operation.id} returned ${contentType}; repeat with --output <path>.`);
|
|
255
|
+
}
|
|
256
|
+
const payload = await response.json();
|
|
257
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function printOperations(jsonOutput) {
|
|
261
|
+
if (jsonOutput) {
|
|
262
|
+
process.stdout.write(`${JSON.stringify({ operations: operationList }, null, 2)}\n`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
for (const operation of operationList) {
|
|
266
|
+
process.stdout.write(
|
|
267
|
+
`${operation.id}\t${operation.method}\t${operation.path}\t${operation.scope}\n`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function runApiCommand(args) {
|
|
273
|
+
if (args[0] === 'operations') {
|
|
274
|
+
const unknown = args.filter((arg) => arg !== 'operations' && arg !== '--json');
|
|
275
|
+
if (unknown.length) throw new Error(`Unknown API operations option: ${unknown[0]}.`);
|
|
276
|
+
printOperations(args.includes('--json'));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (args[0] !== 'call')
|
|
280
|
+
throw new Error('Use `fleetkeep api operations` or `fleetkeep api call`.');
|
|
281
|
+
const operationId = args[1];
|
|
282
|
+
if (!operationId || operationId.startsWith('--')) throw new Error('An operation ID is required.');
|
|
283
|
+
const operation = operations[operationId];
|
|
284
|
+
if (!operation) {
|
|
285
|
+
throw new Error(`Unknown or unpublished Fleetkeep operation: ${operationId}.`);
|
|
286
|
+
}
|
|
287
|
+
await callOperation(operation, args.slice(2));
|
|
288
|
+
}
|
package/auth.mjs
CHANGED
|
@@ -8,7 +8,24 @@ import { spawn } from 'node:child_process';
|
|
|
8
8
|
const DEFAULT_API_URL = 'https://fleetkeep.co.uk';
|
|
9
9
|
const DEFAULT_CLIENT_ID = 'fleetkeep-cli';
|
|
10
10
|
const DEFAULT_REDIRECT_URI = 'http://127.0.0.1:38741/callback';
|
|
11
|
-
const DEFAULT_SCOPES = [
|
|
11
|
+
export const DEFAULT_SCOPES = [
|
|
12
|
+
'vehicles:read',
|
|
13
|
+
'vehicles:write',
|
|
14
|
+
'operations:read',
|
|
15
|
+
'operations:write',
|
|
16
|
+
'tolls:read',
|
|
17
|
+
'tolls:write',
|
|
18
|
+
'documents:read',
|
|
19
|
+
'documents:write',
|
|
20
|
+
'maintenance:read',
|
|
21
|
+
'maintenance:write',
|
|
22
|
+
'reminders:read',
|
|
23
|
+
'reminders:write',
|
|
24
|
+
'profile:read',
|
|
25
|
+
'guidance:read',
|
|
26
|
+
'exports:read',
|
|
27
|
+
'offline_access'
|
|
28
|
+
];
|
|
12
29
|
const AUTH_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
30
|
|
|
14
31
|
/** @typedef {{ accessToken: string, refreshToken?: string | null, expiresAt: number }} StoredTokens */
|
package/fleetkeep.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, extname, resolve } from 'node:path';
|
|
5
|
+
import { runApiCommand } from './api-command.mjs';
|
|
5
6
|
import { authStatus, getAccessToken, login, logout } from './auth.mjs';
|
|
6
7
|
|
|
7
8
|
const DEFAULT_API_URL = 'https://fleetkeep.co.uk';
|
|
@@ -14,15 +15,62 @@ Usage:
|
|
|
14
15
|
fleetkeep auth status
|
|
15
16
|
fleetkeep auth logout
|
|
16
17
|
fleetkeep vehicles list [--json]
|
|
18
|
+
fleetkeep vehicles get <vehicle-id> [--json]
|
|
19
|
+
fleetkeep vehicles add <registration> [vehicle options] [--json]
|
|
20
|
+
fleetkeep vehicles update <vehicle-id> [vehicle options] [--clear <field,...>] [--json]
|
|
21
|
+
fleetkeep vehicles delete <vehicle-id> [--confirmation <server-token>] [--json]
|
|
22
|
+
fleetkeep vehicles bulk-add <registration>... [--json]
|
|
23
|
+
fleetkeep drivers list [--json]
|
|
24
|
+
fleetkeep drivers invite <email> --name <name> [--json]
|
|
25
|
+
fleetkeep drivers resend <driver-id> [--json]
|
|
26
|
+
fleetkeep drivers revoke <driver-id> [--json]
|
|
17
27
|
fleetkeep import preview <file.csv|file.xlsx> [--json]
|
|
18
28
|
fleetkeep import apply <file.csv|file.xlsx> --confirm <digest> [--idempotency-key <key>] [--json]
|
|
29
|
+
fleetkeep api operations [--json]
|
|
30
|
+
fleetkeep api call <operation-id> [API options]
|
|
31
|
+
|
|
32
|
+
API options:
|
|
33
|
+
--path <name=value> Path parameter; repeat when needed
|
|
34
|
+
--query <name=value> Query parameter; repeat when needed
|
|
35
|
+
--data <json> JSON request body
|
|
36
|
+
--data-file <file.json> JSON request body from a file
|
|
37
|
+
--field <name=value> Multipart text field; repeat when needed
|
|
38
|
+
--file <name=path> Multipart file; repeat when needed
|
|
39
|
+
--confirmation <token> Server-issued action confirmation token
|
|
40
|
+
--idempotency-key <key> Required for retry-safe create or send operations
|
|
41
|
+
--output <path> Save a download; existing files are protected
|
|
42
|
+
--overwrite Allow --output to replace an existing file
|
|
43
|
+
|
|
44
|
+
Vehicle options:
|
|
45
|
+
--label <text>
|
|
46
|
+
--insurance-due-date <YYYY-MM-DD>
|
|
47
|
+
--last-service-date <YYYY-MM-DD>
|
|
48
|
+
--last-service-mileage <integer>
|
|
49
|
+
--service-interval-months <integer>
|
|
50
|
+
--operational-status <active|standby|in_maintenance|off_road|sold_or_removed>
|
|
51
|
+
--assigned-driver-name <text>
|
|
52
|
+
--assigned-driver-contact <text>
|
|
53
|
+
--status-notes <text>
|
|
54
|
+
|
|
55
|
+
Clearable update fields:
|
|
56
|
+
label, insuranceDueDate, lastServiceDate, lastServiceMileage, serviceIntervalMonths,
|
|
57
|
+
assignedDriverName, assignedDriverContact, statusNotes
|
|
19
58
|
|
|
20
59
|
Environment:
|
|
21
60
|
FLEETKEEP_ACCESS_TOKEN Optional OAuth token override for automation
|
|
22
61
|
FLEETKEEP_API_URL API origin (default: https://fleetkeep.co.uk)
|
|
23
62
|
|
|
24
63
|
Import is preview-first. Applying requires the exact digest returned by preview.
|
|
25
|
-
|
|
64
|
+
OAuth access and refresh tokens are never accepted as command arguments.`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class FleetkeepApiError extends Error {
|
|
68
|
+
constructor(status, payload) {
|
|
69
|
+
const code = typeof payload?.error === 'string' ? payload.error : `HTTP ${status}`;
|
|
70
|
+
super(`Fleetkeep API rejected the request (${status}): ${code}`);
|
|
71
|
+
this.status = status;
|
|
72
|
+
this.payload = payload;
|
|
73
|
+
}
|
|
26
74
|
}
|
|
27
75
|
|
|
28
76
|
function fail(message, exitCode = 1) {
|
|
@@ -37,6 +85,104 @@ function option(args, name) {
|
|
|
37
85
|
return value && !value.startsWith('--') ? value : '';
|
|
38
86
|
}
|
|
39
87
|
|
|
88
|
+
function requiredValue(args, name) {
|
|
89
|
+
const value = option(args, name);
|
|
90
|
+
if (value === '') throw new Error(`${name} requires a value.`);
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const VEHICLE_OPTIONS = new Map([
|
|
95
|
+
['--label', { field: 'label', type: 'string' }],
|
|
96
|
+
['--insurance-due-date', { field: 'insuranceDueDate', type: 'date' }],
|
|
97
|
+
['--last-service-date', { field: 'lastServiceDate', type: 'date' }],
|
|
98
|
+
[
|
|
99
|
+
'--last-service-mileage',
|
|
100
|
+
{ field: 'lastServiceMileage', type: 'integer', min: 0, max: 1500000 }
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
'--service-interval-months',
|
|
104
|
+
{ field: 'serviceIntervalMonths', type: 'integer', min: 1, max: 120 }
|
|
105
|
+
],
|
|
106
|
+
['--operational-status', { field: 'operationalStatus', type: 'status' }],
|
|
107
|
+
['--assigned-driver-name', { field: 'assignedDriverName', type: 'string' }],
|
|
108
|
+
['--assigned-driver-contact', { field: 'assignedDriverContact', type: 'string' }],
|
|
109
|
+
['--status-notes', { field: 'statusNotes', type: 'string' }]
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const CLEARABLE_FIELDS = new Set(
|
|
113
|
+
[...VEHICLE_OPTIONS.values()]
|
|
114
|
+
.map(({ field }) => field)
|
|
115
|
+
.filter((field) => field !== 'operationalStatus')
|
|
116
|
+
);
|
|
117
|
+
const OPERATIONAL_STATUSES = new Set([
|
|
118
|
+
'active',
|
|
119
|
+
'standby',
|
|
120
|
+
'in_maintenance',
|
|
121
|
+
'off_road',
|
|
122
|
+
'sold_or_removed'
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
function parseVehicleValue(name, value, config) {
|
|
126
|
+
if (config.type === 'string') return value;
|
|
127
|
+
if (config.type === 'date') {
|
|
128
|
+
const date = new Date(`${value}T00:00:00Z`);
|
|
129
|
+
if (
|
|
130
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
|
|
131
|
+
Number.isNaN(date.getTime()) ||
|
|
132
|
+
date.toISOString().slice(0, 10) !== value
|
|
133
|
+
) {
|
|
134
|
+
throw new Error(`${name} must be a valid date in YYYY-MM-DD format.`);
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
if (config.type === 'status') {
|
|
139
|
+
if (!OPERATIONAL_STATUSES.has(value)) {
|
|
140
|
+
throw new Error(`${name} must be one of: ${[...OPERATIONAL_STATUSES].join(', ')}.`);
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
if (!/^-?\d+$/.test(value)) throw new Error(`${name} must be an integer.`);
|
|
145
|
+
const number = Number(value);
|
|
146
|
+
if (!Number.isSafeInteger(number) || number < config.min || number > config.max) {
|
|
147
|
+
throw new Error(`${name} must be between ${config.min} and ${config.max}.`);
|
|
148
|
+
}
|
|
149
|
+
return number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function vehicleBody(args, { allowClear = false } = {}) {
|
|
153
|
+
const body = {};
|
|
154
|
+
const recognised = new Set(['--json']);
|
|
155
|
+
for (const [name, config] of VEHICLE_OPTIONS) {
|
|
156
|
+
recognised.add(name);
|
|
157
|
+
const value = requiredValue(args, name);
|
|
158
|
+
if (value !== null) body[config.field] = parseVehicleValue(name, value, config);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (allowClear) {
|
|
162
|
+
recognised.add('--clear');
|
|
163
|
+
const clear = requiredValue(args, '--clear');
|
|
164
|
+
if (clear !== null) {
|
|
165
|
+
for (const field of clear
|
|
166
|
+
.split(',')
|
|
167
|
+
.map((value) => value.trim())
|
|
168
|
+
.filter(Boolean)) {
|
|
169
|
+
if (!CLEARABLE_FIELDS.has(field)) {
|
|
170
|
+
throw new Error(`--clear contains an unknown or non-clearable field: ${field}.`);
|
|
171
|
+
}
|
|
172
|
+
if (Object.hasOwn(body, field)) {
|
|
173
|
+
throw new Error(`${field} cannot be set and cleared in the same request.`);
|
|
174
|
+
}
|
|
175
|
+
body[field] = null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const arg of args) {
|
|
181
|
+
if (arg.startsWith('--') && !recognised.has(arg)) throw new Error(`Unknown option: ${arg}.`);
|
|
182
|
+
}
|
|
183
|
+
return body;
|
|
184
|
+
}
|
|
185
|
+
|
|
40
186
|
function apiOrigin() {
|
|
41
187
|
const value = (process.env.FLEETKEEP_API_URL || DEFAULT_API_URL).trim().replace(/\/$/, '');
|
|
42
188
|
let url;
|
|
@@ -51,25 +197,37 @@ function apiOrigin() {
|
|
|
51
197
|
return url.origin;
|
|
52
198
|
}
|
|
53
199
|
|
|
54
|
-
async function request(path, { method = 'GET', body }) {
|
|
200
|
+
async function request(path, { method = 'GET', body, headers: extraHeaders = {} }) {
|
|
55
201
|
const token = await getAccessToken();
|
|
56
202
|
const response = await fetch(`${apiOrigin()}${path}`, {
|
|
57
203
|
method,
|
|
58
204
|
headers: {
|
|
59
205
|
authorization: `Bearer ${token}`,
|
|
60
206
|
accept: 'application/json',
|
|
61
|
-
...(body ? { 'content-type': 'application/json' } : {})
|
|
207
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
208
|
+
...extraHeaders
|
|
62
209
|
},
|
|
63
210
|
...(body ? { body: JSON.stringify(body) } : {})
|
|
64
211
|
});
|
|
65
|
-
const payload =
|
|
212
|
+
const payload =
|
|
213
|
+
response.status === 204
|
|
214
|
+
? null
|
|
215
|
+
: await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
|
|
66
216
|
if (!response.ok) {
|
|
67
|
-
|
|
68
|
-
throw new Error(`Fleetkeep API rejected the request (${response.status}): ${code}`);
|
|
217
|
+
throw new FleetkeepApiError(response.status, payload);
|
|
69
218
|
}
|
|
70
219
|
return payload;
|
|
71
220
|
}
|
|
72
221
|
|
|
222
|
+
function vehiclePath(id) {
|
|
223
|
+
if (!id || id.startsWith('--')) throw new Error('A vehicle ID is required.');
|
|
224
|
+
return `/api/v1/vehicles/${encodeURIComponent(id)}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function printJsonOr(payload, jsonOutput, line) {
|
|
228
|
+
process.stdout.write(jsonOutput ? `${JSON.stringify(payload, null, 2)}\n` : `${line}\n`);
|
|
229
|
+
}
|
|
230
|
+
|
|
73
231
|
async function spreadsheetPayload(fileArg) {
|
|
74
232
|
if (!fileArg || fileArg.startsWith('--')) throw new Error('A .csv or .xlsx file is required.');
|
|
75
233
|
const path = resolve(fileArg);
|
|
@@ -150,6 +308,11 @@ async function main() {
|
|
|
150
308
|
return;
|
|
151
309
|
}
|
|
152
310
|
|
|
311
|
+
if (args[0] === 'api') {
|
|
312
|
+
await runApiCommand(args.slice(1));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
153
316
|
if (args[0] === 'vehicles' && args[1] === 'list') {
|
|
154
317
|
const payload = await request('/api/v1/vehicles', {});
|
|
155
318
|
if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
@@ -163,6 +326,138 @@ async function main() {
|
|
|
163
326
|
return;
|
|
164
327
|
}
|
|
165
328
|
|
|
329
|
+
if (args[0] === 'vehicles' && args[1] === 'get') {
|
|
330
|
+
const payload = await request(vehiclePath(args[2]), {});
|
|
331
|
+
if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
332
|
+
else {
|
|
333
|
+
const vehicle = payload.vehicle;
|
|
334
|
+
process.stdout.write(
|
|
335
|
+
`${vehicle.registration}\t${vehicle.label || ''}\t${vehicle.make || ''}\t${vehicle.operationalStatus || ''}\n`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (args[0] === 'vehicles' && args[1] === 'add') {
|
|
342
|
+
const registration = args[2];
|
|
343
|
+
if (!registration || registration.startsWith('--'))
|
|
344
|
+
throw new Error('A registration is required.');
|
|
345
|
+
const body = { registration, ...vehicleBody(args.slice(3)) };
|
|
346
|
+
const payload = await request('/api/v1/vehicles', { method: 'POST', body });
|
|
347
|
+
printJsonOr(payload, jsonOutput, `Vehicle added: ${registration} (${payload.id})`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (args[0] === 'vehicles' && args[1] === 'update') {
|
|
352
|
+
const body = vehicleBody(args.slice(3), { allowClear: true });
|
|
353
|
+
if (Object.keys(body).length === 0) {
|
|
354
|
+
throw new Error('Update requires at least one vehicle option or --clear field.');
|
|
355
|
+
}
|
|
356
|
+
const payload = await request(vehiclePath(args[2]), { method: 'PATCH', body });
|
|
357
|
+
printJsonOr(payload, jsonOutput, `Vehicle updated: ${args[2]}`);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (args[0] === 'vehicles' && args[1] === 'delete') {
|
|
362
|
+
const id = args[2];
|
|
363
|
+
if (!id || id.startsWith('--')) throw new Error('A vehicle ID is required.');
|
|
364
|
+
const confirmation = option(args, '--confirmation');
|
|
365
|
+
try {
|
|
366
|
+
await request(vehiclePath(id), {
|
|
367
|
+
method: 'DELETE',
|
|
368
|
+
headers: confirmation ? { 'x-fleetkeep-confirmation': confirmation } : {}
|
|
369
|
+
});
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (
|
|
372
|
+
error instanceof FleetkeepApiError &&
|
|
373
|
+
error.status === 409 &&
|
|
374
|
+
error.payload?.error === 'confirmation_required' &&
|
|
375
|
+
typeof error.payload.confirmationToken === 'string'
|
|
376
|
+
) {
|
|
377
|
+
if (jsonOutput) process.stdout.write(`${JSON.stringify(error.payload, null, 2)}\n`);
|
|
378
|
+
else {
|
|
379
|
+
process.stdout.write(
|
|
380
|
+
`No vehicle was deleted. Review the vehicle, then repeat with --confirmation ${error.payload.confirmationToken}\n`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
process.exitCode = 2;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
printJsonOr({ ok: true, deletedVehicleId: id }, jsonOutput, `Vehicle deleted: ${id}`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (args[0] === 'vehicles' && args[1] === 'bulk-add') {
|
|
393
|
+
const registrations = args.slice(2).filter((arg) => arg !== '--json');
|
|
394
|
+
if (registrations.length === 0) throw new Error('At least one registration is required.');
|
|
395
|
+
if (registrations.length > 50) throw new Error('Bulk add accepts at most 50 registrations.');
|
|
396
|
+
const payload = await request('/api/v1/vehicles/bulk', {
|
|
397
|
+
method: 'POST',
|
|
398
|
+
body: { registrations }
|
|
399
|
+
});
|
|
400
|
+
if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
401
|
+
else {
|
|
402
|
+
for (const vehicle of payload.created || []) {
|
|
403
|
+
process.stdout.write(`Added: ${vehicle.registration} (${vehicle.id})\n`);
|
|
404
|
+
}
|
|
405
|
+
for (const error of payload.errors || []) {
|
|
406
|
+
process.stdout.write(`Not added: ${error.reg} (${error.reason})\n`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (args[0] === 'drivers' && args[1] === 'list') {
|
|
413
|
+
const payload = await request('/api/v1/drivers', {});
|
|
414
|
+
if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
415
|
+
else {
|
|
416
|
+
for (const driver of payload.drivers || []) {
|
|
417
|
+
const state = driver.revokedAt
|
|
418
|
+
? 'access removed'
|
|
419
|
+
: driver.acceptedAt
|
|
420
|
+
? 'active'
|
|
421
|
+
: 'invitation pending';
|
|
422
|
+
process.stdout.write(`${driver.id}\t${driver.name}\t${driver.email}\t${state}\n`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (args[0] === 'drivers' && args[1] === 'invite') {
|
|
429
|
+
const email = args[2];
|
|
430
|
+
const name = option(args, '--name');
|
|
431
|
+
if (!email || email.startsWith('--')) throw new Error('A driver email address is required.');
|
|
432
|
+
if (!name) throw new Error('--name is required.');
|
|
433
|
+
const payload = await request('/api/v1/drivers', {
|
|
434
|
+
method: 'POST',
|
|
435
|
+
body: { email, name }
|
|
436
|
+
});
|
|
437
|
+
printJsonOr(
|
|
438
|
+
payload,
|
|
439
|
+
jsonOutput,
|
|
440
|
+
`Invitation emailed to ${payload.driver.email} (${payload.driver.id})`
|
|
441
|
+
);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (args[0] === 'drivers' && ['resend', 'revoke'].includes(args[1])) {
|
|
446
|
+
const id = args[2];
|
|
447
|
+
if (!id || id.startsWith('--')) throw new Error('A driver ID is required.');
|
|
448
|
+
const action = args[1];
|
|
449
|
+
const payload = await request(`/api/v1/drivers/${encodeURIComponent(id)}`, {
|
|
450
|
+
method: 'POST',
|
|
451
|
+
body: { action }
|
|
452
|
+
});
|
|
453
|
+
printJsonOr(
|
|
454
|
+
payload,
|
|
455
|
+
jsonOutput,
|
|
456
|
+
action === 'resend' ? 'Invitation emailed again.' : 'Driver access removed.'
|
|
457
|
+
);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
166
461
|
if (args[0] === 'import' && args[1] === 'preview') {
|
|
167
462
|
const body = await spreadsheetPayload(args[2]);
|
|
168
463
|
const payload = await request('/api/v1/imports/vehicles/preview', {
|
package/operations.mjs
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/** @type {Array<[string, string, string, string, Record<string, unknown>?]>} */
|
|
2
|
+
const definitions = [
|
|
3
|
+
['listVehicles', 'GET', '/api/v1/vehicles', 'vehicles:read'],
|
|
4
|
+
['addVehicle', 'POST', '/api/v1/vehicles', 'vehicles:write'],
|
|
5
|
+
['getVehicle', 'GET', '/api/v1/vehicles/{id}', 'vehicles:read'],
|
|
6
|
+
['updateVehicle', 'PATCH', '/api/v1/vehicles/{id}', 'vehicles:write'],
|
|
7
|
+
['deleteVehicle', 'DELETE', '/api/v1/vehicles/{id}', 'vehicles:write', { confirmation: true }],
|
|
8
|
+
['listVehicleEquipment', 'GET', '/api/v1/vehicles/{id}/checks', 'vehicles:read'],
|
|
9
|
+
['addVehicleEquipment', 'POST', '/api/v1/vehicles/{id}/checks', 'vehicles:write'],
|
|
10
|
+
['updateVehicleEquipment', 'PATCH', '/api/v1/vehicles/{id}/checks', 'vehicles:write'],
|
|
11
|
+
[
|
|
12
|
+
'removeVehicleEquipment',
|
|
13
|
+
'DELETE',
|
|
14
|
+
'/api/v1/vehicles/{id}/checks',
|
|
15
|
+
'vehicles:write',
|
|
16
|
+
{ confirmation: true }
|
|
17
|
+
],
|
|
18
|
+
['listEquipmentTemplates', 'GET', '/api/v1/equipment/templates', 'vehicles:read'],
|
|
19
|
+
['previewEquipmentTemplate', 'POST', '/api/v1/equipment/templates', 'vehicles:read'],
|
|
20
|
+
['applyEquipmentTemplate', 'PUT', '/api/v1/equipment/templates', 'vehicles:write'],
|
|
21
|
+
['bulkAddVehicles', 'POST', '/api/v1/vehicles/bulk', 'vehicles:write'],
|
|
22
|
+
['previewVehicleImport', 'POST', '/api/v1/imports/vehicles/preview', 'vehicles:read'],
|
|
23
|
+
['applyVehicleImport', 'POST', '/api/v1/imports/vehicles/apply', 'vehicles:write'],
|
|
24
|
+
['getWalkaroundDefinition', 'GET', '/api/v1/operations/walkaround-definition', 'operations:read'],
|
|
25
|
+
[
|
|
26
|
+
'listWalkarounds',
|
|
27
|
+
'GET',
|
|
28
|
+
'/api/v1/vehicles/{id}/walkarounds',
|
|
29
|
+
'operations:read',
|
|
30
|
+
{ query: ['limit', 'offset'] }
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
'submitWalkaround',
|
|
34
|
+
'POST',
|
|
35
|
+
'/api/v1/vehicles/{id}/walkarounds',
|
|
36
|
+
'operations:write',
|
|
37
|
+
{ confirmation: true, multipart: true }
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
'getWalkaroundEvidence',
|
|
41
|
+
'GET',
|
|
42
|
+
'/api/v1/vehicles/{id}/walkarounds/{checkId}/media/{mediaId}',
|
|
43
|
+
'operations:read',
|
|
44
|
+
{ binary: true }
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
'listVehicleDocuments',
|
|
48
|
+
'GET',
|
|
49
|
+
'/api/v1/vehicles/{id}/documents',
|
|
50
|
+
'documents:read',
|
|
51
|
+
{ query: ['limit', 'offset'] }
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
'createVehicleDocument',
|
|
55
|
+
'POST',
|
|
56
|
+
'/api/v1/vehicles/{id}/documents',
|
|
57
|
+
'documents:write',
|
|
58
|
+
{ multipart: true }
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
'downloadVehicleDocument',
|
|
62
|
+
'GET',
|
|
63
|
+
'/api/v1/vehicles/{id}/documents/{documentId}/download',
|
|
64
|
+
'documents:read',
|
|
65
|
+
{ binary: true }
|
|
66
|
+
],
|
|
67
|
+
['getVehiclePhoto', 'GET', '/api/v1/vehicles/{id}/photo', 'vehicles:read', { binary: true }],
|
|
68
|
+
[
|
|
69
|
+
'uploadVehiclePhoto',
|
|
70
|
+
'POST',
|
|
71
|
+
'/api/v1/vehicles/{id}/photo',
|
|
72
|
+
'vehicles:write',
|
|
73
|
+
{ multipart: true }
|
|
74
|
+
],
|
|
75
|
+
[
|
|
76
|
+
'deleteVehiclePhoto',
|
|
77
|
+
'DELETE',
|
|
78
|
+
'/api/v1/vehicles/{id}/photo',
|
|
79
|
+
'vehicles:write',
|
|
80
|
+
{ confirmation: true }
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
'listServiceRecords',
|
|
84
|
+
'GET',
|
|
85
|
+
'/api/v1/vehicles/{id}/service-records',
|
|
86
|
+
'maintenance:read',
|
|
87
|
+
{ query: ['limit', 'offset'] }
|
|
88
|
+
],
|
|
89
|
+
[
|
|
90
|
+
'createServiceRecord',
|
|
91
|
+
'POST',
|
|
92
|
+
'/api/v1/vehicles/{id}/service-records',
|
|
93
|
+
'maintenance:write',
|
|
94
|
+
{ idempotency: true }
|
|
95
|
+
],
|
|
96
|
+
[
|
|
97
|
+
'getServiceRecord',
|
|
98
|
+
'GET',
|
|
99
|
+
'/api/v1/vehicles/{id}/service-records/{recordId}',
|
|
100
|
+
'maintenance:read'
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
'updateServiceRecord',
|
|
104
|
+
'PUT',
|
|
105
|
+
'/api/v1/vehicles/{id}/service-records/{recordId}',
|
|
106
|
+
'maintenance:write',
|
|
107
|
+
{ confirmation: true }
|
|
108
|
+
],
|
|
109
|
+
[
|
|
110
|
+
'deleteServiceRecord',
|
|
111
|
+
'DELETE',
|
|
112
|
+
'/api/v1/vehicles/{id}/service-records/{recordId}',
|
|
113
|
+
'maintenance:write',
|
|
114
|
+
{ confirmation: true }
|
|
115
|
+
],
|
|
116
|
+
['listMaintenance', 'GET', '/api/v1/operations/maintenance', 'maintenance:read'],
|
|
117
|
+
['createMaintenance', 'POST', '/api/v1/vehicles/{id}/maintenance', 'maintenance:write'],
|
|
118
|
+
['getMaintenance', 'GET', '/api/v1/operations/maintenance/{eventId}', 'maintenance:read'],
|
|
119
|
+
['updateMaintenance', 'PATCH', '/api/v1/operations/maintenance/{eventId}', 'maintenance:write'],
|
|
120
|
+
[
|
|
121
|
+
'deleteMaintenance',
|
|
122
|
+
'DELETE',
|
|
123
|
+
'/api/v1/operations/maintenance/{eventId}',
|
|
124
|
+
'maintenance:write',
|
|
125
|
+
{ confirmation: true }
|
|
126
|
+
],
|
|
127
|
+
['listDefects', 'GET', '/api/v1/operations/defects', 'operations:read'],
|
|
128
|
+
['createDefect', 'POST', '/api/v1/operations/defects', 'operations:write'],
|
|
129
|
+
[
|
|
130
|
+
'completeMaintenance',
|
|
131
|
+
'POST',
|
|
132
|
+
'/api/v1/agent/operations/maintenance/{eventId}/complete',
|
|
133
|
+
'maintenance:write',
|
|
134
|
+
{ confirmation: true }
|
|
135
|
+
],
|
|
136
|
+
[
|
|
137
|
+
'completeDefect',
|
|
138
|
+
'POST',
|
|
139
|
+
'/api/v1/agent/operations/defects/{eventId}/complete',
|
|
140
|
+
'operations:write',
|
|
141
|
+
{ confirmation: true }
|
|
142
|
+
],
|
|
143
|
+
['getReminderSettings', 'GET', '/api/v1/settings/reminders', 'reminders:read'],
|
|
144
|
+
['updateReminderSettings', 'PUT', '/api/v1/settings/reminders', 'reminders:write'],
|
|
145
|
+
['getVehicleReminderRecipients', 'GET', '/api/v1/vehicles/{id}/reminders', 'reminders:read'],
|
|
146
|
+
['replaceVehicleReminderRecipients', 'PUT', '/api/v1/vehicles/{id}/reminders', 'reminders:write'],
|
|
147
|
+
[
|
|
148
|
+
'createMobileVehicleReminderRecipient',
|
|
149
|
+
'POST',
|
|
150
|
+
'/api/v1/vehicles/{id}/reminders',
|
|
151
|
+
'reminders:write'
|
|
152
|
+
],
|
|
153
|
+
['listContacts', 'GET', '/api/v1/contacts', 'reminders:read'],
|
|
154
|
+
['createContact', 'POST', '/api/v1/contacts', 'reminders:write'],
|
|
155
|
+
['deleteContact', 'DELETE', '/api/v1/contacts/{id}', 'reminders:write', { confirmation: true }],
|
|
156
|
+
[
|
|
157
|
+
'previewContactVerification',
|
|
158
|
+
'POST',
|
|
159
|
+
'/api/v1/contacts/{id}/verification/preview',
|
|
160
|
+
'reminders:write'
|
|
161
|
+
],
|
|
162
|
+
[
|
|
163
|
+
'sendContactVerification',
|
|
164
|
+
'POST',
|
|
165
|
+
'/api/v1/contacts/{id}/verification/send',
|
|
166
|
+
'reminders:write',
|
|
167
|
+
{ confirmation: true, idempotency: true }
|
|
168
|
+
],
|
|
169
|
+
['listBookings', 'GET', '/api/v1/bookings', 'maintenance:read'],
|
|
170
|
+
['createBooking', 'POST', '/api/v1/bookings', 'maintenance:write', { idempotency: true }],
|
|
171
|
+
['getBooking', 'GET', '/api/v1/bookings/{id}', 'maintenance:read'],
|
|
172
|
+
['updateBookingStatus', 'PATCH', '/api/v1/bookings/{id}', 'maintenance:write'],
|
|
173
|
+
['previewGarageEmail', 'POST', '/api/v1/bookings/{id}/garage-email/preview', 'maintenance:write'],
|
|
174
|
+
[
|
|
175
|
+
'sendGarageEmail',
|
|
176
|
+
'POST',
|
|
177
|
+
'/api/v1/bookings/{id}/garage-email/send',
|
|
178
|
+
'maintenance:write',
|
|
179
|
+
{ confirmation: true, idempotency: true }
|
|
180
|
+
],
|
|
181
|
+
['getTollOperations', 'GET', '/api/v1/operations/tolls', 'operations:read'],
|
|
182
|
+
['reviewTollCrossing', 'POST', '/api/v1/operations/tolls', 'operations:write'],
|
|
183
|
+
[
|
|
184
|
+
'listTollAccounts',
|
|
185
|
+
'GET',
|
|
186
|
+
'/api/v1/tolls/accounts',
|
|
187
|
+
'tolls:read',
|
|
188
|
+
{ query: ['limit', 'offset', 'vehicleId'] }
|
|
189
|
+
],
|
|
190
|
+
['createTollAccount', 'POST', '/api/v1/tolls/accounts', 'tolls:write', { idempotency: true }],
|
|
191
|
+
[
|
|
192
|
+
'listTollTransactions',
|
|
193
|
+
'GET',
|
|
194
|
+
'/api/v1/tolls/transactions',
|
|
195
|
+
'tolls:read',
|
|
196
|
+
{ query: ['limit', 'offset', 'vehicleId'] }
|
|
197
|
+
],
|
|
198
|
+
[
|
|
199
|
+
'createTollTransaction',
|
|
200
|
+
'POST',
|
|
201
|
+
'/api/v1/tolls/transactions',
|
|
202
|
+
'tolls:write',
|
|
203
|
+
{ idempotency: true }
|
|
204
|
+
],
|
|
205
|
+
['previewTollImport', 'POST', '/api/v1/tolls/imports/preview', 'tolls:read'],
|
|
206
|
+
['applyTollImport', 'POST', '/api/v1/tolls/imports/apply', 'tolls:write', { idempotency: true }],
|
|
207
|
+
['listTrackingSnapshots', 'GET', '/api/v1/operations/tracking', 'operations:read'],
|
|
208
|
+
[
|
|
209
|
+
'getWexRouteHistory',
|
|
210
|
+
'GET',
|
|
211
|
+
'/api/v1/operations/tracking/{vehicleId}/route',
|
|
212
|
+
'operations:read',
|
|
213
|
+
{ query: ['period'] }
|
|
214
|
+
],
|
|
215
|
+
['getProfile', 'GET', '/api/v1/profile', 'profile:read'],
|
|
216
|
+
['getDriverGuidance', 'GET', '/api/v1/driver-guidance', 'guidance:read'],
|
|
217
|
+
[
|
|
218
|
+
'downloadDriverGuidanceDocument',
|
|
219
|
+
'GET',
|
|
220
|
+
'/api/v1/driver-guidance/document',
|
|
221
|
+
'guidance:read',
|
|
222
|
+
{ binary: true }
|
|
223
|
+
],
|
|
224
|
+
['downloadAccountExport', 'GET', '/api/v1/exports/account.json', 'exports:read'],
|
|
225
|
+
[
|
|
226
|
+
'downloadFleetSpreadsheet',
|
|
227
|
+
'GET',
|
|
228
|
+
'/api/v1/exports/fleet.xlsx',
|
|
229
|
+
'exports:read',
|
|
230
|
+
{ binary: true }
|
|
231
|
+
]
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
export const operations = Object.freeze(
|
|
235
|
+
Object.fromEntries(
|
|
236
|
+
definitions.map(([id, method, path, scope, options = {}]) => [
|
|
237
|
+
id,
|
|
238
|
+
Object.freeze({ id, method, path, scope, ...options })
|
|
239
|
+
])
|
|
240
|
+
)
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
export const operationList = Object.freeze(Object.values(operations));
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fleetkeep/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Operate every published Fleetkeep API workflow with guarded writes and machine-readable output",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"fleetkeep": "fleetkeep.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
+
"api-command.mjs",
|
|
10
11
|
"auth.mjs",
|
|
11
12
|
"fleetkeep.mjs",
|
|
13
|
+
"operations.mjs",
|
|
12
14
|
"README.md"
|
|
13
15
|
],
|
|
14
16
|
"engines": {
|