@fleetkeep/cli 0.1.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 +33 -0
- package/auth.mjs +303 -0
- package/fleetkeep.mjs +196 -0
- package/package.json +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Fleetkeep CLI
|
|
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.
|
|
6
|
+
|
|
7
|
+
## Safety model
|
|
8
|
+
|
|
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
|
+
- Interactive login uses an OAuth 2.1 public client with S256 PKCE and a loopback callback.
|
|
15
|
+
- Stored access and refresh tokens use an owner-only local file and are never accepted as command
|
|
16
|
+
arguments.
|
|
17
|
+
- `FLEETKEEP_ACCESS_TOKEN` remains available as an explicit environment override for automation.
|
|
18
|
+
- The API retains the import result in Fleetkeep's audit log.
|
|
19
|
+
|
|
20
|
+
## Use
|
|
21
|
+
|
|
22
|
+
Node.js 20 or newer is required. Sign in through Fleetkeep in your browser, then use the CLI:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
fleetkeep auth login
|
|
26
|
+
fleetkeep vehicles list
|
|
27
|
+
fleetkeep import preview vehicles.xlsx
|
|
28
|
+
fleetkeep import apply vehicles.xlsx --confirm <digest-from-preview>
|
|
29
|
+
```
|
|
30
|
+
|
|
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.
|
package/auth.mjs
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile, chmod } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_API_URL = 'https://fleetkeep.co.uk';
|
|
9
|
+
const DEFAULT_CLIENT_ID = 'fleetkeep-cli';
|
|
10
|
+
const DEFAULT_REDIRECT_URI = 'http://127.0.0.1:38741/callback';
|
|
11
|
+
const DEFAULT_SCOPES = ['vehicles:read', 'vehicles:write', 'offline_access'];
|
|
12
|
+
const AUTH_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/** @typedef {{ accessToken: string, refreshToken?: string | null, expiresAt: number }} StoredTokens */
|
|
15
|
+
|
|
16
|
+
export function oauthConfig() {
|
|
17
|
+
const originValue = (process.env.FLEETKEEP_API_URL || DEFAULT_API_URL).trim().replace(/\/$/, '');
|
|
18
|
+
let originUrl;
|
|
19
|
+
try {
|
|
20
|
+
originUrl = new URL(originValue);
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error('FLEETKEEP_API_URL must be a valid HTTP or HTTPS origin.');
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
!['http:', 'https:'].includes(originUrl.protocol) ||
|
|
26
|
+
originUrl.pathname !== '/' ||
|
|
27
|
+
originUrl.search ||
|
|
28
|
+
originUrl.hash ||
|
|
29
|
+
originUrl.username ||
|
|
30
|
+
originUrl.password
|
|
31
|
+
) {
|
|
32
|
+
throw new Error('FLEETKEEP_API_URL must be an HTTP or HTTPS origin without a path.');
|
|
33
|
+
}
|
|
34
|
+
const origin = originUrl.origin;
|
|
35
|
+
const redirectUri = process.env.FLEETKEEP_OAUTH_REDIRECT_URI || DEFAULT_REDIRECT_URI;
|
|
36
|
+
const redirect = new URL(redirectUri);
|
|
37
|
+
if (redirect.protocol !== 'http:' || redirect.hostname !== '127.0.0.1' || !redirect.port) {
|
|
38
|
+
throw new Error('The Fleetkeep CLI OAuth callback must use http://127.0.0.1.');
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
origin,
|
|
42
|
+
clientId: process.env.FLEETKEEP_OAUTH_CLIENT_ID || DEFAULT_CLIENT_ID,
|
|
43
|
+
redirectUri: redirect.href,
|
|
44
|
+
scopes: DEFAULT_SCOPES,
|
|
45
|
+
authorizationEndpoint: `${origin}/auth/oauth2/authorize`,
|
|
46
|
+
tokenEndpoint: `${origin}/auth/oauth2/token`,
|
|
47
|
+
revocationEndpoint: `${origin}/auth/oauth2/revoke`
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function authFile() {
|
|
52
|
+
if (process.env.FLEETKEEP_AUTH_FILE) return process.env.FLEETKEEP_AUTH_FILE;
|
|
53
|
+
const configRoot = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
54
|
+
return join(configRoot, 'fleetkeep', 'auth.json');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @returns {Promise<StoredTokens | null>} */
|
|
58
|
+
export async function loadStoredTokens() {
|
|
59
|
+
try {
|
|
60
|
+
const value = JSON.parse(await readFile(authFile(), 'utf8'));
|
|
61
|
+
if (
|
|
62
|
+
value?.version !== 1 ||
|
|
63
|
+
typeof value.accessToken !== 'string' ||
|
|
64
|
+
!value.accessToken ||
|
|
65
|
+
(value.refreshToken !== null && typeof value.refreshToken !== 'string') ||
|
|
66
|
+
typeof value.expiresAt !== 'number'
|
|
67
|
+
) {
|
|
68
|
+
throw new Error('invalid token file');
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
|
73
|
+
return null;
|
|
74
|
+
throw new Error('Fleetkeep login is unreadable. Run `fleetkeep auth login` again.', {
|
|
75
|
+
cause: error
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {StoredTokens} tokens */
|
|
81
|
+
export async function saveStoredTokens(tokens) {
|
|
82
|
+
const file = authFile();
|
|
83
|
+
const directory = dirname(file);
|
|
84
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
85
|
+
if (!process.env.FLEETKEEP_AUTH_FILE) await chmod(directory, 0o700);
|
|
86
|
+
const temporary = `${file}.${randomBytes(6).toString('hex')}.tmp`;
|
|
87
|
+
await writeFile(temporary, `${JSON.stringify({ ...tokens, version: 1 })}\n`, { mode: 0o600 });
|
|
88
|
+
await rename(temporary, file);
|
|
89
|
+
await chmod(file, 0o600);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function clearStoredTokens() {
|
|
93
|
+
await rm(authFile(), { force: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pkce() {
|
|
97
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
98
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
99
|
+
return { verifier, challenge };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function authorizationRequest(config = oauthConfig()) {
|
|
103
|
+
const state = randomBytes(24).toString('base64url');
|
|
104
|
+
const { verifier, challenge } = pkce();
|
|
105
|
+
const url = new URL(config.authorizationEndpoint);
|
|
106
|
+
url.search = new URLSearchParams({
|
|
107
|
+
response_type: 'code',
|
|
108
|
+
client_id: config.clientId,
|
|
109
|
+
redirect_uri: config.redirectUri,
|
|
110
|
+
scope: config.scopes.join(' '),
|
|
111
|
+
code_challenge: challenge,
|
|
112
|
+
code_challenge_method: 'S256',
|
|
113
|
+
state,
|
|
114
|
+
resource: config.origin,
|
|
115
|
+
prompt: 'consent'
|
|
116
|
+
}).toString();
|
|
117
|
+
return { url, state, verifier };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {Record<string, string>} parameters
|
|
122
|
+
* @param {ReturnType<typeof oauthConfig>} config
|
|
123
|
+
* @returns {Promise<StoredTokens>}
|
|
124
|
+
*/
|
|
125
|
+
async function tokenRequest(parameters, config = oauthConfig()) {
|
|
126
|
+
const response = await fetch(config.tokenEndpoint, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
129
|
+
body: new URLSearchParams(parameters)
|
|
130
|
+
});
|
|
131
|
+
const payload = await response.json().catch(() => ({}));
|
|
132
|
+
if (!response.ok || typeof payload.access_token !== 'string') {
|
|
133
|
+
const reason = typeof payload.error === 'string' ? payload.error : `HTTP ${response.status}`;
|
|
134
|
+
throw new Error(`Fleetkeep OAuth rejected the request (${reason}).`);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
accessToken: payload.access_token,
|
|
138
|
+
refreshToken:
|
|
139
|
+
typeof payload.refresh_token === 'string'
|
|
140
|
+
? payload.refresh_token
|
|
141
|
+
: parameters.refresh_token || null,
|
|
142
|
+
expiresAt: Date.now() + (Number(payload.expires_in) || 3600) * 1000
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} code
|
|
148
|
+
* @param {string} verifier
|
|
149
|
+
* @param {ReturnType<typeof oauthConfig>} config
|
|
150
|
+
*/
|
|
151
|
+
export async function exchangeAuthorizationCode(code, verifier, config = oauthConfig()) {
|
|
152
|
+
return tokenRequest(
|
|
153
|
+
{
|
|
154
|
+
grant_type: 'authorization_code',
|
|
155
|
+
code,
|
|
156
|
+
redirect_uri: config.redirectUri,
|
|
157
|
+
client_id: config.clientId,
|
|
158
|
+
code_verifier: verifier,
|
|
159
|
+
resource: config.origin
|
|
160
|
+
},
|
|
161
|
+
config
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {StoredTokens} tokens
|
|
167
|
+
* @param {ReturnType<typeof oauthConfig>} config
|
|
168
|
+
*/
|
|
169
|
+
export async function refreshStoredTokens(tokens, config = oauthConfig()) {
|
|
170
|
+
if (!tokens.refreshToken) throw new Error('Your Fleetkeep session has expired. Sign in again.');
|
|
171
|
+
const refreshed = await tokenRequest(
|
|
172
|
+
{
|
|
173
|
+
grant_type: 'refresh_token',
|
|
174
|
+
refresh_token: tokens.refreshToken,
|
|
175
|
+
client_id: config.clientId,
|
|
176
|
+
scope: config.scopes.join(' '),
|
|
177
|
+
resource: config.origin
|
|
178
|
+
},
|
|
179
|
+
config
|
|
180
|
+
);
|
|
181
|
+
await saveStoredTokens(refreshed);
|
|
182
|
+
return refreshed;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function getAccessToken() {
|
|
186
|
+
const environmentToken = (process.env.FLEETKEEP_ACCESS_TOKEN || '').trim();
|
|
187
|
+
if (environmentToken) return environmentToken;
|
|
188
|
+
const stored = await loadStoredTokens();
|
|
189
|
+
if (!stored) {
|
|
190
|
+
throw new Error('Sign in with `fleetkeep auth login` or set FLEETKEEP_ACCESS_TOKEN.');
|
|
191
|
+
}
|
|
192
|
+
if (stored.expiresAt > Date.now() + 60_000) return stored.accessToken;
|
|
193
|
+
try {
|
|
194
|
+
return (await refreshStoredTokens(stored)).accessToken;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
await clearStoredTokens();
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** @param {string} url */
|
|
202
|
+
function openBrowser(url) {
|
|
203
|
+
/** @type {[string, string[]]} */
|
|
204
|
+
const command =
|
|
205
|
+
process.platform === 'darwin'
|
|
206
|
+
? ['open', [url]]
|
|
207
|
+
: process.platform === 'win32'
|
|
208
|
+
? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]]
|
|
209
|
+
: ['xdg-open', [url]];
|
|
210
|
+
const child = spawn(command[0], command[1], { detached: true, stdio: 'ignore' });
|
|
211
|
+
child.on('error', () => {});
|
|
212
|
+
child.unref();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function login({ noBrowser = false } = {}) {
|
|
216
|
+
if ((process.env.FLEETKEEP_ACCESS_TOKEN || '').trim()) {
|
|
217
|
+
throw new Error('Unset FLEETKEEP_ACCESS_TOKEN before saving a CLI login.');
|
|
218
|
+
}
|
|
219
|
+
const config = oauthConfig();
|
|
220
|
+
const request = authorizationRequest(config);
|
|
221
|
+
const redirect = new URL(config.redirectUri);
|
|
222
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
223
|
+
let timeout;
|
|
224
|
+
/** @type {import('node:http').Server | undefined} */
|
|
225
|
+
let server;
|
|
226
|
+
/** @type {Promise<string>} */
|
|
227
|
+
const callback = new Promise((resolve, reject) => {
|
|
228
|
+
server = createServer((incoming, response) => {
|
|
229
|
+
const url = new URL(incoming.url || '/', config.redirectUri);
|
|
230
|
+
if (url.pathname !== redirect.pathname) {
|
|
231
|
+
response.writeHead(404).end('Not found');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const error = url.searchParams.get('error');
|
|
235
|
+
const code = url.searchParams.get('code');
|
|
236
|
+
const state = url.searchParams.get('state');
|
|
237
|
+
if (error || !code || state !== request.state) {
|
|
238
|
+
response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
|
|
239
|
+
response.end('Fleetkeep sign-in was not completed. Return to the terminal.');
|
|
240
|
+
reject(new Error(error || 'Fleetkeep sign-in response was invalid.'));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
|
|
244
|
+
response.end('Fleetkeep sign-in complete. You can close this window.');
|
|
245
|
+
resolve(code);
|
|
246
|
+
});
|
|
247
|
+
server.listen(Number(redirect.port), redirect.hostname);
|
|
248
|
+
timeout = setTimeout(
|
|
249
|
+
() => reject(new Error('Fleetkeep sign-in timed out. Run `fleetkeep auth login` again.')),
|
|
250
|
+
Number(process.env.FLEETKEEP_AUTH_TIMEOUT_MS) || AUTH_TIMEOUT_MS
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
await /** @type {Promise<void>} */ (
|
|
256
|
+
new Promise((resolve, reject) => {
|
|
257
|
+
if (!server) reject(new Error('Fleetkeep callback server did not start.'));
|
|
258
|
+
else if (server.listening) resolve();
|
|
259
|
+
else {
|
|
260
|
+
server.once('listening', resolve);
|
|
261
|
+
server.once('error', reject);
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
);
|
|
265
|
+
process.stdout.write(`Open this URL to sign in:\n${request.url.href}\n`);
|
|
266
|
+
if (!noBrowser) openBrowser(request.url.href);
|
|
267
|
+
const code = await callback;
|
|
268
|
+
const tokens = await exchangeAuthorizationCode(code, request.verifier, config);
|
|
269
|
+
await saveStoredTokens(tokens);
|
|
270
|
+
process.stdout.write('Signed in to Fleetkeep.\n');
|
|
271
|
+
} finally {
|
|
272
|
+
clearTimeout(timeout);
|
|
273
|
+
await /** @type {Promise<void>} */ (
|
|
274
|
+
new Promise((resolve) => (server?.listening ? server.close(() => resolve()) : resolve()))
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function authStatus() {
|
|
280
|
+
if ((process.env.FLEETKEEP_ACCESS_TOKEN || '').trim()) return 'Using FLEETKEEP_ACCESS_TOKEN.';
|
|
281
|
+
const stored = await loadStoredTokens();
|
|
282
|
+
if (!stored) return 'Not signed in.';
|
|
283
|
+
if (stored.expiresAt <= Date.now() && !stored.refreshToken) return 'Session expired.';
|
|
284
|
+
return 'Signed in to Fleetkeep.';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function logout() {
|
|
288
|
+
const stored = await loadStoredTokens();
|
|
289
|
+
if (stored?.refreshToken) {
|
|
290
|
+
const config = oauthConfig();
|
|
291
|
+
await fetch(config.revocationEndpoint, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
294
|
+
body: new URLSearchParams({
|
|
295
|
+
token: stored.refreshToken,
|
|
296
|
+
token_type_hint: 'refresh_token',
|
|
297
|
+
client_id: config.clientId
|
|
298
|
+
})
|
|
299
|
+
}).catch(() => null);
|
|
300
|
+
}
|
|
301
|
+
await clearStoredTokens();
|
|
302
|
+
process.stdout.write('Signed out of Fleetkeep on this machine.\n');
|
|
303
|
+
}
|
package/fleetkeep.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { basename, extname, resolve } from 'node:path';
|
|
5
|
+
import { authStatus, getAccessToken, login, logout } from './auth.mjs';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_API_URL = 'https://fleetkeep.co.uk';
|
|
8
|
+
|
|
9
|
+
function usage() {
|
|
10
|
+
return `Fleetkeep CLI
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
fleetkeep auth login [--no-browser]
|
|
14
|
+
fleetkeep auth status
|
|
15
|
+
fleetkeep auth logout
|
|
16
|
+
fleetkeep vehicles list [--json]
|
|
17
|
+
fleetkeep import preview <file.csv|file.xlsx> [--json]
|
|
18
|
+
fleetkeep import apply <file.csv|file.xlsx> --confirm <digest> [--idempotency-key <key>] [--json]
|
|
19
|
+
|
|
20
|
+
Environment:
|
|
21
|
+
FLEETKEEP_ACCESS_TOKEN Optional OAuth token override for automation
|
|
22
|
+
FLEETKEEP_API_URL API origin (default: https://fleetkeep.co.uk)
|
|
23
|
+
|
|
24
|
+
Import is preview-first. Applying requires the exact digest returned by preview.
|
|
25
|
+
Tokens are never accepted as command arguments.`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fail(message, exitCode = 1) {
|
|
29
|
+
process.stderr.write(`${message}\n`);
|
|
30
|
+
process.exitCode = exitCode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function option(args, name) {
|
|
34
|
+
const index = args.indexOf(name);
|
|
35
|
+
if (index === -1) return null;
|
|
36
|
+
const value = args[index + 1];
|
|
37
|
+
return value && !value.startsWith('--') ? value : '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function apiOrigin() {
|
|
41
|
+
const value = (process.env.FLEETKEEP_API_URL || DEFAULT_API_URL).trim().replace(/\/$/, '');
|
|
42
|
+
let url;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(value);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error('FLEETKEEP_API_URL must be a valid HTTP or HTTPS origin.');
|
|
47
|
+
}
|
|
48
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.pathname !== '/') {
|
|
49
|
+
throw new Error('FLEETKEEP_API_URL must be an HTTP or HTTPS origin without a path.');
|
|
50
|
+
}
|
|
51
|
+
return url.origin;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function request(path, { method = 'GET', body }) {
|
|
55
|
+
const token = await getAccessToken();
|
|
56
|
+
const response = await fetch(`${apiOrigin()}${path}`, {
|
|
57
|
+
method,
|
|
58
|
+
headers: {
|
|
59
|
+
authorization: `Bearer ${token}`,
|
|
60
|
+
accept: 'application/json',
|
|
61
|
+
...(body ? { 'content-type': 'application/json' } : {})
|
|
62
|
+
},
|
|
63
|
+
...(body ? { body: JSON.stringify(body) } : {})
|
|
64
|
+
});
|
|
65
|
+
const payload = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
|
|
66
|
+
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}`);
|
|
69
|
+
}
|
|
70
|
+
return payload;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function spreadsheetPayload(fileArg) {
|
|
74
|
+
if (!fileArg || fileArg.startsWith('--')) throw new Error('A .csv or .xlsx file is required.');
|
|
75
|
+
const path = resolve(fileArg);
|
|
76
|
+
const extension = extname(path).toLowerCase();
|
|
77
|
+
if (!['.csv', '.xlsx'].includes(extension)) {
|
|
78
|
+
throw new Error('Fleetkeep imports .csv and .xlsx files. Save older .xls files first.');
|
|
79
|
+
}
|
|
80
|
+
const bytes = await readFile(path);
|
|
81
|
+
const sourceName = basename(path);
|
|
82
|
+
return extension === '.csv'
|
|
83
|
+
? { sourceName, csv: bytes.toString('utf8') }
|
|
84
|
+
: { sourceName, contentBase64: bytes.toString('base64') };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function printPreview(payload, jsonOutput, fileArg) {
|
|
88
|
+
if (jsonOutput) {
|
|
89
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const p = payload.preview;
|
|
93
|
+
process.stdout.write(
|
|
94
|
+
[
|
|
95
|
+
`Source: ${payload.sourceName}`,
|
|
96
|
+
`Digest: ${payload.digest}`,
|
|
97
|
+
`Rows: ${p.detectedVehicleCount}`,
|
|
98
|
+
`Valid registrations: ${p.validRegistrations}`,
|
|
99
|
+
`Invalid registrations: ${p.invalidRegistrations}`,
|
|
100
|
+
`Duplicate registrations: ${p.duplicateRegistrations}`,
|
|
101
|
+
`Over self-serve limit: ${p.overSelfServeLimit ? 'yes' : 'no'}`,
|
|
102
|
+
'',
|
|
103
|
+
'No data was changed. To apply this exact file:',
|
|
104
|
+
`fleetkeep import apply ${JSON.stringify(fileArg)} --confirm ${payload.digest}`
|
|
105
|
+
].join('\n') + '\n'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function printImport(payload, jsonOutput) {
|
|
110
|
+
if (jsonOutput) {
|
|
111
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const summary = payload.summary;
|
|
115
|
+
process.stdout.write(
|
|
116
|
+
[
|
|
117
|
+
payload.replayed ? 'Import result replayed from the original request.' : 'Import completed.',
|
|
118
|
+
`Added: ${summary.added}`,
|
|
119
|
+
`Existing vehicles updated with equipment: ${summary.updated}`,
|
|
120
|
+
`Duplicates or unchanged: ${summary.duplicates}`,
|
|
121
|
+
`Invalid: ${summary.invalid}`,
|
|
122
|
+
`Database errors: ${summary.dbErrors}`
|
|
123
|
+
].join('\n') + '\n'
|
|
124
|
+
);
|
|
125
|
+
for (const outcome of payload.outcomes) {
|
|
126
|
+
process.stdout.write(`${outcome.registration || `line ${outcome.line}`}: ${outcome.message}\n`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function main() {
|
|
131
|
+
const args = process.argv.slice(2);
|
|
132
|
+
const jsonOutput = args.includes('--json');
|
|
133
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
134
|
+
process.stdout.write(`${usage()}\n`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (args[0] === 'auth' && args[1] === 'login') {
|
|
139
|
+
await login({ noBrowser: args.includes('--no-browser') });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (args[0] === 'auth' && args[1] === 'status') {
|
|
144
|
+
process.stdout.write(`${await authStatus()}\n`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (args[0] === 'auth' && args[1] === 'logout') {
|
|
149
|
+
await logout();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (args[0] === 'vehicles' && args[1] === 'list') {
|
|
154
|
+
const payload = await request('/api/v1/vehicles', {});
|
|
155
|
+
if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
156
|
+
else {
|
|
157
|
+
for (const vehicle of payload.vehicles || []) {
|
|
158
|
+
process.stdout.write(
|
|
159
|
+
`${vehicle.registration}\t${vehicle.label || ''}\t${vehicle.make || ''}\n`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (args[0] === 'import' && args[1] === 'preview') {
|
|
167
|
+
const body = await spreadsheetPayload(args[2]);
|
|
168
|
+
const payload = await request('/api/v1/imports/vehicles/preview', {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
body
|
|
171
|
+
});
|
|
172
|
+
printPreview(payload, jsonOutput, args[2]);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (args[0] === 'import' && args[1] === 'apply') {
|
|
177
|
+
const confirmDigest = option(args, '--confirm');
|
|
178
|
+
if (!confirmDigest) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
'Apply is blocked until --confirm contains the exact digest returned by preview.'
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const body = await spreadsheetPayload(args[2]);
|
|
184
|
+
const idempotencyKey = option(args, '--idempotency-key') || `cli:${confirmDigest}`;
|
|
185
|
+
const payload = await request('/api/v1/imports/vehicles/apply', {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
body: { ...body, confirmDigest, idempotencyKey }
|
|
188
|
+
});
|
|
189
|
+
printImport(payload, jsonOutput);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
throw new Error(`Unknown command.\n\n${usage()}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
main().catch((error) => fail(error instanceof Error ? error.message : String(error)));
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fleetkeep/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Preview and apply Fleetkeep fleet spreadsheet imports through the Fleetkeep API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fleetkeep": "fleetkeep.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"auth.mjs",
|
|
11
|
+
"fleetkeep.mjs",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"license": "UNLICENSED"
|
|
18
|
+
}
|