@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 CHANGED
@@ -1,21 +1,27 @@
1
1
  # Fleetkeep CLI
2
2
 
3
- The Fleetkeep CLI gives an operator or software agent the same bounded fleet import path as the
4
- Fleetkeep dashboard. It can list vehicles, preview CSV or XLSX imports and apply the exact
5
- 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.
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
- - 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.
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
- Use `--json` for machine-readable output. Set `FLEETKEEP_API_URL` to an alternate origin for local
32
- or staging verification. For unattended automation, set `FLEETKEEP_ACCESS_TOKEN` in the process
33
- environment instead of storing or passing a token in command arguments.
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`.
@@ -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';
@@ -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
- 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
+ }
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 = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
212
+ const payload =
213
+ response.status === 204
214
+ ? null
215
+ : await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
66
216
  if (!response.ok) {
67
- const code = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`;
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.1.2",
4
- "description": "Preview and apply Fleetkeep 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": {