@fleetkeep/cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,27 @@ API. Friendly commands cover common vehicle and spreadsheet work. The allowliste
5
5
  command covers equipment, maintenance, defects, walkarounds, files, reminders, contacts, bookings,
6
6
  tolls, tracking, guidance and exports without opening arbitrary or unpublished paths.
7
7
 
8
+ ## Start here
9
+
10
+ Run `npx @fleetkeep/cli setup` after installation. It signs you in through your
11
+ browser, checks your existing account and shows the next step. Return to the
12
+ terminal after each website step and refresh to see the current status.
13
+
14
+ Choose your intended fleet size on the website. Eligible new accounts can start
15
+ a 30-day trial without a card; the price is shown before confirmation. Existing
16
+ customers keep their account and subscription. App-store subscribers should
17
+ contact Fleetkeep about Fleet access before purchasing a separate web plan.
18
+
19
+ Once Fleet access is active, use `fleetkeep import preview "fleet.xlsx"` to
20
+ review a spreadsheet before saving. The preview supplies the exact confirmation
21
+ command. Review missing dates, reminder timing and recipients on the dashboard.
22
+ Add a card only through the website after checking the first payment date.
23
+
24
+ `fleetkeep setup --json` returns the current setup state for automation without
25
+ opening a browser or starting an interactive login. `--no-browser` prints links
26
+ for you to open yourself. Setup never starts a subscription, imports records or
27
+ enables reminders on its own. It requires the matching setup API deployment.
28
+
8
29
  ## Shared jobs and running costs
9
30
 
10
31
  Friendly commands return JSON, including missing-data and stale-tracking fields:
@@ -39,6 +60,51 @@ not enter them again. Account JSON exports include jobs, journeys and additional
39
60
  Xero authorisation and bill review take place in the web portal. Saved SHVL grants and
40
61
  credentials are not transferred between products or customer workspaces.
41
62
 
63
+ ## Service intervals and equipment
64
+
65
+ Record completed servicing by registration without looking up an internal vehicle ID first:
66
+
67
+ ```sh
68
+ fleetkeep service record "BD15 KFG" --date 2026-09-27 --mileage 48212 \
69
+ --work "Scheduled service"
70
+ ```
71
+
72
+ Fleetkeep matches the exact normalised registration and shows the proposed record. Nothing is
73
+ saved until the operator repeats the same command with the short-lived confirmation token. The
74
+ CLI derives a stable idempotency key from the matched vehicle and record, so an identical retry
75
+ returns the original result rather than creating a duplicate.
76
+
77
+ Set one service interval in days, weeks or months. The vehicle command clears the
78
+ alternative unit when switching, and converts weeks to days:
79
+
80
+ ```sh
81
+ fleetkeep vehicles update <vehicle-id> --service-interval-weeks 6
82
+ fleetkeep vehicles update <vehicle-id> --service-interval-months 12
83
+ fleetkeep vehicles update <vehicle-id> --clear serviceIntervalDays,serviceIntervalMonths
84
+ fleetkeep api call getWalkaroundSummary
85
+ fleetkeep api call listVehicleEquipment --path id=<vehicle-id>
86
+ fleetkeep api call updateVehicleEquipment --path id=<vehicle-id> --data-file equipment.json
87
+ ```
88
+
89
+ The walkaround summary returns vehicles checked today in London time, not a
90
+ roadworthiness certificate. Equipment payloads support `presetKey`, provider,
91
+ reference, cover dates, reminders and up to eight `details` label/value pairs.
92
+ Use an `Arrangement` detail for the confirmed ownership type, such as `Hired`.
93
+ Omitting details preserves existing values; an empty array explicitly clears them.
94
+ Use `previewEquipmentTemplate` before `applyEquipmentTemplate` for fleet-wide setup.
95
+
96
+ With the matching API update deployed, `createMaintenance` also accepts
97
+ `forecastConsent: true` and a `forecast` object containing `baselineDate`,
98
+ `baselineMileage`, `intervalMiles`, `annualMileage` and `reminderDays` (0 means on
99
+ the estimated date). Supply the actual last check, not an assumed MOT check date.
100
+ Duplicate setup does not create another reminder; refresh the maintenance list.
101
+ `completeMaintenance` accepts `completedDate` and `completedMileage` for
102
+ an existing forecast. Supply the identical body for preview and confirmation;
103
+ the next cycle retains the saved mileage interval and reminder timing. Ordinary
104
+ maintenance updates cannot edit calculated forecast dates. There are no automatic
105
+ fuel-card feeds in the CLI. Wex credentials and Xero authorisation are entered in
106
+ the website, never through CLI command arguments.
107
+
42
108
  ## Safety model
