@fleetkeep/cli 0.2.0 → 0.5.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,61 @@
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.
7
+
8
+ ## Shared jobs and running costs
9
+
10
+ Friendly commands return JSON, including missing-data and stale-tracking fields:
11
+
12
+ ```sh
13
+ fleetkeep costs list --from 2026-09-01 --to 2026-09-30
14
+ fleetkeep costs record --data-file expense.json
15
+ fleetkeep drivers activity --search JOB-42
16
+ fleetkeep tracking list
17
+ fleetkeep tracking route <vehicle-id> --period 7d
18
+ ```
19
+
20
+ Driver activity returns up to 100 journeys and 100 inspections from the company's
21
+ central record. Search filters journeys by job reference, driver or registration;
22
+ inspections remain the latest 100. The activity endpoint must be deployed before
23
+ using this command. Route history requires a supported connected tracker.
24
+ Expense files use the fields below; keep the same UUID and body when retrying.
25
+
26
+ `fleetkeep jobs list --search JOB-42` finds company jobs. Use
27
+ `fleetkeep jobs save JOB-42 --title "Depot installation"` to create or update a reference,
28
+ and `fleetkeep jobs close <job-id>` or `reopen <job-id>` to change its availability.
29
+ Closing a job preserves its journey history. These owner operations need an active Fleet plan.
30
+
31
+ `fleetkeep api call getRunningCosts --query from=2026-09-01 --query to=2026-09-30`
32
+ returns recorded GBP costs and missing-data flags. `recordFleetExpense` accepts a JSON body
33
+ with `id` (UUID), `vehicleId`, `incurredOn`, `category`, `amount` (GBP decimal string) and
34
+ `description`. Reuse the UUID and identical body on retries. Dates span up to one year.
35
+ Supported categories are fuel, insurance, repair, parking and other. Negative amounts record
36
+ refunds or corrections. Service costs and recorded GBP tolls are included separately, so do
37
+ not enter them again. Account JSON exports include jobs, journeys and additional costs.
38
+
39
+ Xero authorisation and bill review take place in the web portal. Saved SHVL grants and
40
+ credentials are not transferred between products or customer workspaces.
6
41
 
7
42
  ## Safety model
8
43
 
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.
44
+ - `api operations` is generated as a fixed allowlist of the published OpenAPI operation IDs.
45
+ - Consequential actions use short-lived server-issued confirmation tokens. An unconfirmed request
46
+ returns the token without making the change; review it before repeating with `--confirmation`.
47
+ - Retry-safe creates and sends require `--idempotency-key` when the API contract requires it.
48
+ - Vehicle and toll imports remain preview-first and require the exact preview digest before apply.
49
+ - Multipart uploads accept only explicit `--field` and `--file` values. Each file is capped at 10 MB.
50
+ - Downloads require an explicit output path. Existing files are not replaced unless `--overwrite`
51
+ is present.
15
52
  - Updates distinguish omitted fields from fields explicitly cleared with `--clear`.
16
53
  - Interactive login uses an OAuth 2.1 public client with S256 PKCE and a loopback callback.
17
54
  - Stored access and refresh tokens use an owner-only local file and are never accepted as command
18
55
  arguments.
19
56
  - `FLEETKEEP_ACCESS_TOKEN` remains available as an explicit environment override for automation.
20
- - The API retains the import result in Fleetkeep's audit log.
57
+ - OAuth asks only for the published operational scopes. Account deletion, billing, OAuth-client
58
+ administration and telematics credential operations are not available.
21
59
 
22
60
  ## Use
23
61
 
@@ -31,12 +69,29 @@ fleetkeep vehicles add "AB12 CDE" --label "Service van" --operational-status act
31
69
  fleetkeep vehicles update <vehicle-id> --operational-status off_road --status-notes "Unsafe tyre"
32
70
  fleetkeep vehicles update <vehicle-id> --clear assignedDriverName,assignedDriverContact
33
71
  fleetkeep vehicles bulk-add "AB12 CDE" "XY34 ZZZ" --json
