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