43
109
 
44
110
  - `api operations` is generated as a fixed allowlist of the published OpenAPI operation IDs.
@@ -82,6 +148,9 @@ fleetkeep api call listTrackingSnapshots
82
148
  fleetkeep api call getWexRouteHistory --path vehicleId=<vehicle-id> --query period=7d
83
149
  fleetkeep api call createServiceRecord --path id=<vehicle-id> \
84
150
  --idempotency-key <unique-value> --data-file service-record.json
151
+ fleetkeep api call createServiceRecord --path id=<vehicle-id> \
152
+ --idempotency-key <same-unique-value> --confirmation <server-token> \
153
+ --data-file service-record.json
85
154
  fleetkeep api call uploadVehiclePhoto --path id=<vehicle-id> --file photo=van.jpg
86
155
  fleetkeep api call downloadFleetSpreadsheet --output fleetkeep-export.xlsx
87
156
  ```
@@ -95,3 +164,7 @@ Set `FLEETKEEP_API_URL` to an alternate origin for local or staging verification
95
164
  automation, set `FLEETKEEP_ACCESS_TOKEN` in the process environment. OAuth access and refresh tokens
96
165
  are never accepted in command arguments. Short-lived action confirmations are supplied separately
97
166
  with `--confirmation`.
167
+
168
+ Service-record creation is a two-step operator-confirmed action. The first call returns a 409
169
+ challenge. Retry the identical body and idempotency key with its `--confirmation` token. A safe
170
+ retry with the same key returns the original record instead of creating a duplicate.
package/api-command.mjs CHANGED
@@ -97,6 +97,9 @@ function operationPath(operation, assignments) {
97
97
  if (!values.has(name) || values.get(name) === '') {
98
98
  throw new Error(`${operation.id} requires --path ${name}=<value>.`);
99
99
  }
100
+ if (values.get(name) === '.' || values.get(name) === '..') {
101
+ throw new Error(`--path ${name} must not be a dot segment.`);
102
+ }
100
103
  path = path.replace(`{${name}}`, encodeURIComponent(values.get(name)));
101
104
  }
102
105
  return path;
@@ -280,9 +283,9 @@ export async function runApiCommand(args) {
280
283
  throw new Error('Use `fleetkeep api operations` or `fleetkeep api call`.');
281
284
  const operationId = args[1];
282
285
  if (!operationId || operationId.startsWith('--')) throw new Error('An operation ID is required.');
283
- const operation = operations[operationId];
284
- if (!operation) {
286
+ if (!Object.hasOwn(operations, operationId)) {
285
287
  throw new Error(`Unknown or unpublished Fleetkeep operation: ${operationId}.`);
286
288
  }
289
+ const operation = operations[operationId];
287
290
  await callOperation(operation, args.slice(2));
288
291
  }
package/auth.mjs CHANGED
@@ -224,7 +224,7 @@ export async function getAccessToken() {
224
224
  }
225
225
 
226
226
  /** @param {string} url */
227
- function openBrowser(url) {
227
+ export function openBrowser(url) {
228
228
  /** @type {[string, string[]]} */
229
229
  const command =
230
230
  process.platform === 'darwin'
package/fleetkeep.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { readFile } from 'node:fs/promises';
4
+ import { createHash } from 'node:crypto';
4
5
  import { basename, extname, resolve } from 'node:path';
5
6
  import { runApiCommand } from './api-command.mjs';
7
+ import { runSetup } from './setup.mjs';
6
8
  import { authStatus, getAccessToken, login, logout } from './auth.mjs';
7
9
 
8
10
  const DEFAULT_API_URL = 'https://fleetkeep.co.uk';
@@ -11,6 +13,7 @@ function usage() {
11
13
  return `Fleetkeep CLI
12
14
 
13
15
  Usage:
16
+ fleetkeep setup [--no-browser] [--json]
14
17
  fleetkeep auth login [--no-browser]
15
18
  fleetkeep auth status
16
19
  fleetkeep auth logout
@@ -20,6 +23,8 @@ Usage:
20
23
  fleetkeep vehicles update <vehicle-id> [vehicle options] [--clear <field,...>] [--json]
21
24
  fleetkeep vehicles delete <vehicle-id> [--confirmation <server-token>] [--json]
