@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.
Files changed (4) hide show
  1. package/README.md +33 -0
  2. package/auth.mjs +303 -0
  3. package/fleetkeep.mjs +196 -0
  4. 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
+ }