@fleetkeep/cli 0.1.2 → 0.2.0

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