22
25
  fleetkeep vehicles bulk-add <registration>... [--json]
26
+ fleetkeep service record <registration> --date <YYYY-MM-DD> --mileage <integer> --work <text>
27
+ [--supplier <text>] [--invoice <text>] [--confirmation <server-token>] [--json]
23
28
  fleetkeep drivers list [--json]
24
29
  fleetkeep drivers activity [--search <job-driver-registration>] [--json]
25
30
  fleetkeep costs list [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--json]
@@ -56,6 +61,8 @@ Vehicle options:
56
61
  --last-service-date <YYYY-MM-DD>
57
62
  --last-service-mileage <integer>
58
63
  --service-interval-months <integer>
64
+ --service-interval-days <integer>
65
+ --service-interval-weeks <integer>
59
66
  --operational-status <active|standby|in_maintenance|off_road|sold_or_removed>
60
67
  --assigned-driver-name <text>
61
68
  --assigned-driver-contact <text>
@@ -63,7 +70,10 @@ Vehicle options:
63
70
 
64
71
  Clearable update fields:
65
72
  label, insuranceDueDate, lastServiceDate, lastServiceMileage, serviceIntervalMonths,
66
- assignedDriverName, assignedDriverContact, statusNotes
73
+ serviceIntervalDays, assignedDriverName, assignedDriverContact, statusNotes
74
+
75
+ Choose one service interval unit. Weeks are stored as days. Setting an interval
76
+ clears the alternative unit so an old interval cannot override the new one.
67
77
 
68
78
  Environment:
69
79
  FLEETKEEP_ACCESS_TOKEN Optional OAuth token override for automation
