@fleetkeep/cli 0.2.0 → 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 CHANGED
@@ -1,23 +1,27 @@
1
1
  # Fleetkeep CLI
2
2
 
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.
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
- - Preview is read-only and uses `vehicles:read`.
10
- - Apply uses `vehicles:write` and requires the exact SHA-256 digest returned by preview.
11
- - Every apply request has an idempotency key. Retrying the same request does not duplicate vehicles
12
- or equipment history.
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.
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.
15
18
  - Updates distinguish omitted fields from fields explicitly cleared with `--clear`.
16
19
  - Interactive login uses an OAuth 2.1 public client with S256 PKCE and a loopback callback.
17
20
  - Stored access and refresh tokens use an owner-only local file and are never accepted as command
18
21
  arguments.
19
22
  - `FLEETKEEP_ACCESS_TOKEN` remains available as an explicit environment override for automation.
20
- - The API retains the import result in Fleetkeep's audit log.
23
+ - OAuth asks only for the published operational scopes. Account deletion, billing, OAuth-client
24
+ administration and telematics credential operations are not available.
21
25
 
22
26
  ## Use
23
27
 
@@ -31,12 +35,29 @@ fleetkeep vehicles add "AB12 CDE" --label "Service van" --operational-status act
31
35
  fleetkeep vehicles update <vehicle-id> --operational-status off_road --status-notes "Unsafe tyre"
32
36
  fleetkeep vehicles update <vehicle-id> --clear assignedDriverName,assignedDriverContact
33
37
  fleetkeep vehicles bulk-add "AB12 CDE" "XY34 ZZZ" --json
34
- fleetkeep vehicles delete <vehicle-id> --confirm <vehicle-id>
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>
35
44
  fleetkeep import preview vehicles.xlsx
36
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
37
53
  ```
38
54
 
39
- Run `fleetkeep --help` for every supported vehicle field. Use `--json` for machine-readable output.
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
+
40
60
  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.
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`.
@@ -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 = ['vehicles:read', 'vehicles:write', 'offline_access'];
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';
@@ -17,10 +18,28 @@ Usage:
17
18
  fleetkeep vehicles get <vehicle-id> [--json]
18
19
  fleetkeep vehicles add <registration> [vehicle options] [--json]
19
20
  fleetkeep vehicles update <vehicle-id> [vehicle options] [--clear <field,...>] [--json]
20
- fleetkeep vehicles delete <vehicle-id> --confirm <vehicle-id> [--json]
21
+ fleetkeep vehicles delete <vehicle-id> [--confirmation <server-token>] [--json]
21
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]
22
27
  fleetkeep import preview <file.csv|file.xlsx> [--json]
23
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
24
43
 
25
44
  Vehicle options:
26
45
  --label <text>
@@ -42,7 +61,16 @@ Environment:
42
61
  FLEETKEEP_API_URL API origin (default: https://fleetkeep.co.uk)
43
62
 
44
63
  Import is preview-first. Applying requires the exact digest returned by preview.
45
- Tokens are never accepted as command arguments.`;
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
+ }
46
74
  }
47
75
 
48
76
  function fail(message, exitCode = 1) {
@@ -169,14 +197,15 @@ function apiOrigin() {
169
197
  return url.origin;
170
198
  }
171
199
 
172
- async function request(path, { method = 'GET', body }) {
200
+ async function request(path, { method = 'GET', body, headers: extraHeaders = {} }) {
173
201
  const token = await getAccessToken();
174
202
  const response = await fetch(`${apiOrigin()}${path}`, {
175
203
  method,
176
204
  headers: {
177
205
  authorization: `Bearer ${token}`,
178
206
  accept: 'application/json',
179
- ...(body ? { 'content-type': 'application/json' } : {})
207
+ ...(body ? { 'content-type': 'application/json' } : {}),
208
+ ...extraHeaders
180
209
  },
181
210
  ...(body ? { body: JSON.stringify(body) } : {})
182
211
  });
@@ -185,8 +214,7 @@ async function request(path, { method = 'GET', body }) {
185
214
  ? null
186
215
  : await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
187
216
  if (!response.ok) {
188
- const code = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`;
189
- throw new Error(`Fleetkeep API rejected the request (${response.status}): ${code}`);
217
+ throw new FleetkeepApiError(response.status, payload);
190
218
  }
191
219
  return payload;
192
220
  }
@@ -280,6 +308,11 @@ async function main() {
280
308
  return;
281
309
  }
282
310
 
311
+ if (args[0] === 'api') {
312
+ await runApiCommand(args.slice(1));
313
+ return;
314
+ }
315
+
283
316
  if (args[0] === 'vehicles' && args[1] === 'list') {
284
317
  const payload = await request('/api/v1/vehicles', {});
285
318
  if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
@@ -327,12 +360,31 @@ async function main() {
327
360
 
328
361
  if (args[0] === 'vehicles' && args[1] === 'delete') {
329
362
  const id = args[2];
330
- const confirmId = option(args, '--confirm');
331
363
  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.');
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;
334
387
  }
335
- await request(vehiclePath(id), { method: 'DELETE' });
336
388
  printJsonOr({ ok: true, deletedVehicleId: id }, jsonOutput, `Vehicle deleted: ${id}`);
337
389
  return;
338
390
  }
@@ -357,6 +409,55 @@ async function main() {
357
409
  return;
358
410
  }
359
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
+
360
461
  if (args[0] === 'import' && args[1] === 'preview') {
361
462
  const body = await spreadsheetPayload(args[2]);
362
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.2.0",
4
- "description": "Manage Fleetkeep vehicles and guarded fleet spreadsheet imports through the Fleetkeep API",
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": {