34
- fleetkeep vehicles delete <vehicle-id> --confirm <vehicle-id>
72
+ fleetkeep vehicles delete <vehicle-id> --json
73
+ fleetkeep vehicles delete <vehicle-id> --confirmation <server-token> --json
74
+ fleetkeep drivers list
75
+ fleetkeep drivers invite driver@example.com --name "Pat Driver"
76
+ fleetkeep drivers resend <driver-id>
77
+ fleetkeep drivers revoke <driver-id>
35
78
  fleetkeep import preview vehicles.xlsx
36
79
  fleetkeep import apply vehicles.xlsx --confirm <digest-from-preview>
80
+ fleetkeep api operations --json
81
+ fleetkeep api call listTrackingSnapshots
82
+ fleetkeep api call getWexRouteHistory --path vehicleId=<vehicle-id> --query period=7d
83
+ fleetkeep api call createServiceRecord --path id=<vehicle-id> \
84
+ --idempotency-key <unique-value> --data-file service-record.json
85
+ fleetkeep api call uploadVehiclePhoto --path id=<vehicle-id> --file photo=van.jpg
86
+ fleetkeep api call downloadFleetSpreadsheet --output fleetkeep-export.xlsx
37
87
  ```
38
88
 
39
- Run `fleetkeep --help` for every supported vehicle field. Use `--json` for machine-readable output.
89
+ Run `fleetkeep --help` for API call options and every supported vehicle field. `api call` returns
90
+ JSON for JSON endpoints and a JSON receipt for saved downloads. Use `--path name=value` for each path
91
+ placeholder, `--query name=value` for query values, and `--data` or `--data-file` for JSON bodies.
92
+ Use repeated `--field` and `--file` options for published multipart operations.
93
+
40
94
  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.
95
+ automation, set `FLEETKEEP_ACCESS_TOKEN` in the process environment. OAuth access and refresh tokens
96
+ are never accepted in command arguments. Short-lived action confirmations are supplied separately
97
+ 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,37 @@ 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 activity [--search <job-driver-registration>] [--json]
25
+ fleetkeep costs list [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--json]
26
+ fleetkeep costs record --data-file <expense.json>
27
+ fleetkeep tracking list [--json]
28
+ fleetkeep tracking route <vehicle-id> [--period <period>] [--json]
29
+ fleetkeep drivers invite <email> --name <name> [--json]
30
+ fleetkeep drivers resend <driver-id> [--json]
31
+ fleetkeep drivers revoke <driver-id> [--json]
32
+ fleetkeep jobs list [--search <reference>] [--json]
33
+ fleetkeep jobs save <reference> --title <title> [--json]
34
+ fleetkeep jobs close <job-id> [--json]
35
+ fleetkeep jobs reopen <job-id> [--json]
22
36
  fleetkeep import preview <file.csv|file.xlsx> [--json]
23
37
  fleetkeep import apply <file.csv|file.xlsx> --confirm <digest> [--idempotency-key <key>] [--json]
38
+ fleetkeep api operations [--json]
39
+ fleetkeep api call <operation-id> [API options]
40
+
41
+ API options:
42
+ --path <name=value> Path parameter; repeat when needed
43
+ --query <name=value> Query parameter; repeat when needed
44
+ --data <json> JSON request body
45
+ --data-file <file.json> JSON request body from a file
46
+ --field <name=value> Multipart text field; repeat when needed
47
+ --file <name=path> Multipart file; repeat when needed
48
+ --confirmation <token> Server-issued action confirmation token
49
+ --idempotency-key <key> Required for retry-safe create or send operations
50
+ --output <path> Save a download; existing files are protected
51
+ --overwrite Allow --output to replace an existing file
24
52
 
25
53
  Vehicle options:
26
54
  --label <text>
@@ -42,7 +70,16 @@ Environment:
42
70
  FLEETKEEP_API_URL API origin (default: https://fleetkeep.co.uk)
43
71
 
44
72
  Import is preview-first. Applying requires the exact digest returned by preview.
45
- Tokens are never accepted as command arguments.`;
73
+ OAuth access and refresh tokens are never accepted as command arguments.`;
74
+ }
75
+
76
+ class FleetkeepApiError extends Error {
77
+ constructor(status, payload) {
78
+ const code = typeof payload?.error === 'string' ? payload.error : `HTTP ${status}`;
79
+ super(`Fleetkeep API rejected the request (${status}): ${code}`);
80
+ this.status = status;
81
+ this.payload = payload;
82
+ }
46
83
  }
47
84
 
48
85
  function fail(message, exitCode = 1) {
@@ -169,14 +206,15 @@ function apiOrigin() {
169
206
  return url.origin;
170
207
  }
171
208
 
172
- async function request(path, { method = 'GET', body }) {
209
+ async function request(path, { method = 'GET', body, headers: extraHeaders = {} }) {
173
210
  const token = await getAccessToken();
174
211
  const response = await fetch(`${apiOrigin()}${path}`, {
175
212
  method,
176
213
  headers: {
177
214
  authorization: `Bearer ${token}`,
178
215
  accept: 'application/json',
179
- ...(body ? { 'content-type': 'application/json' } : {})
216
+ ...(body ? { 'content-type': 'application/json' } : {}),
217
+ ...extraHeaders
180
218
  },
181
219
  ...(body ? { body: JSON.stringify(body) } : {})
182
220
  });
@@ -185,8 +223,7 @@ async function request(path, { method = 'GET', body }) {
185
223
  ? null
186
224
  : await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
187
225
  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}`);