@@ -112,6 +122,11 @@ const VEHICLE_OPTIONS = new Map([
112
122
  '--service-interval-months',
113
123
  { field: 'serviceIntervalMonths', type: 'integer', min: 1, max: 120 }
114
124
  ],
125
+ ['--service-interval-days', { field: 'serviceIntervalDays', type: 'integer', min: 1, max: 3650 }],
126
+ [
127
+ '--service-interval-weeks',
128
+ { field: 'serviceIntervalDays', type: 'integer', min: 1, max: 521, multiplier: 7 }
129
+ ],
115
130
  ['--operational-status', { field: 'operationalStatus', type: 'status' }],
116
131
  ['--assigned-driver-name', { field: 'assignedDriverName', type: 'string' }],
117
132
  ['--assigned-driver-contact', { field: 'assignedDriverContact', type: 'string' }],
@@ -160,11 +175,22 @@ function parseVehicleValue(name, value, config) {
160
175
 
161
176
  function vehicleBody(args, { allowClear = false } = {}) {
162
177
  const body = {};
178
+ const intervalOptions = [
179
+ '--service-interval-days',
180
+ '--service-interval-weeks',
181
+ '--service-interval-months'
182
+ ];
183
+ if (intervalOptions.filter((name) => args.includes(name)).length > 1) {
184
+ throw new Error('Choose one service interval unit: days, weeks or months.');
185
+ }
163
186
  const recognised = new Set(['--json']);
164
187
  for (const [name, config] of VEHICLE_OPTIONS) {
165
188
  recognised.add(name);
166
189
  const value = requiredValue(args, name);
167
- if (value !== null) body[config.field] = parseVehicleValue(name, value, config);
190
+ if (value !== null) {
191
+ const parsed = parseVehicleValue(name, value, config);
192
+ body[config.field] = config.multiplier ? parsed * config.multiplier : parsed;
193
+ }
168
194
  }
169
195
 
170
196
  if (allowClear) {
@@ -189,6 +215,8 @@ function vehicleBody(args, { allowClear = false } = {}) {
189
215
  for (const arg of args) {
190
216
  if (arg.startsWith('--') && !recognised.has(arg)) throw new Error(`Unknown option: ${arg}.`);
191
217
  }
218
+ if (body.serviceIntervalDays != null) body.serviceIntervalMonths = null;
219
+ if (body.serviceIntervalMonths != null) body.serviceIntervalDays = null;
192
220
  return body;
193
221
  }
194
222
 
@@ -237,6 +265,129 @@ function printJsonOr(payload, jsonOutput, line) {
237
265
  process.stdout.write(jsonOutput ? `${JSON.stringify(payload, null, 2)}\n` : `${line}\n`);
238
266
  }
239
267
 
268
+ function normaliseRegistration(value) {
269
+ return value.toUpperCase().replace(/\s+/g, '');
270
+ }
271
+
272
+ function serviceRecordBody(args) {
273
+ const allowed = new Set([
274
+ '--date',
275
+ '--mileage',
276
+ '--work',
277
+ '--supplier',
278
+ '--invoice',
279
+ '--notes',
280
+ '--next-date',
281
+ '--next-mileage',
282
+ '--confirmation',
283
+ '--json'
284
+ ]);
285
+ for (const arg of args) {
286
+ if (arg.startsWith('--') && !allowed.has(arg)) throw new Error(`Unknown option: ${arg}.`);
287
+ }
288
+ const servicedAt = option(args, '--date');
289
+ const mileage = option(args, '--mileage');
290
+ const workSummary = option(args, '--work');
291
+ if (!servicedAt) throw new Error('--date is required.');
292
+ parseVehicleValue('--date', servicedAt, { type: 'date' });
293
+ if (!mileage) throw new Error('--mileage is required.');
294
+ const odometerMileage = parseVehicleValue('--mileage', mileage, {
295
+ type: 'integer',
296
+ min: 0,
297
+ max: 1500000
298
+ });
299
+ if (!workSummary) throw new Error('--work is required.');
300
+ const body = { servicedAt, odometerMileage, workSummary };
301
+ for (const [name, field] of [
302
+ ['--supplier', 'supplier'],
303
+ ['--invoice', 'invoiceReference'],
304
+ ['--notes', 'notes'],
305
+ ['--next-date', 'nextServiceDueDate']
306
+ ]) {
307
+ const value = option(args, name);
308
+ if (value) {
309
+ if (name === '--next-date') parseVehicleValue(name, value, { type: 'date' });
310
+ body[field] = value;
311
+ }
312
+ }
313
+ const nextMileage = option(args, '--next-mileage');
314
+ if (nextMileage) {
315
+ body.nextServiceDueMileage = parseVehicleValue('--next-mileage', nextMileage, {
316
+ type: 'integer',
317
+ min: 0,
318
+ max: 1500000
319
+ });
320
+ }
321
+ return body;
322
+ }
323
+
324
+ async function recordServiceByRegistration(registration, args, jsonOutput) {
325
+ if (!registration || registration.startsWith('--'))
326
+ throw new Error('A registration is required.');
327
+ const body = serviceRecordBody(args);
328
+ const listed = await request('/api/v1/vehicles', {});
329
+ const wanted = normaliseRegistration(registration);
330
+ const matches = (listed.vehicles || []).filter(
331
+ (vehicle) => normaliseRegistration(vehicle.registration || '') === wanted
332
+ );
333
+ if (matches.length === 0) throw new Error(`No vehicle matched registration ${registration}.`);
334
+ if (matches.length > 1)
335
+ throw new Error(`More than one vehicle matched registration ${registration}.`);
336
+ const vehicle = matches[0];
337
+ const idempotencyKey = `service:${createHash('sha256')
338
+ .update(JSON.stringify({ vehicleId: vehicle.id, body }))
339
+ .digest('hex')
340
+ .slice(0, 32)}`;
341
+ const confirmation = option(args, '--confirmation');
342
+ try {
343
+ const payload = await request(
344
+ `/api/v1/vehicles/${encodeURIComponent(vehicle.id)}/service-records`,
345
+ {
346
+ method: 'POST',
347
+ body,
348
+ headers: {
349
+ 'idempotency-key': idempotencyKey,
350
+ ...(confirmation ? { 'x-fleetkeep-confirmation': confirmation } : {})
351
+ }
352
+ }
353
+ );
354
+ printJsonOr(
355
+ payload,
356
+ jsonOutput,
357
+ `${payload.replay ? 'Service record already saved' : 'Service record saved'}: ${vehicle.registration}, ${body.servicedAt}, ${body.odometerMileage.toLocaleString('en-GB')} miles.`
358
+ );
359
+ } catch (error) {
360
+ if (
361
+ error instanceof FleetkeepApiError &&
362
+ error.status === 409 &&
363
+ error.payload?.error === 'confirmation_required' &&
364
+ typeof error.payload.confirmationToken === 'string'
365
+ ) {
366
+ if (jsonOutput)
367
+ process.stdout.write(
368
+ `${JSON.stringify({ ...error.payload, vehicle, proposedRecord: body }, null, 2)}\n`
369
+ );
370
+ else {
371
+ process.stdout.write(
372
+ [
373
+ 'No record was saved.',
374
+ `Vehicle: ${vehicle.registration}${vehicle.label ? ` (${vehicle.label})` : ''}`,
375
+ `Service date: ${body.servicedAt}`,
376
+ `Mileage: ${body.odometerMileage.toLocaleString('en-GB')}`,
377
+ `Work: ${body.workSummary}`,
378
+ '',
379
+ 'After checking those details, repeat the same command with:',
380
+ `--confirmation ${error.payload.confirmationToken}`
381
+ ].join('\n') + '\n'
382
+ );
383
+ }
384
+ process.exitCode = 2;
385
+ return;
386
+ }
387
+ throw error;
388
+ }
389
+ }
390
+
240
391
  async function spreadsheetPayload(fileArg) {
241
392
  if (!fileArg || fileArg.startsWith('--')) throw new Error('A .csv or .xlsx file is required.');
242
393
  const path = resolve(fileArg);
@@ -302,6 +453,11 @@ async function main() {
302
453
  return;
303
454
  }
304
455
 
456
+ if (args[0] === 'setup') {
457
+ await runSetup(args.slice(1));
458
+ return;
459
+ }
460
+
305
461
  if (args[0] === 'costs' && args[1] === 'list') {
306
462
  const query = [];
307
463
  for (const name of ['from', 'to']) {
@@ -375,6 +531,11 @@ async function main() {
375
531
  return;
376
532
  }
377
533
 
534
+ if (args[0] === 'service' && args[1] === 'record') {
535
+ await recordServiceByRegistration(args[2], args.slice(3), jsonOutput);
536
+ return;
537
+ }
538
+
378
539
  if (args[0] === 'vehicles' && args[1] === 'get') {
379
540
  const payload = await request(vehiclePath(args[2]), {});
380
541
  if (jsonOutput) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
package/operations.mjs CHANGED
@@ -34,6 +34,7 @@ const definitions = [
34
34
  ['previewVehicleImport', 'POST', '/api/v1/imports/vehicles/preview', 'vehicles:read'],
35
35
  ['applyVehicleImport', 'POST', '/api/v1/imports/vehicles/apply', 'vehicles:write'],
36
36
  ['getWalkaroundDefinition', 'GET', '/api/v1/operations/walkaround-definition', 'operations:read'],
37
+ ['getWalkaroundSummary', 'GET', '/api/v1/operations/walkaround-summary', 'operations:read'],
37
38
  [
38
39
  'listWalkarounds',
39
40
  'GET',
@@ -91,6 +92,27 @@ const definitions = [
91
92
  'vehicles:write',
92
93
  { confirmation: true }
93
94
  ],
95
+ [
96
+ 'listServiceSchedules',
97
+ 'GET',
98
+ '/api/v1/vehicles/{id}/service-schedules',
99
+ 'maintenance:read',
100
+ { query: ['limit', 'offset'] }
101
+ ],
102
+ [
103
+ 'listServiceEvidenceRequests',
104
+ 'GET',
105
+ '/api/v1/vehicles/{id}/service-evidence-requests',
106
+ 'maintenance:read',
107
+ { query: ['limit', 'offset'] }
108
+ ],
109
+ [
110
+ 'getServiceEvidenceTimeline',
111
+ 'GET',
112
+ '/api/v1/vehicles/{id}/service-evidence-requests/{requestId}/contacts',
113
+ 'maintenance:read',
114
+ { query: ['limit'] }
115
+ ],
94
116
  [
95
117
  'listServiceRecords',
96
118
  'GET',
@@ -103,7 +125,7 @@ const definitions = [
103
125
  'POST',
104
126
  '/api/v1/vehicles/{id}/service-records',
105
127
  'maintenance:write',
106
- { idempotency: true }
128
+ { confirmation: true, idempotency: true }
107
129
  ],
108
130
  [
109
131
  'getServiceRecord',
@@ -224,6 +246,7 @@ const definitions = [
224
246
  'operations:read',
225
247
  { query: ['period'] }
226
248
  ],
249
+ ['getSetupStatus', 'GET', '/api/v1/setup', 'profile:read'],
227
250
  ['getProfile', 'GET', '/api/v1/profile', 'profile:read'],
228
251
  ['getDriverGuidance', 'GET', '/api/v1/driver-guidance', 'guidance:read'],
229
252
  [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetkeep/cli",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Operate every published Fleetkeep API workflow with guarded writes and machine-readable output",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "auth.mjs",
12
12
  "fleetkeep.mjs",
13
13
  "operations.mjs",
14
+ "setup.mjs",
14
15
  "README.md"
15
16
  ],
16
17
  "engines": {
package/setup.mjs ADDED
@@ -0,0 +1,94 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { getAccessToken, loadStoredTokens, login, oauthConfig, openBrowser } from './auth.mjs';
3
+
4
+ /** @param {string[]} args */
5
+ export async function runSetup(args) {
6
+ for (const arg of args) {
7
+ if (!['--json', '--no-browser'].includes(arg)) throw new Error(`Unknown setup option: ${arg}`);
8
+ }
9
+ const json = args.includes('--json');
10
+ const interactive = !json && Boolean(process.stdin.isTTY && process.stdout.isTTY);
11
+ const origin = oauthConfig().origin;
12
+ if (!process.env.FLEETKEEP_ACCESS_TOKEN?.trim() && !(await loadStoredTokens())) {
13
+ if (!interactive) {
14
+ const next = { nextStep: 'sign_in', command: 'fleetkeep auth login', url: `${origin}/login` };
15
+ process.stdout.write(
16
+ json
17
+ ? `${JSON.stringify(next)}\n`
18
+ : `Sign in or create your Fleetkeep account in the browser:\n${next.command}\nThen run fleetkeep setup again.\n`
19
+ );
20
+ return;
21
+ }
22
+ process.stdout.write(
23
+ 'Sign in or create your account in the browser. Existing plans stay on the same account.\n'
24
+ );
25
+ await login({ noBrowser: args.includes('--no-browser') });
26
+ }
27
+ const input = interactive
28
+ ? createInterface({ input: process.stdin, output: process.stdout })
29
+ : null;
30
+ try {
31
+ do {
32
+ const response = await fetch(new URL('/api/v1/setup', origin), {
33
+ headers: { authorization: `Bearer ${await getAccessToken()}`, accept: 'application/json' },
34
+ redirect: 'error',
35
+ signal: AbortSignal.timeout(25_000)
36
+ });
37
+ if (!response.ok)
38
+ throw new Error(
39
+ `Setup status unavailable (${response.status}). No changes were made. Try fleetkeep auth login if your session expired.`
40
+ );
41
+ const state = await response.json();
42
+ if (json) {
43
+ process.stdout.write(`${JSON.stringify(state, null, 2)}\n`);
44
+ return;
45
+ }
46
+ process.stdout.write(
47
+ `\nFleetkeep setup\n${Number(state.vehicleCount).toLocaleString('en-GB')} vehicles saved\n${state.nextAction}\n`
48
+ );
49
+ if (state.billing.trialEndsAt)
50
+ process.stdout.write(
51
+ `Trial ends: ${new Date(state.billing.trialEndsAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/London' })}\n`
52
+ );
53
+ process.stdout.write(`${state.paymentNote}\n`);
54
+ const steps = /** @type {Record<string, string>} */ ({
55
+ plan: 'plans',
56
+ fleet_access: 'billing',
57
+ vehicles: 'addVehicle',
58
+ reminders: 'reminders',
59
+ review: 'dashboard'
60
+ });
61
+ const key = steps[state.nextStep];
62
+ // Only open the known website paths, never an arbitrary returned URL.
63
+ const paths = /** @type {Record<string, string>} */ ({
64
+ plans: '/dashboard/choose-plan',
65
+ billing: '/dashboard/settings#billing',
66
+ addVehicle: '/dashboard/vehicles/new',
67
+ reminders: '/dashboard/settings#reminders',
68
+ dashboard: '/dashboard'
69
+ });
70
+ const url = new URL(paths[key] || '/dashboard', origin).href;
71
+ process.stdout.write(`Next: ${url}\n`);
72
+ if (state.canUseApi)
73
+ process.stdout.write(
74
+ 'Spreadsheet: fleetkeep import preview "fleet.xlsx"\nReview the preview, then use its confirmation command to save.\n'
75
+ );
76
+ if (!input) return;
77
+ const answer = (
78
+ await input.question(
79
+ '\n[o] Open next step [r] Refresh after completing it [Enter] Finish: '
80
+ )
81
+ )
82
+ .trim()
83
+ .toLowerCase();
84
+ if (answer === 'o') {
85
+ if (!args.includes('--no-browser')) openBrowser(url);
86
+ await input.question(
87
+ 'Complete the step in your browser, then press Enter to check again. '
88
+ );
89
+ } else if (answer !== 'r') return;
90
+ } while (true);
91
+ } finally {
92
+ input?.close();
93
+ }
94
+ }