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 +4 -0
- package/dist/cli.js +100 -36
- package/package.json +1 -1
- package/skills/fortrip-hotels/SKILL.md +14 -9
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
|
-
|
|
354
|
-
await
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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 (
|
|
375
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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 =
|
|
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
|
@@ -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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 |
|
|
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
|