226
+ throw new FleetkeepApiError(response.status, payload);
190
227
  }
191
228
  return payload;
192
229
  }
@@ -265,6 +302,46 @@ async function main() {
265
302
  return;
266
303
  }
267
304
 
305
+ if (args[0] === 'costs' && args[1] === 'list') {
306
+ const query = [];
307
+ for (const name of ['from', 'to']) {
308
+ const value = option(args, `--${name}`);
309
+ if (value) query.push('--query', `${name}=${value}`);
310
+ }
311
+ await runApiCommand(['call', 'getRunningCosts', ...query]);
312
+ return;
313
+ }
314
+ if (args[0] === 'costs' && args[1] === 'record') {
315
+ const file = option(args, '--data-file');
316
+ if (!file) throw new Error('--data-file is required. Reuse the expense UUID on retries.');
317
+ await runApiCommand(['call', 'recordFleetExpense', '--data-file', file]);
318
+ return;
319
+ }
320
+ if (args[0] === 'tracking' && args[1] === 'list') {
321
+ await runApiCommand(['call', 'listTrackingSnapshots']);
322
+ return;
323
+ }
324
+ if (args[0] === 'tracking' && args[1] === 'route') {
325
+ if (!args[2] || args[2].startsWith('--')) throw new Error('A vehicle ID is required.');
326
+ const period = option(args, '--period');
327
+ await runApiCommand([
328
+ 'call',
329
+ 'getWexRouteHistory',
330
+ '--path',
331
+ `vehicleId=${args[2]}`,
332
+ ...(period ? ['--query', `period=${period}`] : [])
333
+ ]);
334
+ return;
335
+ }
336
+ if (args[0] === 'drivers' && args[1] === 'activity') {
337
+ const payload = await request(
338
+ `/api/v1/drivers/activity?q=${encodeURIComponent(option(args, '--search') || '')}`,
339
+ {}
340
+ );
341
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
342
+ return;
343
+ }
344
+
268
345
  if (args[0] === 'auth' && args[1] === 'login') {
269
346
  await login({ noBrowser: args.includes('--no-browser') });
270
347
  return;
@@ -280,6 +357,11 @@ async function main() {
280
357
  return;
281
358
  }
282
359
 
360
+ if (args[0] === 'api') {
361
+ await runApiCommand(args.slice(1));
362
+ return;
363
+ }
364
+
283
365
  if (args[0] === 'vehicles' && args[1] === 'list') {
284
366
  const payload = await request('/api/v1/vehicles', {});
285
367
  if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
@@ -327,12 +409,31 @@ async function main() {
327
409
 
328
410
  if (args[0] === 'vehicles' && args[1] === 'delete') {
329
411
  const id = args[2];
330
- const confirmId = option(args, '--confirm');
331
412
  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.');
413
+ const confirmation = option(args, '--confirmation');
414
+ try {
415
+ await request(vehiclePath(id), {
416
+ method: 'DELETE',
417
+ headers: confirmation ? { 'x-fleetkeep-confirmation': confirmation } : {}
418
+ });
419
+ } catch (error) {
420
+ if (
421
+ error instanceof FleetkeepApiError &&
422
+ error.status === 409 &&
423
+ error.payload?.error === 'confirmation_required' &&
424
+ typeof error.payload.confirmationToken === 'string'
425
+ ) {
426
+ if (jsonOutput) process.stdout.write(`${JSON.stringify(error.payload, null, 2)}\n`);
427
+ else {
428
+ process.stdout.write(
429
+ `No vehicle was deleted. Review the vehicle, then repeat with --confirmation ${error.payload.confirmationToken}\n`
430
+ );
431
+ }
432
+ process.exitCode = 2;
433
+ return;
434
+ }
435
+ throw error;
334
436
  }
335
- await request(vehiclePath(id), { method: 'DELETE' });
336
437
  printJsonOr({ ok: true, deletedVehicleId: id }, jsonOutput, `Vehicle deleted: ${id}`);
337
438
  return;
338
439
  }
@@ -357,6 +458,79 @@ async function main() {
357
458
  return;
358
459
  }
359
460
 
461
+ if (args[0] === 'jobs' && ['list', 'save', 'close', 'reopen'].includes(args[1])) {
462
+ const action = args[1];
463
+ let payload;
464
+ if (action === 'list') {
465
+ payload = await request(
466
+ `/api/v1/jobs?q=${encodeURIComponent(option(args, '--search') || '')}`,
467
+ {}
468
+ );
469
+ } else {
470
+ if (!args[2] || args[2].startsWith('--'))
471
+ throw new Error('A job reference or ID is required.');
472
+ if (action === 'save' && !option(args, '--title')) throw new Error('--title is required.');
473
+ payload = await request('/api/v1/jobs', {
474
+ method: 'POST',
475
+ body:
476
+ action === 'save'
477
+ ? { action: 'save', reference: args[2], title: option(args, '--title') }
478
+ : { action: 'status', id: args[2], status: action === 'close' ? 'closed' : 'active' }
479
+ });
480
+ }
481
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
482
+ return;
483
+ }
484
+
485
+ if (args[0] === 'drivers' && args[1] === 'list') {
486
+ const payload = await request('/api/v1/drivers', {});
487
+ if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
488
+ else {
489
+ for (const driver of payload.drivers || []) {
490
+ const state = driver.revokedAt
491
+ ? 'access removed'
492
+ : driver.acceptedAt
493
+ ? 'active'
494
+ : 'invitation pending';
495
+ process.stdout.write(`${driver.id}\t${driver.name}\t${driver.email}\t${state}\n`);
496
+ }
497
+ }
498
+ return;
499
+ }
500
+
501
+ if (args[0] === 'drivers' && args[1] === 'invite') {
502
+ const email = args[2];
503
+ const name = option(args, '--name');
504
+ if (!email || email.startsWith('--')) throw new Error('A driver email address is required.');
505
+ if (!name) throw new Error('--name is required.');
506
+ const payload = await request('/api/v1/drivers', {
507
+ method: 'POST',
508
+ body: { email, name }
509
+ });
510
+ printJsonOr(
511
+ payload,
512
+ jsonOutput,
513
+ `Invitation emailed to ${payload.driver.email} (${payload.driver.id})`
514
+ );
515
+ return;
516
+ }
517
+
518
+ if (args[0] === 'drivers' && ['resend', 'revoke'].includes(args[1])) {
519
+ const id = args[2];
520
+ if (!id || id.startsWith('--')) throw new Error('A driver ID is required.');
521
+ const action = args[1];
522
+ const payload = await request(`/api/v1/drivers/${encodeURIComponent(id)}`, {
523
+ method: 'POST',
524
+ body: { action }
525
+ });
526
+ printJsonOr(
527
+ payload,
528
+ jsonOutput,
529
+ action === 'resend' ? 'Invitation emailed again.' : 'Driver access removed.'
530
+ );
531
+ return;
532
+ }
533
+
360
534
  if (args[0] === 'import' && args[1] === 'preview') {
361
535
  const body = await spreadsheetPayload(args[2]);
362
536
  const payload = await request('/api/v1/imports/vehicles/preview', {
package/operations.mjs ADDED
@@ -0,0 +1,255 @@
1
+ /** @type {Array<[string, string, string, string, Record<string, unknown>?]>} */
2
+ const definitions = [
3
+ ['importProviderJobs', 'POST', '/api/v1/jobs/import', 'operations:write'],
4
+ ['listFleetJobs', 'GET', '/api/v1/jobs', 'operations:read', { query: ['q'] }],
5
+ ['saveFleetJob', 'POST', '/api/v1/jobs', 'operations:write'],
6
+ ['getRunningCosts', 'GET', '/api/v1/costs', 'operations:read', { query: ['from', 'to'] }],
7
+ ['recordFleetExpense', 'POST', '/api/v1/costs', 'operations:write'],
8
+ [
9
+ 'listDriverJobs',
10
+ 'GET',
11
+ '/api/v1/driver-workspace/{id}/jobs',
12
+ 'operations:read',
13
+ { query: ['q'] }
14
+ ],
15
+ ['listVehicles', 'GET', '/api/v1/vehicles', 'vehicles:read'],
16
+ ['addVehicle', 'POST', '/api/v1/vehicles', 'vehicles:write'],
17
+ ['getVehicle', 'GET', '/api/v1/vehicles/{id}', 'vehicles:read'],
18
+ ['updateVehicle', 'PATCH', '/api/v1/vehicles/{id}', 'vehicles:write'],
19
+ ['deleteVehicle', 'DELETE', '/api/v1/vehicles/{id}', 'vehicles:write', { confirmation: true }],
20
+ ['listVehicleEquipment', 'GET', '/api/v1/vehicles/{id}/checks', 'vehicles:read'],
21
+ ['addVehicleEquipment', 'POST', '/api/v1/vehicles/{id}/checks', 'vehicles:write'],
22
+ ['updateVehicleEquipment', 'PATCH', '/api/v1/vehicles/{id}/checks', 'vehicles:write'],
23
+ [
24
+ 'removeVehicleEquipment',
25
+ 'DELETE',
26
+ '/api/v1/vehicles/{id}/checks',
27
+ 'vehicles:write',
28
+ { confirmation: true }
29
+ ],
30
+ ['listEquipmentTemplates', 'GET', '/api/v1/equipment/templates', 'vehicles:read'],
31
+ ['previewEquipmentTemplate', 'POST', '/api/v1/equipment/templates', 'vehicles:read'],
32
+ ['applyEquipmentTemplate', 'PUT', '/api/v1/equipment/templates', 'vehicles:write'],
33
+ ['bulkAddVehicles', 'POST', '/api/v1/vehicles/bulk', 'vehicles:write'],
34
+ ['previewVehicleImport', 'POST', '/api/v1/imports/vehicles/preview', 'vehicles:read'],
35
+ ['applyVehicleImport', 'POST', '/api/v1/imports/vehicles/apply', 'vehicles:write'],
36
+ ['getWalkaroundDefinition', 'GET', '/api/v1/operations/walkaround-definition', 'operations:read'],
37
+ [
38
+ 'listWalkarounds',
39
+ 'GET',
40
+ '/api/v1/vehicles/{id}/walkarounds',
41
+ 'operations:read',
42
+ { query: ['limit', 'offset'] }
43
+ ],
44
+ [
45
+ 'submitWalkaround',
46
+ 'POST',
47
+ '/api/v1/vehicles/{id}/walkarounds',
48
+ 'operations:write',
49
+ { confirmation: true, multipart: true }
50
+ ],
51
+ [
52
+ 'getWalkaroundEvidence',
53
+ 'GET',
54
+ '/api/v1/vehicles/{id}/walkarounds/{checkId}/media/{mediaId}',
55
+ 'operations:read',
56
+ { binary: true }
57
+ ],
58
+ [
59
+ 'listVehicleDocuments',
60
+ 'GET',
61
+ '/api/v1/vehicles/{id}/documents',
62
+ 'documents:read',
63
+ { query: ['limit', 'offset'] }
64
+ ],
65
+ [
66
+ 'createVehicleDocument',
67
+ 'POST',
68
+ '/api/v1/vehicles/{id}/documents',
69
+ 'documents:write',
70
+ { multipart: true }
71
+ ],
72
+ [
73
+ 'downloadVehicleDocument',
74
+ 'GET',
75
+ '/api/v1/vehicles/{id}/documents/{documentId}/download',
76
+ 'documents:read',
77
+ { binary: true }
78
+ ],
79
+ ['getVehiclePhoto', 'GET', '/api/v1/vehicles/{id}/photo', 'vehicles:read', { binary: true }],
80
+ [
81
+ 'uploadVehiclePhoto',
82
+ 'POST',
83
+ '/api/v1/vehicles/{id}/photo',
84
+ 'vehicles:write',
85
+ { multipart: true }
86
+ ],
87
+ [
88
+ 'deleteVehiclePhoto',
89
+ 'DELETE',
90
+ '/api/v1/vehicles/{id}/photo',
91
+ 'vehicles:write',
92
+ { confirmation: true }
93
+ ],
94
+ [
95
+ 'listServiceRecords',
96
+ 'GET',
97
+ '/api/v1/vehicles/{id}/service-records',
98
+ 'maintenance:read',
99
+ { query: ['limit', 'offset'] }
100
+ ],
101
+ [
102
+ 'createServiceRecord',
103
+ 'POST',
104
+ '/api/v1/vehicles/{id}/service-records',
105
+ 'maintenance:write',
106
+ { idempotency: true }
107
+ ],
108
+ [
109
+ 'getServiceRecord',
110
+ 'GET',
111
+ '/api/v1/vehicles/{id}/service-records/{recordId}',
112
+ 'maintenance:read'
113
+ ],
114
+ [
115
+ 'updateServiceRecord',
116
+ 'PUT',
117
+ '/api/v1/vehicles/{id}/service-records/{recordId}',
118
+ 'maintenance:write',
119
+ { confirmation: true }
120
+ ],
121
+ [
122
+ 'deleteServiceRecord',
123
+ 'DELETE',
124
+ '/api/v1/vehicles/{id}/service-records/{recordId}',
125
+ 'maintenance:write',
126
+ { confirmation: true }
127
+ ],
128
+ ['listMaintenance', 'GET', '/api/v1/operations/maintenance', 'maintenance:read'],
129
+ ['createMaintenance', 'POST', '/api/v1/vehicles/{id}/maintenance', 'maintenance:write'],
130
+ ['getMaintenance', 'GET', '/api/v1/operations/maintenance/{eventId}', 'maintenance:read'],
131
+ ['updateMaintenance', 'PATCH', '/api/v1/operations/maintenance/{eventId}', 'maintenance:write'],
132
+ [
133
+ 'deleteMaintenance',
134
+ 'DELETE',
135
+ '/api/v1/operations/maintenance/{eventId}',
136
+ 'maintenance:write',
137
+ { confirmation: true }
138
+ ],
139
+ ['listDefects', 'GET', '/api/v1/operations/defects', 'operations:read'],
140
+ ['createDefect', 'POST', '/api/v1/operations/defects', 'operations:write'],
141
+ [
142
+ 'completeMaintenance',
143
+ 'POST',
144
+ '/api/v1/agent/operations/maintenance/{eventId}/complete',
145
+ 'maintenance:write',
146
+ { confirmation: true }
147
+ ],
148
+ [
149
+ 'completeDefect',
150
+ 'POST',
151
+ '/api/v1/agent/operations/defects/{eventId}/complete',
152
+ 'operations:write',
153
+ { confirmation: true }
154
+ ],
155
+ ['getReminderSettings', 'GET', '/api/v1/settings/reminders', 'reminders:read'],
156
+ ['updateReminderSettings', 'PUT', '/api/v1/settings/reminders', 'reminders:write'],
157
+ ['getVehicleReminderRecipients', 'GET', '/api/v1/vehicles/{id}/reminders', 'reminders:read'],
158
+ ['replaceVehicleReminderRecipients', 'PUT', '/api/v1/vehicles/{id}/reminders', 'reminders:write'],
159
+ [
160
+ 'createMobileVehicleReminderRecipient',
161
+ 'POST',
162
+ '/api/v1/vehicles/{id}/reminders',
163
+ 'reminders:write'
164
+ ],
165
+ ['listContacts', 'GET', '/api/v1/contacts', 'reminders:read'],
166
+ ['createContact', 'POST', '/api/v1/contacts', 'reminders:write'],
167
+ ['deleteContact', 'DELETE', '/api/v1/contacts/{id}', 'reminders:write', { confirmation: true }],
168
+ [
169
+ 'previewContactVerification',
170
+ 'POST',
171
+ '/api/v1/contacts/{id}/verification/preview',
172
+ 'reminders:write'
173
+ ],
174
+ [
175
+ 'sendContactVerification',
176
+ 'POST',
177
+ '/api/v1/contacts/{id}/verification/send',
178
+ 'reminders:write',
179
+ { confirmation: true, idempotency: true }
180
+ ],
181
+ ['listBookings', 'GET', '/api/v1/bookings', 'maintenance:read'],
182
+ ['createBooking', 'POST', '/api/v1/bookings', 'maintenance:write', { idempotency: true }],
183
+ ['getBooking', 'GET', '/api/v1/bookings/{id}', 'maintenance:read'],
184
+ ['updateBookingStatus', 'PATCH', '/api/v1/bookings/{id}', 'maintenance:write'],
185
+ ['previewGarageEmail', 'POST', '/api/v1/bookings/{id}/garage-email/preview', 'maintenance:write'],
186
+ [
187
+ 'sendGarageEmail',
188
+ 'POST',
189
+ '/api/v1/bookings/{id}/garage-email/send',
190
+ 'maintenance:write',
191
+ { confirmation: true, idempotency: true }
192
+ ],
193
+ ['getTollOperations', 'GET', '/api/v1/operations/tolls', 'operations:read'],
194
+ ['reviewTollCrossing', 'POST', '/api/v1/operations/tolls', 'operations:write'],
195
+ [
196
+ 'listTollAccounts',
197
+ 'GET',
198
+ '/api/v1/tolls/accounts',
199
+ 'tolls:read',
200
+ { query: ['limit', 'offset', 'vehicleId'] }
201
+ ],
202
+ ['createTollAccount', 'POST', '/api/v1/tolls/accounts', 'tolls:write', { idempotency: true }],
203
+ [
204
+ 'listTollTransactions',
205
+ 'GET',
206
+ '/api/v1/tolls/transactions',
207
+ 'tolls:read',
208
+ { query: ['limit', 'offset', 'vehicleId'] }
209
+ ],
210
+ [
211
+ 'createTollTransaction',
212
+ 'POST',
213
+ '/api/v1/tolls/transactions',
214
+ 'tolls:write',
215
+ { idempotency: true }
216
+ ],
217
+ ['previewTollImport', 'POST', '/api/v1/tolls/imports/preview', 'tolls:read'],
218
+ ['applyTollImport', 'POST', '/api/v1/tolls/imports/apply', 'tolls:write', { idempotency: true }],
219
+ ['listTrackingSnapshots', 'GET', '/api/v1/operations/tracking', 'operations:read'],
220
+ [
221
+ 'getWexRouteHistory',
222
+ 'GET',
223
+ '/api/v1/operations/tracking/{vehicleId}/route',
224
+ 'operations:read',
225
+ { query: ['period'] }
226
+ ],
227
+ ['getProfile', 'GET', '/api/v1/profile', 'profile:read'],
228
+ ['getDriverGuidance', 'GET', '/api/v1/driver-guidance', 'guidance:read'],
229
+ [
230
+ 'downloadDriverGuidanceDocument',
231
+ 'GET',
232
+ '/api/v1/driver-guidance/document',
233
+ 'guidance:read',
234
+ { binary: true }
235
+ ],
236
+ ['downloadAccountExport', 'GET', '/api/v1/exports/account.json', 'exports:read'],
237
+ [
238
+ 'downloadFleetSpreadsheet',
239
+ 'GET',
240
+ '/api/v1/exports/fleet.xlsx',
241
+ 'exports:read',
242
+ { binary: true }
243
+ ]
244
+ ];
245
+
246
+ export const operations = Object.freeze(
247
+ Object.fromEntries(
248
+ definitions.map(([id, method, path, scope, options = {}]) => [
249
+ id,
250
+ Object.freeze({ id, method, path, scope, ...options })
251
+ ])
252
+ )
253
+ );
254
+
255
+ 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.5.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": {