fortrip 0.1.0 → 0.1.1

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
@@ -41,6 +41,10 @@ Output is JSON on stdout. Errors are JSON on stderr, with fixed exit codes:
41
41
  - `login` saves the token to `~/.config/fortrip/credentials.json`
42
42
  (`$XDG_CONFIG_HOME` is honoured), mode `0600`. Tokens are stored per API URL,
43
43
  so a token is only ever sent to the server that issued it.
44
+ - Without a token, any other command starts a device login and exits 3 with
45
+ `loginUrl` on stderr instead of waiting. Once that link is approved in the
46
+ browser, rerunning the command saves the token and carries on. This is how
47
+ AI agents sign in, since they can't sit in `login`.
44
48
  - `FORTRIP_TOKEN` overrides the saved login (CI, remote sandboxes).
45
49
  - `FORTRIP_API_URL` overrides the base URL (default `https://fortrip.ai`).
46
50
  Plain `http` is only accepted for `localhost`.
package/dist/cli.js CHANGED
@@ -331,11 +331,21 @@ function resolveApiUrl(env = process.env) {
331
331
  async function readCredentials(path) {
332
332
  try {
333
333
  const parsed = JSON.parse(await readFile(path, "utf8"));
334
- return { tokens: parsed?.tokens ?? {} };
334
+ return { tokens: parsed?.tokens ?? {}, pending: parsed?.pending ?? {} };
335
335
  } catch {
336
- return { tokens: {} };
336
+ return { tokens: {}, pending: {} };
337
337
  }
338
338
  }
339
+ async function writeCredentials(path, file) {
340
+ if (Object.keys(file.tokens).length === 0 && Object.keys(file.pending).length === 0) {
341
+ await rm(path, { force: true });
342
+ return;
343
+ }
344
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
345
+ await writeFile(path, `${JSON.stringify(file, null, 2)}
346
+ `, { mode: 384 });
347
+ await chmod(path, 384);
348
+ }
339
349
  async function loadStoredToken(apiUrl, env = process.env) {
340
350
  const file = await readCredentials(credentialsPath(env));
341
351
  return file.tokens[apiUrl]?.accessToken ?? null;
@@ -350,31 +360,51 @@ async function saveToken(apiUrl, accessToken, env = process.env) {
350
360
  const path = credentialsPath(env);
351
361
  const file = await readCredentials(path);
352
362
  file.tokens[apiUrl] = { accessToken, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
353
- await mkdir(dirname(path), { recursive: true, mode: 448 });
354
- await writeFile(path, `${JSON.stringify(file, null, 2)}
355
- `, { mode: 384 });
356
- await chmod(path, 384);
363
+ delete file.pending[apiUrl];
364
+ await writeCredentials(path, file);
357
365
  }
358
366
  async function deleteToken(apiUrl, env = process.env) {
359
367
  const path = credentialsPath(env);
360
368
  const file = await readCredentials(path);
361
369
  delete file.tokens[apiUrl];
362
- if (Object.keys(file.tokens).length === 0) {
363
- await rm(path, { force: true });
364
- return;
365
- }
366
- await writeFile(path, `${JSON.stringify(file, null, 2)}
367
- `, { mode: 384 });
368
- await chmod(path, 384);
370
+ await writeCredentials(path, file);
371
+ }
372
+ async function loadPendingLogin(apiUrl, env = process.env) {
373
+ const pending = (await readCredentials(credentialsPath(env))).pending[apiUrl];
374
+ return pending && Date.parse(pending.expiresAt) > Date.now() ? pending : null;
375
+ }
376
+ async function savePendingLogin(apiUrl, pending, env = process.env) {
377
+ const path = credentialsPath(env);
378
+ const file = await readCredentials(path);
379
+ if (pending) file.pending[apiUrl] = pending;
380
+ else delete file.pending[apiUrl];
381
+ await writeCredentials(path, file);
369
382
  }
370
383
 
371
384
  // src/auth.ts
372
385
  async function requireToken(apiUrl) {
373
386
  const resolved = await resolveToken(apiUrl);
374
- if (!resolved) {
375
- throw new CliError(`Not logged in to ${apiUrl}. ${LOGIN_HINT}`, EXIT.unauthenticated);
387
+ if (resolved) return resolved.token;
388
+ const pending = await loadPendingLogin(apiUrl);
389
+ if (pending) {
390
+ const result = await exchange(apiUrl, pending.deviceCode);
391
+ if (result.status === "ok") {
392
+ await saveToken(apiUrl, result.accessToken);
393
+ return result.accessToken;
394
+ }
395
+ if (result.status === "authorization_pending" || result.status === "slow_down") {
396
+ throw loginRequired(apiUrl, pending);
397
+ }
376
398
  }
377
- return resolved.token;
399
+ const start = await startDeviceLogin(apiUrl);
400
+ const fresh = {
401
+ deviceCode: start.deviceCode,
402
+ userCode: start.userCode,
403
+ loginUrl: start.verificationUriComplete,
404
+ expiresAt: new Date(Date.now() + start.expiresIn * 1e3).toISOString()
405
+ };
406
+ await savePendingLogin(apiUrl, fresh);
407
+ throw loginRequired(apiUrl, fresh);
378
408
  }
379
409
  async function login({
380
410
  apiUrl,
@@ -382,12 +412,7 @@ async function login({
382
412
  log,
383
413
  wait = sleep
384
414
  }) {
385
- const start = unwrap(
386
- await request(apiUrl, "/api/cli/auth/device", {
387
- method: "POST",
388
- body: { label: deviceLabel() }
389
- })
390
- );
415
+ const start = await startDeviceLogin(apiUrl);
391
416
  log(`Confirm this code in your browser: ${start.userCode}`);
392
417
  log(`If the browser doesn't open, visit: ${start.verificationUriComplete}`);
393
418
  if (shouldOpen) openBrowser(start.verificationUriComplete);
@@ -396,21 +421,16 @@ async function login({
396
421
  const deadline = Date.now() + start.expiresIn * 1e3;
397
422
  while (Date.now() < deadline) {
398
423
  await wait(interval * 1e3);
399
- const poll = await request(apiUrl, "/api/cli/auth/token", {
400
- method: "POST",
401
- body: { deviceCode: start.deviceCode }
402
- });
403
- if (poll.status === 200 && poll.body.success === true) {
404
- const { accessToken } = poll.body.data;
405
- await saveToken(apiUrl, accessToken);
406
- log("Logged in.");
407
- return whoami(apiUrl, accessToken);
408
- }
409
- switch (poll.body.error) {
424
+ const result = await exchange(apiUrl, start.deviceCode);
425
+ switch (result.status) {
426
+ case "ok":
427
+ await saveToken(apiUrl, result.accessToken);
428
+ log("Logged in.");
429
+ return whoami(apiUrl, result.accessToken);
410
430
  case "authorization_pending":
411
431
  continue;
412
432
  case "slow_down":
413
- interval = typeof poll.body.interval === "number" ? poll.body.interval : interval + 5;
433
+ interval = result.interval ?? interval + 5;
414
434
  continue;
415
435
  case "access_denied":
416
436
  throw new CliError("Login was denied in the browser.", EXIT.error);
@@ -419,12 +439,56 @@ async function login({
419
439
  "The login code expired. Run `login` again.",
420
440
  EXIT.error
421
441
  );
422
- default:
423
- unwrap(poll);
424
442
  }
425
443
  }
426
444
  throw new CliError("The login code expired. Run `login` again.", EXIT.error);
427
445
  }
446
+ async function startDeviceLogin(apiUrl) {
447
+ return unwrap(
448
+ await request(apiUrl, "/api/cli/auth/device", {
449
+ method: "POST",
450
+ body: { label: deviceLabel(), platform: process.platform }
451
+ })
452
+ );
453
+ }
454
+ async function exchange(apiUrl, deviceCode) {
455
+ const poll = await request(apiUrl, "/api/cli/auth/token", {
456
+ method: "POST",
457
+ body: { deviceCode }
458
+ });
459
+ if (poll.status === 200 && poll.body.success === true) {
460
+ const { accessToken } = poll.body.data;
461
+ return { status: "ok", accessToken };
462
+ }
463
+ switch (poll.body.error) {
464
+ case "authorization_pending":
465
+ case "access_denied":
466
+ case "expired_token":
467
+ return { status: poll.body.error };
468
+ case "slow_down":
469
+ return {
470
+ status: "slow_down",
471
+ interval: typeof poll.body.interval === "number" ? poll.body.interval : void 0
472
+ };
473
+ default:
474
+ unwrap(poll);
475
+ throw new CliError(`Unexpected login response ${poll.status}`, EXIT.error);
476
+ }
477
+ }
478
+ function loginRequired(apiUrl, pending) {
479
+ return new CliError(
480
+ `Not logged in to ${apiUrl}. Ask the user to open loginUrl and approve, then rerun this command (or run \`npx fortrip login\` in a terminal).`,
481
+ EXIT.unauthenticated,
482
+ {
483
+ loginUrl: pending.loginUrl,
484
+ userCode: pending.userCode,
485
+ expiresInSeconds: Math.max(
486
+ 0,
487
+ Math.round((Date.parse(pending.expiresAt) - Date.now()) / 1e3)
488
+ )
489
+ }
490
+ );
491
+ }
428
492
  async function logout(apiUrl) {
429
493
  const token = await loadStoredToken(apiUrl);
430
494
  if (!token) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fortrip",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Search, price and book Fortrip hotels from the terminal or an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,16 +24,21 @@ Not for flights, activities, or changing/cancelling an existing booking.
24
24
  Run commands with `npx -y fortrip <command>` (or `fortrip <command>`
25
25
  if installed globally).
26
26
 
27
- If any command exits with code **3** (not logged in, or the token was revoked),
28
- ask the user to run this in their own terminal, then retry:
27
+ When the user first asks about hotels, run `fortrip whoami` before asking
28
+ follow-up questions, so a login can happen while they answer.
29
29
 
30
- ```bash
31
- npx fortrip login
32
- ```
30
+ If a command exits with code **3** and stderr has `loginUrl`, show it to the
31
+ user as a link together with `userCode`, e.g. "Sign in to Fortrip: <loginUrl>
32
+ (code BCDF-GHJK) and tell me when you're done." When they confirm, rerun the
33
+ same command; it finishes the login itself. Don't run `fortrip login` — it
34
+ waits for up to 10 minutes. If the rerun gives a new `loginUrl`, the old one
35
+ expired or was denied; show the new one.
33
36
 
34
- It opens a browser page where they sign in to Fortrip and confirm a code that
35
- matches the one in the terminal. In CI or a remote sandbox, the user can set
36
- `FORTRIP_TOKEN` instead. Never ask the user to paste a token into the chat.
37
+ If exit 3 has no `loginUrl`, the saved token was revoked: run `fortrip logout`,
38
+ then rerun the command to get a link.
39
+
40
+ In CI, the user can set `FORTRIP_TOKEN` instead. Never ask the user to paste a
41
+ token into the chat.
37
42
 
38
43
  ## Exit codes
39
44
 
@@ -42,7 +47,7 @@ matches the one in the terminal. In CI or a remote sandbox, the user can set
42
47
  | 0 | Success; JSON on stdout | Continue |
43
48
  | 1 | Other error (supplier error, rate no longer bookable, network) | Read `error` on stderr; re-run `rates` if a rate went stale |
44
49
  | 2 | Invalid arguments | Fix the flags; stderr `issues` lists the bad fields |
45
- | 3 | Not logged in | Ask the user to run `login` (above) |
50
+ | 3 | Not logged in | Show `loginUrl` to the user, then rerun (above) |
46
51
  | 4 | Rate limited | Wait `retryAfterSeconds`, then retry once |
47
52
 
48
53
  ## Recommended order