@voyagier/cli
Advanced tools
| import { getApiUrl } from "./config.js"; | ||
| import { deriveBaseUrl } from "./utils.js"; | ||
| function base(baseUrl) { | ||
| return baseUrl ?? deriveBaseUrl(getApiUrl()); | ||
| } | ||
| export function clientPlanUrl(id, baseUrl) { | ||
| return `${base(baseUrl)}/me/trips/plans/${id}`; | ||
| } | ||
| export function advisorPlanUrl(id, baseUrl) { | ||
| return `${base(baseUrl)}/advisor/plans/${id}`; | ||
| } | ||
| export function planUrls(id, baseUrl) { | ||
| const b = base(baseUrl); | ||
| const clientUrl = clientPlanUrl(id, b); | ||
| return { url: clientUrl, clientUrl, advisorUrl: advisorPlanUrl(id, b) }; | ||
| } |
| import { graphql } from "./api.js"; | ||
| import { GET_SELECTION_WITH_MONITOR, GET_BLUEPRINT_MONITOR, REFRESH_SELECTION_OPTIONS, } from "./queries.js"; | ||
| import { classifySelection, isTerminal, } from "./selection-status.js"; | ||
| import { CliError, CliErrorCode } from "./errors.js"; | ||
| export const DEFAULT_RETRY_AFTER_MS = 2000; | ||
| const MAX_DELAY_MS = 8000; | ||
| const BACKOFF_FACTOR = 1.5; | ||
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | ||
| export async function loadSelectionState(selectionId, retryAfterMs, gql = graphql) { | ||
| const data = await gql(GET_SELECTION_WITH_MONITOR, { tripPlanSelectionId: selectionId }); | ||
| const raw = data.getTripPlanSelection; | ||
| if (!raw || !raw.id) { | ||
| throw new CliError(CliErrorCode.NOT_FOUND, `Selection ${selectionId} not found.`); | ||
| } | ||
| let monitor = null; | ||
| if (raw.blueprintMonitorId) { | ||
| try { | ||
| const m = await gql(GET_BLUEPRINT_MONITOR, { | ||
| id: raw.blueprintMonitorId, | ||
| }); | ||
| monitor = m.blueprintMonitor; | ||
| } | ||
| catch { | ||
| monitor = null; | ||
| } | ||
| } | ||
| const state = { | ||
| id: raw.id, | ||
| type: raw.type, | ||
| blueprintMonitorId: raw.blueprintMonitorId, | ||
| optionCount: (raw.options ?? []).length, | ||
| monitor: monitor | ||
| ? { | ||
| id: monitor.id, | ||
| fetchedAt: monitor.fetchedAt, | ||
| lastFetchAttempt: monitor.lastFetchAttempt, | ||
| lastFetchError: monitor.lastFetchError, | ||
| } | ||
| : null, | ||
| }; | ||
| return { raw, result: classifySelection(state, { retryAfterMs }) }; | ||
| } | ||
| export async function pollSelectionOptions(selectionId, initial, opts, deps = {}) { | ||
| const gql = deps.gql ?? graphql; | ||
| const now = deps.now ?? Date.now; | ||
| const sleepFn = deps.sleepFn ?? sleep; | ||
| const retryAfterMs = opts.retryAfterMs ?? DEFAULT_RETRY_AFTER_MS; | ||
| let snap = initial; | ||
| try { | ||
| await gql(REFRESH_SELECTION_OPTIONS, { selectionId }); | ||
| } | ||
| catch { | ||
| } | ||
| const startedAt = now(); | ||
| const deadline = startedAt + opts.timeoutMs; | ||
| let delay = retryAfterMs; | ||
| let attempt = 0; | ||
| while (!isTerminal(snap.result.status) && now() < deadline) { | ||
| const remaining = deadline - now(); | ||
| await sleepFn(Math.min(delay, Math.max(0, remaining))); | ||
| snap = await loadSelectionState(selectionId, retryAfterMs, gql); | ||
| attempt++; | ||
| deps.heartbeat?.({ | ||
| attempt, | ||
| status: snap.result.status, | ||
| optionCount: snap.result.optionCount, | ||
| elapsedMs: now() - startedAt, | ||
| }); | ||
| delay = Math.min(delay * BACKOFF_FACTOR, MAX_DELAY_MS); | ||
| } | ||
| if (!isTerminal(snap.result.status)) { | ||
| return { ...snap, result: { ...snap.result, status: "FETCHING", retryAfterMs } }; | ||
| } | ||
| return snap; | ||
| } | ||
| export async function waitForSelectionOptions(selectionId, opts, deps = {}) { | ||
| const gql = deps.gql ?? graphql; | ||
| const retryAfterMs = opts.retryAfterMs ?? DEFAULT_RETRY_AFTER_MS; | ||
| const initial = await loadSelectionState(selectionId, retryAfterMs, gql); | ||
| if (isTerminal(initial.result.status)) | ||
| return initial; | ||
| return pollSelectionOptions(selectionId, initial, opts, deps); | ||
| } |
+19
-5
@@ -89,3 +89,5 @@ # Voyagier CLI — Agent Reference | ||
| # rows) — picking the identical id on outbound and return is the intended | ||
| # pattern, not a bug: | ||
| # pairing, not a bug. Operating airlines MAY differ between the two legs | ||
| # (mixed-carrier round trips are normal), so don't reject a pairing just | ||
| # because the return leg's carrier isn't the outbound's: | ||
| voyagier selection-options <RETURN_SELECTION_ID> --wait --json | ||
@@ -234,3 +236,5 @@ voyagier select --selection-id <RETURN_SELECTION_ID> --option-id <OPTION_ID> --json | ||
| "title": "...", | ||
| "url": "https://app.voyagier.com/plans/..." | ||
| "url": "https://app.voyagier.com/me/trips/plans/...", | ||
| "clientUrl": "https://app.voyagier.com/me/trips/plans/...", | ||
| "advisorUrl": "https://app.voyagier.com/advisor/plans/..." | ||
| } | ||
@@ -240,2 +244,4 @@ } | ||
| **Plan URL fields.** Payloads that link to a plan emit three fields: `clientUrl` (`<base>/me/trips/plans/{id}`) is the traveller-facing view a client opens; `advisorUrl` (`<base>/advisor/plans/{id}`) is the advisor-facing workspace. `url` is a back-compat alias of `clientUrl` — hand a client the `clientUrl`, and open the `advisorUrl` yourself. The older `<base>/plans/{id}` route is retired. | ||
| **Style B — flat / domain-specific** (clients, `plans list`/`create`, travellers, search, select, whoami — the older surfaces). `select` payloads are flat but DO carry `ok: true`, so `.ok` is checkable on every select outcome: | ||
@@ -247,5 +253,5 @@ | ||
| // clients upsert: { "client": { ... }, "ok": true, "created": false } | ||
| // plans create: { "id": "...", "title": "...", "url": "...", "planSummary": "..." } | ||
| // plans list: { "items": [...], "total": 12, "page": 1, "limit": 20 } | ||
| // search flights: { "tripPlanId": "...", "selectionId": "...", "optionCount": N, "topOptions": [≤10 summaries], "url": "..." } (--full swaps topOptions for the complete options[] dump) | ||
| // plans create: { "id": "...", "title": "...", "url": "...", "clientUrl": "...", "advisorUrl": "...", "planSummary": "..." } | ||
| // plans list: { "items": [...], "total": 12, "page": 1, "limit": 20 } (each item carries url/clientUrl/advisorUrl) | ||
| // search flights: { "tripPlanId": "...", "selectionId": "...", "optionCount": N, "topOptions": [≤10 summaries], "url": "...", "clientUrl": "...", "advisorUrl": "..." } (--full swaps topOptions for the complete options[] dump) | ||
| // select: { "ok": true, "success": true, "type": "option_selected", ... } | ||
@@ -483,2 +489,8 @@ // selection-options: { "selectionId": "...", "status": "...", "optionCount": N, "options": [...] } | ||
| **Prices are party totals.** Every search option's price is the total for the searched traveller group, NOT per-person — do not multiply by traveller count. (Hotel search prices are additionally whole-STAY totals, not nightly.) `book --dry-run` / `quote` are the chargeable truth. | ||
| **Default option order is the server's value ranking**, a composite of price / stops / duration computed server-side — it is NOT sorted by price. `topOptions[0]` (and `options[0]`) is the server's value pick, not the cheapest. To rank by a single factual field, pass `--sort price|duration|stops` (a client-side sort of the returned options); callers that specifically want the cheapest must sort or filter by `price` explicitly. | ||
| **Re-searching a goal reuses its existing selection.** Running `search` again on a goal that already has a selection reuses that selection rather than creating a new one, so a re-search with DIFFERENT dates can return results computed for the original parameters. Always verify the effective dates in the response before selecting — cross-reference the echoed search params (`--full` includes the per-option data) rather than assuming the new dates took effect. | ||
| ### Cart + Book (Style A JSON) | ||
@@ -513,2 +525,4 @@ ```bash | ||
| > | ||
| > 👥 **Search prices are party totals.** Every search/selection option price is the total for the searched traveller group, NOT per-person — do not multiply by traveller count (hotel search prices are additionally whole-STAY totals, not nightly). `quote`'s `chargeableTotal` and `book --dry-run`'s `chargeableSubtotal` are the chargeable truth. | ||
| > | ||
| > ✉️ **`send` is not idempotent** — every invocation emails the client again. Non-interactive runs refuse without `--yes` (`CONFIRMATION_REQUIRED`). Send once; track with `plan-status`. | ||
@@ -515,0 +529,0 @@ |
+185
-38
| import chalk from "chalk"; | ||
| import { createInterface } from "readline/promises"; | ||
| import { stdin, stdout } from "process"; | ||
| import { openBrowser } from "../utils.js"; | ||
| import { openBrowser, maskLoyaltyValue } from "../utils.js"; | ||
| import { saveCredentials, getToken, getApiUrl, clearCredentials, credentialsExist, saveUserContext, getUserContext } from "../config.js"; | ||
| import { graphql } from "../api.js"; | ||
| import { UPDATE_MY_USER } from "../queries.js"; | ||
| import { CliError, CliErrorCode, authFailedMessage } from "../errors.js"; | ||
@@ -54,10 +55,10 @@ const CITY_AIRPORTS = { | ||
| } | ||
| function maskNumber(num, showLast = 4) { | ||
| if (num.length <= showLast) | ||
| return num; | ||
| return "••••" + num.slice(-showLast); | ||
| function formatPassport(p) { | ||
| const bits = [`••••${p.last4}`]; | ||
| if (p.issueCountry) | ||
| bits.push(p.issueCountry); | ||
| if (p.expirationDate) | ||
| bits.push(`exp ${p.expirationDate}`); | ||
| return bits.join(" · "); | ||
| } | ||
| function maskFFPrograms(programs) { | ||
| return programs.map((ff) => ({ airlineCode: ff.airlineCode, membershipNumber: maskNumber(ff.membershipNumber) })); | ||
| } | ||
| async function prompt(rl, question) { | ||
@@ -67,2 +68,48 @@ const answer = await rl.question(question); | ||
| } | ||
| async function promptMuted(rl, question) { | ||
| const iface = rl; | ||
| const origWriteToOutput = iface._writeToOutput; | ||
| const canMute = typeof origWriteToOutput === "function" && !!iface.output; | ||
| let muted = false; | ||
| if (canMute) { | ||
| iface._writeToOutput = (stringToWrite) => { | ||
| if (!muted) | ||
| iface.output.write(stringToWrite); | ||
| }; | ||
| } | ||
| try { | ||
| const pending = rl.question(question); | ||
| muted = true; | ||
| let answer = ""; | ||
| try { | ||
| answer = await pending; | ||
| } | ||
| catch { | ||
| answer = ""; | ||
| } | ||
| return answer.trim(); | ||
| } | ||
| finally { | ||
| if (canMute) { | ||
| iface._writeToOutput = origWriteToOutput; | ||
| iface.output.write("\n"); | ||
| } | ||
| } | ||
| } | ||
| async function readPassportNumber(rl, promptText) { | ||
| for (let attempt = 0; attempt < 2; attempt++) { | ||
| const raw = (await promptMuted(rl, promptText)).toUpperCase(); | ||
| if (!raw) | ||
| return null; | ||
| if (/^[A-Z0-9]{6,9}$/.test(raw)) | ||
| return raw; | ||
| if (attempt === 0) { | ||
| console.log(chalk.yellow(" ⚠ A passport number is 6–9 letters or digits. Let's try that again.")); | ||
| } | ||
| else { | ||
| console.log(chalk.yellow(" ⚠ That still isn't a valid passport number.")); | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| async function readStdin() { | ||
@@ -106,4 +153,10 @@ const chunks = []; | ||
| if (me.frequentFlyerPrograms && me.frequentFlyerPrograms.length > 0) { | ||
| ctx.frequentFlyerPrograms = maskFFPrograms(me.frequentFlyerPrograms); | ||
| ctx.frequentFlyerPrograms = me.frequentFlyerPrograms.map((ff) => ({ | ||
| airlineCode: ff.airlineCode, | ||
| membershipNumber: ff.membershipNumber, | ||
| })); | ||
| } | ||
| if (existingCtx?.hotelLoyaltyPrograms && existingCtx.hotelLoyaltyPrograms.length > 0) { | ||
| ctx.hotelLoyaltyPrograms = existingCtx.hotelLoyaltyPrograms; | ||
| } | ||
| return { ctx, me }; | ||
@@ -135,5 +188,9 @@ } | ||
| if (me.frequentFlyerPrograms && me.frequentFlyerPrograms.length > 0) { | ||
| const ffs = me.frequentFlyerPrograms.map(ff => `${ff.airlineCode} ${maskNumber(ff.membershipNumber)}`).join(", "); | ||
| const ffs = me.frequentFlyerPrograms.map(ff => `${ff.airlineCode} ${maskLoyaltyValue(ff.membershipNumber)}`).join(", "); | ||
| parts.push(` ✈️ ${ffs}`); | ||
| } | ||
| if (ctx.hotelLoyaltyPrograms && ctx.hotelLoyaltyPrograms.length > 0) { | ||
| const hls = ctx.hotelLoyaltyPrograms.map(hl => `${hl.chainCode} ${maskLoyaltyValue(hl.membershipNumber)}`).join(", "); | ||
| parts.push(` 🏨 ${hls}`); | ||
| } | ||
| if (parts.length > 0) { | ||
@@ -207,5 +264,9 @@ console.log(parts.join("\n")); | ||
| if (ctx.frequentFlyerPrograms && ctx.frequentFlyerPrograms.length > 0) { | ||
| const ffs = ctx.frequentFlyerPrograms.map(ff => `${ff.airlineCode} ${maskNumber(ff.membershipNumber)}`).join(", "); | ||
| const ffs = ctx.frequentFlyerPrograms.map(ff => `${ff.airlineCode} ${maskLoyaltyValue(ff.membershipNumber)}`).join(", "); | ||
| console.log(` ✈️ FF: ${ffs}`); | ||
| } | ||
| if (ctx.hotelLoyaltyPrograms && ctx.hotelLoyaltyPrograms.length > 0) { | ||
| const hls = ctx.hotelLoyaltyPrograms.map(hl => `${hl.chainCode} ${maskLoyaltyValue(hl.membershipNumber)}`).join(", "); | ||
| console.log(` 🏨 Hotel: ${hls}`); | ||
| } | ||
| } | ||
@@ -228,3 +289,3 @@ else { | ||
| console.log(" Generate a Personal Access Token at:\n"); | ||
| console.log(chalk.cyan(` ${webUrl}/settings\n`)); | ||
| console.log(chalk.cyan(` ${webUrl}/me/settings/tokens\n`)); | ||
| console.log(" Then run:\n"); | ||
@@ -234,3 +295,3 @@ console.log(chalk.cyan(" voyagier auth set-token <your-token>\n")); | ||
| } | ||
| const settingsUrl = `${webUrl}/settings`; | ||
| const settingsUrl = `${webUrl}/me/settings/tokens`; | ||
| console.log(` 1. Go to ${chalk.cyan(settingsUrl)}`); | ||
@@ -277,3 +338,3 @@ console.log(` 2. Generate a Personal Access Token`); | ||
| .command("setup") | ||
| .description("Configure your traveller profile (airports, cabin, passport, frequent flyer)") | ||
| .description("Configure your traveller profile (airports, cabin, passport, frequent flyer, hotel loyalty)") | ||
| .option("--airports <codes>", "Home airport(s), comma-separated (e.g. BWI,DCA,IAD)") | ||
@@ -402,23 +463,31 @@ .option("--cabin <class>", "Preferred cabin: economy, premium_economy, business, first") | ||
| } | ||
| let passportToSync; | ||
| let ffToSync; | ||
| if (!opts.skipPassport) { | ||
| console.log(` 🛂 ${chalk.bold("Passport")}`); | ||
| if (me.passport) { | ||
| console.log(chalk.dim(` On file: ••••${me.passport.last4} (${me.passport.issueCountry}, exp ${me.passport.expirationDate})`)); | ||
| userCtx.passport = me.passport; | ||
| console.log(chalk.green(" ✓ Imported from profile\n")); | ||
| const onFile = me.passport; | ||
| if (onFile) { | ||
| console.log(chalk.dim(` On file: ${formatPassport(onFile)}`)); | ||
| } | ||
| else if (rl) { | ||
| const last4 = await prompt(rl, " Passport number (last 4 digits, or Enter to skip): "); | ||
| if (last4 && /^\d{4}$/.test(last4)) { | ||
| const issueCountry = await prompt(rl, " Issue country (e.g. US): ") || "US"; | ||
| const nationality = await prompt(rl, " Nationality (e.g. US): ") || issueCountry; | ||
| const expiration = await prompt(rl, " Expiration (YYYY-MM): "); | ||
| userCtx.passport = { | ||
| last4, | ||
| issueCountry: issueCountry.toUpperCase(), | ||
| nationalityCountry: nationality.toUpperCase(), | ||
| expirationDate: expiration || "unknown", | ||
| }; | ||
| console.log(chalk.green(` ✓ ••••${last4} (${userCtx.passport.issueCountry}, exp ${userCtx.passport.expirationDate})\n`)); | ||
| if (rl) { | ||
| const promptText = onFile | ||
| ? " Enter to keep, or type a new number to replace (never displayed): " | ||
| : " Passport number (sent securely to your profile, never displayed — or Enter to skip): "; | ||
| const number = await readPassportNumber(rl, promptText); | ||
| if (number) { | ||
| const defIssue = onFile?.issueCountry ?? "US"; | ||
| const defNat = onFile?.nationalityCountry; | ||
| const defExp = onFile?.expirationDate; | ||
| const issueCountry = ((await prompt(rl, ` Issue country (e.g. US) [${defIssue}]: `)) || defIssue).toUpperCase(); | ||
| const nationality = ((await prompt(rl, ` Nationality (e.g. US)${defNat ? ` [${defNat}]` : ""}: `)) || defNat || issueCountry).toUpperCase(); | ||
| const expiration = (await prompt(rl, ` Expiration (YYYY-MM)${defExp ? ` [${defExp}]` : ""}: `)) || defExp || ""; | ||
| passportToSync = { passportNumber: number, issueCountry, nationalityCountry: nationality }; | ||
| if (expiration) | ||
| passportToSync.expirationDate = expiration; | ||
| console.log(chalk.green(` ✓ ${formatPassport({ last4: number.slice(-4), issueCountry, expirationDate: expiration || undefined })}\n`)); | ||
| } | ||
| else if (onFile) { | ||
| userCtx.passport = onFile; | ||
| console.log(chalk.dim(" Keeping the passport on file.\n")); | ||
| } | ||
| else { | ||
@@ -428,2 +497,6 @@ console.log(chalk.dim(" Skipped.\n")); | ||
| } | ||
| else if (onFile) { | ||
| userCtx.passport = onFile; | ||
| console.log(chalk.green(" ✓ Imported from profile\n")); | ||
| } | ||
| else { | ||
@@ -436,4 +509,3 @@ console.log(chalk.dim(" Skipped (non-interactive).\n")); | ||
| if (me.frequentFlyerPrograms && me.frequentFlyerPrograms.length > 0) { | ||
| userCtx.frequentFlyerPrograms = maskFFPrograms(me.frequentFlyerPrograms); | ||
| const display = me.frequentFlyerPrograms.map(ff => `${ff.airlineCode} ${maskNumber(ff.membershipNumber)}`).join(", "); | ||
| const display = me.frequentFlyerPrograms.map(ff => `${ff.airlineCode} ${maskLoyaltyValue(ff.membershipNumber)}`).join(", "); | ||
| console.log(chalk.dim(` On file: ${display}`)); | ||
@@ -458,4 +530,4 @@ console.log(chalk.green(" ✓ Imported from profile\n")); | ||
| if (/^[A-Z0-9]{2}$/.test(airline)) { | ||
| programs.push({ airlineCode: airline, membershipNumber: maskNumber(number) }); | ||
| console.log(chalk.green(` ✓ ${airline} ${maskNumber(number)}`)); | ||
| programs.push({ airlineCode: airline, membershipNumber: number }); | ||
| console.log(chalk.green(` ✓ ${airline} ${maskLoyaltyValue(number)}`)); | ||
| } | ||
@@ -472,3 +544,3 @@ else { | ||
| if (programs.length > 0) { | ||
| userCtx.frequentFlyerPrograms = programs; | ||
| ffToSync = programs; | ||
| } | ||
@@ -481,2 +553,73 @@ console.log(); | ||
| } | ||
| console.log(` 🏨 ${chalk.bold("Hotel Loyalty")}`); | ||
| const existingHotel = userCtx.hotelLoyaltyPrograms ?? []; | ||
| if (existingHotel.length > 0) { | ||
| const display = existingHotel.map(hl => `${hl.chainCode} ${maskLoyaltyValue(hl.membershipNumber)}`).join(", "); | ||
| console.log(chalk.dim(` On file: ${display}`)); | ||
| } | ||
| if (rl) { | ||
| const programs = []; | ||
| let adding = true; | ||
| while (adding) { | ||
| const hlInput = await prompt(rl, programs.length === 0 | ||
| ? " Add a program (e.g. HI 12345678, or Enter to skip): " | ||
| : " Add another (or Enter to finish): "); | ||
| if (!hlInput) { | ||
| adding = false; | ||
| } | ||
| else { | ||
| const parts = hlInput.split(/\s+/); | ||
| if (parts.length >= 2) { | ||
| const chain = parts[0].toUpperCase(); | ||
| const number = parts.slice(1).join(""); | ||
| if (!/^[A-Z]{2}$/.test(chain)) { | ||
| console.log(chalk.yellow(` ⚠ Invalid chain code "${chain}" (expected 2 letters)`)); | ||
| } | ||
| else if (!/^\d+$/.test(number)) { | ||
| console.log(chalk.yellow(" ⚠ Member number must be digits only (the chain code is added at booking time)")); | ||
| } | ||
| else { | ||
| programs.push({ chainCode: chain, membershipNumber: number }); | ||
| console.log(chalk.green(` ✓ ${chain} ${maskLoyaltyValue(number)}`)); | ||
| } | ||
| } | ||
| else { | ||
| console.log(chalk.yellow(" ⚠ Format: CHAIN NUMBER (e.g. HI 12345678)")); | ||
| } | ||
| } | ||
| } | ||
| if (programs.length > 0) { | ||
| userCtx.hotelLoyaltyPrograms = programs; | ||
| } | ||
| console.log(); | ||
| } | ||
| else if (existingHotel.length === 0) { | ||
| console.log(chalk.dim(" Skipped (non-interactive).\n")); | ||
| } | ||
| else { | ||
| console.log(); | ||
| } | ||
| if (passportToSync || ffToSync) { | ||
| const updateInput = {}; | ||
| if (passportToSync) | ||
| updateInput.passport = passportToSync; | ||
| if (ffToSync) | ||
| updateInput.frequentFlyerPrograms = ffToSync; | ||
| try { | ||
| const data = await graphql(UPDATE_MY_USER, { input: updateInput }); | ||
| const updated = data.updateMyUser; | ||
| if (passportToSync) | ||
| userCtx.passport = updated.passport ?? undefined; | ||
| if (ffToSync && updated.frequentFlyerPrograms) { | ||
| userCtx.frequentFlyerPrograms = updated.frequentFlyerPrograms.map(ff => ({ | ||
| airlineCode: ff.airlineCode, | ||
| membershipNumber: ff.membershipNumber, | ||
| })); | ||
| } | ||
| } | ||
| catch { | ||
| console.log(chalk.red(" ✗ Profile sync failed — your local setup is saved.")); | ||
| console.log(chalk.dim(" Run `voyagier auth setup` again later or check `voyagier doctor`.\n")); | ||
| } | ||
| } | ||
| saveUserContext(userCtx); | ||
@@ -492,7 +635,11 @@ console.log(chalk.dim(" ──────────────────")); | ||
| if (userCtx.passport) | ||
| console.log(` Passport: ••••${userCtx.passport.last4} (${userCtx.passport.issueCountry}, exp ${userCtx.passport.expirationDate})`); | ||
| console.log(` Passport: ${formatPassport(userCtx.passport)}`); | ||
| if (userCtx.frequentFlyerPrograms && userCtx.frequentFlyerPrograms.length > 0) { | ||
| const ffs = userCtx.frequentFlyerPrograms.map(ff => `${ff.airlineCode} ${maskNumber(ff.membershipNumber)}`).join(", "); | ||
| const ffs = userCtx.frequentFlyerPrograms.map(ff => `${ff.airlineCode} ${maskLoyaltyValue(ff.membershipNumber)}`).join(", "); | ||
| console.log(` FF: ${ffs}`); | ||
| } | ||
| if (userCtx.hotelLoyaltyPrograms && userCtx.hotelLoyaltyPrograms.length > 0) { | ||
| const hls = userCtx.hotelLoyaltyPrograms.map(hl => `${hl.chainCode} ${maskLoyaltyValue(hl.membershipNumber)}`).join(", "); | ||
| console.log(` Hotel: ${hls}`); | ||
| } | ||
| console.log(chalk.dim(`\n Search flights: voyagier search flights --to SJU --date 2026-05-14\n`)); | ||
@@ -499,0 +646,0 @@ } |
@@ -6,7 +6,9 @@ import chalk from "chalk"; | ||
| import { formatPrice, openBrowser, deriveBaseUrl, shellArg, cents } from "../utils.js"; | ||
| import { clientPlanUrl, planUrls } from "../plan-urls.js"; | ||
| import { resolvePlanArg } from "../resolve-plan-arg.js"; | ||
| import { hintCheckoutCreated, hintBookingConfirmed, hintBookingPending, hintDryRun } from "../hints.js"; | ||
| import { GET_CART_V2, CREATE_CHECKOUT, GET_PAYMENT_CHECKOUTS } from "../queries.js"; | ||
| import { GET_CART_V2, CREATE_CHECKOUT, GET_PAYMENT_CHECKOUTS, GET_PLAN_STATUS } from "../queries.js"; | ||
| import { collectBlockers, filterBookable, filterByTypes, } from "./cart-helpers.js"; | ||
| import { buildCheckoutPreview } from "./checkout-preview.js"; | ||
| import { buildPlanStatus, resolveHotelCodes, } from "./plan-status.js"; | ||
| export function parseMoney(raw, flagName) { | ||
@@ -30,2 +32,60 @@ const cleaned = raw.trim().replace(/^\$/, ""); | ||
| } | ||
| export function blockerFix(b, travellers) { | ||
| switch (b.kind) { | ||
| case "TRAVELLER_DATA": { | ||
| const missing = travellers.find((t) => t.travellerId === b.refs.travellerId)?.missing ?? []; | ||
| const flags = [ | ||
| missing.includes("gender") ? "--gender <M|F|X>" : null, | ||
| missing.includes("dateOfBirth") ? "--dob <YYYY-MM-DD>" : null, | ||
| missing.includes("passport") | ||
| ? "--passport-number <number> --passport-country <code> --passport-expiry <YYYY-MM>" | ||
| : null, | ||
| ].filter(Boolean); | ||
| return `voyagier travellers update ${shellArg(b.refs.travellerId ?? "")} ${flags.join(" ")}`.trim(); | ||
| } | ||
| case "PICK_PENDING": { | ||
| const single = b.candidateSelectionIds && b.candidateSelectionIds.length === 1 | ||
| ? b.candidateSelectionIds[0] | ||
| : b.refs.selectionId; | ||
| if (single && !(b.candidateSelectionIds && b.candidateSelectionIds.length > 1)) { | ||
| return `voyagier select --selection-id ${shellArg(single)} --option-id <optionId>`; | ||
| } | ||
| return `voyagier plans goal ${shellArg(b.refs.goalId ?? "")} --json`; | ||
| } | ||
| case "SELECTION_INPUT": | ||
| case "REQUIREMENT_UNMET": | ||
| default: | ||
| return `voyagier plans goal ${shellArg(b.refs.goalId ?? "")} --json`; | ||
| } | ||
| } | ||
| async function enforcePlanReadiness(planId, baseUrl, planIdArg) { | ||
| let statusData; | ||
| try { | ||
| statusData = await graphql(GET_PLAN_STATUS, { id: planId }); | ||
| } | ||
| catch (err) { | ||
| const note = "Could not verify the plan's readiness before checkout — refusing to book " + | ||
| `(re-run with --force-checkout to trust the server's own validation, or check: voyagier plan-status ${planIdArg}).`; | ||
| if (err instanceof CliError) | ||
| throw new CliError(err.code, `${note}\n${err.message}`, err.details); | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| throw new CliError(CliErrorCode.API_ERROR, `${note}\n${message}`); | ||
| } | ||
| if (!statusData.tripPlan) | ||
| return; | ||
| const hotelCodes = await resolveHotelCodes(statusData); | ||
| const status = buildPlanStatus(statusData, baseUrl, hotelCodes); | ||
| const hardBlockers = status.blockers.filter((b) => b.unverified !== true); | ||
| if (hardBlockers.length === 0) | ||
| return; | ||
| const detailed = hardBlockers.map((b) => ({ | ||
| kind: b.kind, | ||
| message: b.message, | ||
| refs: b.refs, | ||
| fix: blockerFix(b, status.travellers), | ||
| })); | ||
| const lines = detailed.map((b) => ` • [${b.kind}] ${b.message}\n fix: ${b.fix}`); | ||
| throw new CliError(CliErrorCode.PLAN_BLOCKED, `Cannot book — the plan has ${hardBlockers.length} unresolved blocker${hardBlockers.length === 1 ? "" : "s"} that must be resolved before checkout ` + | ||
| `(or pass --force-checkout to trust the server's own validation):\n${lines.join("\n")}`, { blockers: detailed }); | ||
| } | ||
| export function registerBookCommands(program) { | ||
@@ -44,2 +104,3 @@ program | ||
| .option("--rebook", "Create a checkout even though a Paid checkout already exists for this plan") | ||
| .option("--force-checkout", "Skip the client-side readiness guard and trust the server's own validation") | ||
| .option("--status", "Show payment + booking status for past checkouts on this plan") | ||
@@ -50,3 +111,3 @@ .option("--plan <id>", "Trip plan ID (alternative to the positional argument)") | ||
| const baseUrl = deriveBaseUrl(getApiUrl()); | ||
| const planUrl = `${baseUrl}/plans/${planId}`; | ||
| const planUrl = clientPlanUrl(planId, baseUrl); | ||
| if (opts.status) { | ||
@@ -107,3 +168,3 @@ await showBookingStatus(planId, baseUrl, Boolean(opts.json), Boolean(opts.agent)); | ||
| title: plan.title, | ||
| url: planUrl, | ||
| ...planUrls(plan.id, baseUrl), | ||
| urlForCli: `voyagier plans get ${shellArg(plan.id)}`, | ||
@@ -263,2 +324,5 @@ }; | ||
| } | ||
| if (!opts.forceCheckout) { | ||
| await enforcePlanReadiness(planId, baseUrl, planIdArg); | ||
| } | ||
| if (!opts.json && !opts.agent) { | ||
@@ -350,3 +414,3 @@ process.stderr.write(chalk.dim("Creating checkout session...\n")); | ||
| const checkouts = data.tripPlanPaymentCheckouts ?? []; | ||
| const planUrl = `${baseUrl}/plans/${planId}`; | ||
| const planUrl = clientPlanUrl(planId, baseUrl); | ||
| if (json) { | ||
@@ -360,3 +424,3 @@ const renamed = checkouts.map((c) => ({ | ||
| data: { checkouts: renamed }, | ||
| planContext: { planId, url: planUrl, urlForCli: `voyagier plans get ${shellArg(planId)}` }, | ||
| planContext: { planId, ...planUrls(planId, baseUrl), urlForCli: `voyagier plans get ${shellArg(planId)}` }, | ||
| }, null, 2) + "\n"); | ||
@@ -363,0 +427,0 @@ return; |
@@ -6,2 +6,3 @@ import chalk from "chalk"; | ||
| import { getApiUrl } from "../config.js"; | ||
| import { clientPlanUrl, planUrls } from "../plan-urls.js"; | ||
| import { GET_BOOKING_RECORDS, GET_BOOKING_RECORDS_BY_USER, REFRESH_BOOKING_RECORD, GET_BOOKING_RECORD, } from "../queries.js"; | ||
@@ -69,3 +70,3 @@ function statusIcon(status) { | ||
| amountCents: amount, | ||
| ...(r.tripPlanId ? { url: `${baseUrl}/plans/${r.tripPlanId}` } : {}), | ||
| ...(r.tripPlanId ? planUrls(r.tripPlanId, baseUrl) : {}), | ||
| })); | ||
@@ -123,3 +124,3 @@ process.stdout.write(JSON.stringify({ bookings: enriched }, null, 2) + "\n"); | ||
| amountCents: amount, | ||
| ...(r.tripPlanId ? { url: `${baseUrl}/plans/${r.tripPlanId}` } : {}), | ||
| ...(r.tripPlanId ? planUrls(r.tripPlanId, baseUrl) : {}), | ||
| }; | ||
@@ -149,3 +150,3 @@ process.stdout.write(JSON.stringify(enriched, null, 2) + "\n"); | ||
| console.log(` Plan: ${r.tripPlan.title}`); | ||
| console.log(chalk.dim(` ${baseUrl}/plans/${r.tripPlan.id}`)); | ||
| console.log(chalk.dim(` ${clientPlanUrl(r.tripPlan.id, baseUrl)}`)); | ||
| } | ||
@@ -152,0 +153,0 @@ const item = r.tripPlanPlace ?? r.tripPlanProduct; |
@@ -6,2 +6,3 @@ import chalk from "chalk"; | ||
| import { formatPrice, deriveBaseUrl, shellArg } from "../utils.js"; | ||
| import { clientPlanUrl, planUrls } from "../plan-urls.js"; | ||
| import { resolvePlanArg } from "../resolve-plan-arg.js"; | ||
@@ -20,3 +21,3 @@ import { GET_CART_V2 } from "../queries.js"; | ||
| const baseUrl = deriveBaseUrl(getApiUrl()); | ||
| const planUrl = `${baseUrl}/plans/${planId}`; | ||
| const planUrl = clientPlanUrl(planId, baseUrl); | ||
| let data; | ||
@@ -43,3 +44,3 @@ try { | ||
| title: plan.title, | ||
| url: planUrl, | ||
| ...planUrls(plan.id, baseUrl), | ||
| urlForCli: `voyagier plans get ${shellArg(plan.id)}`, | ||
@@ -46,0 +47,0 @@ }; |
@@ -6,5 +6,5 @@ import chalk from "chalk"; | ||
| import { GET_TRIP_PLAN_EVENTS } from "../queries.js"; | ||
| import { validateDate, deriveBaseUrl } from "../utils.js"; | ||
| import { validateDate } from "../utils.js"; | ||
| import { resolvePlanArg } from "../resolve-plan-arg.js"; | ||
| import { getApiUrl } from "../config.js"; | ||
| import { clientPlanUrl, planUrls } from "../plan-urls.js"; | ||
| export function computeDayNumber(eventDatetime, planStart) { | ||
@@ -84,3 +84,3 @@ if (!eventDatetime || !planStart) | ||
| function planUrl(planId) { | ||
| return `${deriveBaseUrl(getApiUrl())}/plans/${planId}`; | ||
| return clientPlanUrl(planId); | ||
| } | ||
@@ -152,3 +152,3 @@ function formatEventLine(e, planStart) { | ||
| title: plan.title, | ||
| url: planUrl(plan.id), | ||
| ...planUrls(plan.id), | ||
| startDate: plan.startDate ?? null, | ||
@@ -155,0 +155,0 @@ endDate: plan.endDate ?? null, |
@@ -7,2 +7,3 @@ import chalk from "chalk"; | ||
| import { deriveBaseUrl, formatPrice, shellArg } from "../utils.js"; | ||
| import { planUrls } from "../plan-urls.js"; | ||
| import { resolvePlanArg } from "../resolve-plan-arg.js"; | ||
@@ -426,3 +427,3 @@ import { GET_PLAN_STATUS, GET_HOTEL_OPTION_DATA } from "../queries.js"; | ||
| title: plan.title ?? null, | ||
| url: `${planUrlBase}/plans/${plan.id}`, | ||
| ...planUrls(plan.id, planUrlBase), | ||
| readiness, | ||
@@ -429,0 +430,0 @@ summary: { |
@@ -6,2 +6,3 @@ import chalk from "chalk"; | ||
| import { validateDate, warnPastDate, validateIata, deriveBaseUrl, shellArg } from "../utils.js"; | ||
| import { clientPlanUrl, planUrls } from "../plan-urls.js"; | ||
| import { progress, warn, fatal, jsonOutput } from "../output.js"; | ||
@@ -190,3 +191,3 @@ import { CliError, CliErrorCode } from "../errors.js"; | ||
| const baseUrl2 = deriveBaseUrl(getApiUrl()); | ||
| const planUrl = `${baseUrl2}/plans/${plan.id}`; | ||
| const planUrl = clientPlanUrl(plan.id, baseUrl2); | ||
| const nextSteps = []; | ||
@@ -219,3 +220,3 @@ if (opts.to && opts.depart) { | ||
| note: "plan-trip creates a starting plan + default goal graph (a round-trip + hotel TEMPLATE); compose the trip with the primitives below. Prune goals your brief doesn't need — shape flags (--one-way/--flight-only/--hotel-only) at scaffold time, or `plans goal-remove <goalId> --force` any time.", | ||
| url: planUrl, | ||
| ...planUrls(plan.id, baseUrl2), | ||
| ...(shapeLabels.length > 0 | ||
@@ -222,0 +223,0 @@ ? { |
@@ -6,2 +6,3 @@ import chalk from "chalk"; | ||
| import { formatPrice, deriveBaseUrl, shellArg } from "../../utils.js"; | ||
| import { clientPlanUrl, planUrls } from "../../plan-urls.js"; | ||
| import { resolvePlanArg } from "../../resolve-plan-arg.js"; | ||
@@ -20,3 +21,3 @@ import { GET_CART_V2 } from "../../queries.js"; | ||
| const baseUrl = deriveBaseUrl(getApiUrl()); | ||
| const planUrl = `${baseUrl}/plans/${planId}`; | ||
| const planUrl = clientPlanUrl(planId, baseUrl); | ||
| let data; | ||
@@ -45,3 +46,3 @@ try { | ||
| title: plan.title, | ||
| url: planUrl, | ||
| ...planUrls(plan.id, baseUrl), | ||
| urlForCli: `voyagier plans get ${shellArg(plan.id)}`, | ||
@@ -48,0 +49,0 @@ }; |
@@ -11,2 +11,3 @@ import chalk from "chalk"; | ||
| import { planUrl, typeIcon, chosenOption } from "./types.js"; | ||
| import { planUrls } from "../../plan-urls.js"; | ||
| import { GET_TRIP_PLANS, GET_TRIP_PLAN, GET_TRIP_PLAN_SUMMARY, UPDATE_TRIP_PLAN, GET_TRIP_PLAN_WITH_DESC, DELETE_TRIP_PLAN, } from "../../queries.js"; | ||
@@ -45,3 +46,3 @@ export function registerCrudCommands(plans) { | ||
| const planSummary = await getPlanSummary(plan.id); | ||
| jsonOutput({ ...plan, url: planUrl(plan.id), planSummary }); | ||
| jsonOutput({ ...plan, ...planUrls(plan.id), planSummary }); | ||
| return; | ||
@@ -106,3 +107,3 @@ } | ||
| process.stdout.write(JSON.stringify({ | ||
| items: items.map((p) => ({ ...p, url: planUrl(p.id) })), | ||
| items: items.map((p) => ({ ...p, ...planUrls(p.id) })), | ||
| total: opts.active ? items.length : total, | ||
@@ -173,3 +174,3 @@ page: opts.active ? 1 : page, | ||
| if (opts.json) { | ||
| process.stdout.write(JSON.stringify({ ...plan, url: planUrl(plan.id) }, null, 2) + "\n"); | ||
| process.stdout.write(JSON.stringify({ ...plan, ...planUrls(plan.id) }, null, 2) + "\n"); | ||
| return; | ||
@@ -176,0 +177,0 @@ } |
@@ -5,2 +5,3 @@ import chalk from "chalk"; | ||
| import { deriveBaseUrl, formatDateRange } from "../../utils.js"; | ||
| import { clientPlanUrl } from "../../plan-urls.js"; | ||
| import { resolvePlanArg } from "../../resolve-plan-arg.js"; | ||
@@ -162,3 +163,3 @@ import { jsonOutput } from "../../output.js"; | ||
| console.log(` ${chalk.white(p.title)}${dates}`); | ||
| console.log(chalk.dim(` ${baseUrl}/plans/${p.id}`)); | ||
| console.log(chalk.dim(` ${clientPlanUrl(p.id, baseUrl)}`)); | ||
| } | ||
@@ -165,0 +166,0 @@ console.log(); |
@@ -1,3 +0,2 @@ | ||
| import { getApiUrl } from "../../config.js"; | ||
| import { deriveBaseUrl } from "../../utils.js"; | ||
| import { clientPlanUrl } from "../../plan-urls.js"; | ||
| import { deriveChosen } from "../../choices.js"; | ||
@@ -29,4 +28,3 @@ export function deepChosenOption(sel) { | ||
| export function planUrl(id) { | ||
| const baseUrl = deriveBaseUrl(getApiUrl()); | ||
| return `${baseUrl}/plans/${id}`; | ||
| return clientPlanUrl(id); | ||
| } | ||
@@ -33,0 +31,0 @@ export function inferTypeFromTitle(title) { |
@@ -164,1 +164,30 @@ import { graphql } from "../api.js"; | ||
| } | ||
| const PARAM_LABELS = { | ||
| origin: "origin", | ||
| destination: "destination", | ||
| depart: "departure date", | ||
| return: "return date", | ||
| checkin: "check-in", | ||
| checkout: "check-out", | ||
| partySize: "party size", | ||
| }; | ||
| export function diffSearchParams(effective, requested) { | ||
| const norm = (v) => (v === undefined || v === null ? undefined : v); | ||
| const changed = []; | ||
| for (const key of Object.keys(PARAM_LABELS)) { | ||
| const a = norm(effective[key]); | ||
| const b = norm(requested[key]); | ||
| if (a !== b) | ||
| changed.push(key); | ||
| } | ||
| return changed; | ||
| } | ||
| export function formatReuseWarning(changed, effective, requested) { | ||
| const parts = changed.map((key) => { | ||
| const from = effective[key]; | ||
| const to = requested[key]; | ||
| return `${PARAM_LABELS[key]} ${from ?? "—"} → ${to ?? "—"}`; | ||
| }); | ||
| return (`SELECTION_REUSED_PARAMS_MISMATCH: this search reused an existing selection whose inventory ` + | ||
| `was fetched for different params (${parts.join(", ")}); the results may reflect the original search params, not the ones just requested.`); | ||
| } |
+150
-15
| import { printPlanFooter } from "../plan-footer.js"; | ||
| import chalk from "chalk"; | ||
| import { graphql } from "../api.js"; | ||
| import { getApiUrl, getHomeAirports } from "../config.js"; | ||
| import { getHomeAirports } from "../config.js"; | ||
| import { GET_TRAVELLERS_BRIEF, CREATE_FLIGHT_SELECTION, GET_TRIP_PLAN_ITEM_TYPES, DELETE_TRIP_PLAN_ITEM, CREATE_HOTEL_SELECTION, CREATE_ACTIVITY_SELECTION, GET_DECISION_SELECTION_OPTIONS, } from "../queries.js"; | ||
| import { loadGoals, resolveGoal, resolveMirrorList, resolveDecisionSelection, setAirport, addDateOption, resolveDateRange, requireAirports, resolveReturnFlightGoal, requireDateSelection, setDestination, } from "./search-helpers.js"; | ||
| import { saveSearchState, loadSearchState } from "../state.js"; | ||
| import { loadGoals, resolveGoal, resolveMirrorList, resolveDecisionSelection, setAirport, addDateOption, resolveDateRange, requireAirports, resolveReturnFlightGoal, requireDateSelection, setDestination, diffSearchParams, formatReuseWarning, } from "./search-helpers.js"; | ||
| import { saveSearchState, loadSearchState, getSelectionSearchParams, rememberSelectionSearchParams } from "../state.js"; | ||
| import { formatFlights, formatHotels, formatActivities } from "../formatters.js"; | ||
| import { extractFlightToken, buildFlightSummary, buildHotelSummary, buildActivitySummary, validateDate, warnPastDate, validateIata, deriveBaseUrl, looksLikeAirportCode, shellArg } from "../utils.js"; | ||
| import { extractFlightToken, buildFlightSummary, buildHotelSummary, buildActivitySummary, validateDate, warnPastDate, validateIata, looksLikeAirportCode, shellArg } from "../utils.js"; | ||
| import { clientPlanUrl, planUrls } from "../plan-urls.js"; | ||
| import { agentFlightOptions, agentHotelOptions, agentActivityOptions } from "../agent-output.js"; | ||
@@ -15,3 +16,4 @@ import { deriveHotelStay } from "../hotel-format.js"; | ||
| import { CliError, CliErrorCode } from "../errors.js"; | ||
| import { startSpinner } from "../spinner.js"; | ||
| import { waitForSelectionOptions } from "../selection-wait.js"; | ||
| import { startSpinner, spinnerAnimates } from "../spinner.js"; | ||
| import { isInteractive, promptText } from "../prompt.js"; | ||
@@ -177,2 +179,72 @@ import { scaffoldPlan, generateTripTitle } from "./scaffold.js"; | ||
| } | ||
| const SEARCH_WAIT_TIMEOUT_MS = 90_000; | ||
| const WAIT_HEARTBEAT_MS = 10_000; | ||
| function makeWaitHeartbeat(label, waitSpinner) { | ||
| let lastBucket = 0; | ||
| return ({ elapsedMs }) => { | ||
| const line = `${label}… fetching inventory (${Math.round(elapsedMs / 1000)}s)`; | ||
| if (waitSpinner) { | ||
| waitSpinner.update(line); | ||
| return; | ||
| } | ||
| const bucket = Math.floor(elapsedMs / WAIT_HEARTBEAT_MS); | ||
| if (bucket > lastBucket) { | ||
| lastBucket = bucket; | ||
| process.stderr.write(chalk.dim(line + "\n")); | ||
| } | ||
| }; | ||
| } | ||
| async function refetchDecisionOptions(selectionId) { | ||
| const data = await graphql(GET_DECISION_SELECTION_OPTIONS, { tripPlanSelectionId: selectionId }); | ||
| return data.getTripPlanSelection?.options ?? []; | ||
| } | ||
| function shouldWaitInline(opts) { | ||
| return (!opts.json && | ||
| !opts.agent && | ||
| opts.wait !== false && | ||
| process.stderr.isTTY === true); | ||
| } | ||
| function writePollHint(selectionId) { | ||
| process.stderr.write(chalk.dim("Poll for results with:\n")); | ||
| process.stderr.write(chalk.dim(` voyagier selection-options ${shellArg(selectionId)} --wait\n`)); | ||
| } | ||
| function reportWaitStop(result, selectionId) { | ||
| switch (result.status) { | ||
| case "FETCHING": | ||
| process.stderr.write(chalk.yellow("Inventory is still loading on our side — your results will be ready shortly.\n")); | ||
| writePollHint(selectionId); | ||
| break; | ||
| case "FETCH_ERROR": | ||
| process.stderr.write(chalk.yellow(`The inventory search hit an error while fetching${result.fetchError ? `: ${result.fetchError}` : "."}\n`)); | ||
| writePollHint(selectionId); | ||
| break; | ||
| case "AWAITING_INPUT": | ||
| process.stderr.write(chalk.yellow("The search is missing a required input, so no inventory could be fetched. Check the owning goal: voyagier plans goal <goalId>\n")); | ||
| break; | ||
| case "NO_RESULTS": | ||
| default: | ||
| break; | ||
| } | ||
| } | ||
| function observeSelectionReuse(selectionId, requested) { | ||
| const stored = getSelectionSearchParams(selectionId); | ||
| if (!stored) { | ||
| rememberSelectionSearchParams(selectionId, requested); | ||
| return { requestedParams: requested, warnings: [] }; | ||
| } | ||
| const changed = diffSearchParams(stored, requested); | ||
| const warnings = changed.length > 0 ? [formatReuseWarning(changed, stored, requested)] : []; | ||
| return { requestedParams: requested, effectiveParams: stored, warnings }; | ||
| } | ||
| function reuseEnvelopeFields(obs) { | ||
| return { | ||
| requestedParams: obs.requestedParams, | ||
| ...(obs.effectiveParams ? { effectiveParams: obs.effectiveParams } : {}), | ||
| ...(obs.warnings.length > 0 ? { warnings: obs.warnings } : {}), | ||
| }; | ||
| } | ||
| function writeReuseWarnings(warnings) { | ||
| for (const w of warnings) | ||
| process.stderr.write(chalk.yellow(`⚠ ${w}\n`)); | ||
| } | ||
| export function registerSearchCommands(program) { | ||
@@ -230,2 +302,3 @@ const search = program.command("search").description("Search flights, hotels, and activities"); | ||
| .option("--dry-run", "Show the GraphQL query without executing") | ||
| .option("--no-wait", "Return immediately instead of waiting inline for async inventory (human/TTY mode)") | ||
| .option("--no-input", "Never prompt for missing input; fail instead (for scripts, agents, CI)") | ||
@@ -319,2 +392,31 @@ .action(async (opts, command) => { | ||
| } | ||
| const reuse = observeSelectionReuse(selectionId, { | ||
| origin, | ||
| destination, | ||
| depart: opts.date, | ||
| ...(opts.return ? { return: opts.return } : {}), | ||
| partySize: travellerIds.length, | ||
| }); | ||
| if (fetchedOptions.length === 0 && shouldWaitInline(opts)) { | ||
| const label = `Searching ${origin} → ${destination}`; | ||
| const waitSpinner = spinnerAnimates() ? startSpinner(`${label}… fetching inventory`) : null; | ||
| let snap; | ||
| try { | ||
| snap = await waitForSelectionOptions(selectionId, { timeoutMs: SEARCH_WAIT_TIMEOUT_MS }, { heartbeat: makeWaitHeartbeat(label, waitSpinner) }); | ||
| } | ||
| finally { | ||
| waitSpinner?.stop(); | ||
| } | ||
| if (snap.result.status === "READY") { | ||
| fetchedOptions = await refetchDecisionOptions(selectionId); | ||
| } | ||
| else if (snap.result.status === "NO_RESULTS") { | ||
| process.stderr.write(chalk.yellow(`No flights matched ${origin} → ${destination} on these dates.\n`)); | ||
| return; | ||
| } | ||
| else { | ||
| reportWaitStop(snap.result, selectionId); | ||
| return; | ||
| } | ||
| } | ||
| const sortBy = (opts.sort ?? "default"); | ||
@@ -354,3 +456,4 @@ let filtered = [...fetchedOptions].sort((a, b) => a.sortOrder - b.sortOrder); | ||
| isRoundTrip, | ||
| url: `${deriveBaseUrl(getApiUrl())}/plans/${tripPlanId}`, | ||
| ...planUrls(tripPlanId), | ||
| ...reuseEnvelopeFields(reuse), | ||
| }, options, searchResults, opts.full, "--sort/--max-stops"), null, 2) + "\n"); | ||
@@ -360,3 +463,3 @@ return; | ||
| if (opts.agent) { | ||
| const planUrl = `${deriveBaseUrl(getApiUrl())}/plans/${tripPlanId}`; | ||
| const planUrl = clientPlanUrl(tripPlanId); | ||
| const lines = []; | ||
@@ -366,2 +469,4 @@ lines.push(`### Flights (${origin} → ${destination})`); | ||
| lines.push(`_No plan given — created draft plan \`${tripPlanId}\`._`); | ||
| for (const w of reuse.warnings) | ||
| lines.push(`> ⚠ ${w}`); | ||
| if (options.length === 0) { | ||
@@ -390,5 +495,6 @@ lines.push("_No options yet — the search is still fetching inventory._"); | ||
| } | ||
| writeReuseWarnings(reuse.warnings); | ||
| if (options.length === 0) { | ||
| process.stderr.write(chalk.dim("No options yet — the search is still fetching inventory.\n")); | ||
| process.stderr.write(chalk.dim(` Poll: voyagier selection-options ${shellArg(selectionId)} --wait\n`)); | ||
| writePollHint(selectionId); | ||
| return; | ||
@@ -427,2 +533,3 @@ } | ||
| .option("--verbose", "Show request details sent to the API") | ||
| .option("--no-wait", "Return immediately instead of waiting inline for async inventory (human/TTY mode)") | ||
| .option("--no-input", "Never prompt for missing input; fail instead (for scripts, agents, CI)") | ||
@@ -515,2 +622,26 @@ .action(async (opts) => { | ||
| } | ||
| const reuse = observeSelectionReuse(selectionId, { | ||
| destination: opts.location, | ||
| checkin: opts.checkin, | ||
| checkout: opts.checkout, | ||
| partySize: adults, | ||
| }); | ||
| if (fetchedOptions.length === 0 && shouldWaitInline(opts)) { | ||
| const label = `Searching hotels in ${opts.location}`; | ||
| const waitSpinner = spinnerAnimates() ? startSpinner(`${label}… fetching inventory`) : null; | ||
| let snap; | ||
| try { | ||
| snap = await waitForSelectionOptions(selectionId, { timeoutMs: SEARCH_WAIT_TIMEOUT_MS }, { heartbeat: makeWaitHeartbeat(label, waitSpinner) }); | ||
| } | ||
| finally { | ||
| waitSpinner?.stop(); | ||
| } | ||
| if (snap.result.status === "READY") { | ||
| fetchedOptions = await refetchDecisionOptions(selectionId); | ||
| } | ||
| else if (snap.result.status !== "NO_RESULTS") { | ||
| reportWaitStop(snap.result, selectionId); | ||
| return; | ||
| } | ||
| } | ||
| const sortBy = (opts.sort ?? "default"); | ||
@@ -549,3 +680,4 @@ const options = sortBy === "price" | ||
| selectionId: selectionId, | ||
| url: `${deriveBaseUrl(getApiUrl())}/plans/${tripPlanId}`, | ||
| ...planUrls(tripPlanId), | ||
| ...reuseEnvelopeFields(reuse), | ||
| }, options, searchResults, opts.full, "--sort"), null, 2) + "\n"); | ||
@@ -555,3 +687,3 @@ return; | ||
| if (opts.agent) { | ||
| const planUrl = `${deriveBaseUrl(getApiUrl())}/plans/${tripPlanId}`; | ||
| const planUrl = clientPlanUrl(tripPlanId); | ||
| const lines = []; | ||
@@ -561,2 +693,4 @@ lines.push(`### Hotels (${opts.location})`); | ||
| lines.push(`_No plan given — created draft plan \`${tripPlanId}\`._`); | ||
| for (const w of reuse.warnings) | ||
| lines.push(`> ⚠ ${w}`); | ||
| if (options.length === 0) { | ||
@@ -581,7 +715,8 @@ lines.push("_No options yet — the search is still fetching inventory._"); | ||
| } | ||
| writeReuseWarnings(reuse.warnings); | ||
| if (options.length === 0) { | ||
| const loc = opts.location; | ||
| process.stderr.write(chalk.dim(`No options yet — the search may still be fetching inventory.\n`)); | ||
| process.stderr.write(chalk.dim(` Poll: voyagier selection-options ${shellArg(selectionId)} --wait\n\n`)); | ||
| process.stderr.write(chalk.yellow(`If it stays empty, no hotels matched "${loc}" on these dates.\n\n`)); | ||
| writePollHint(selectionId); | ||
| process.stderr.write(chalk.yellow(`\nIf it stays empty, no hotels matched "${loc}" on these dates.\n\n`)); | ||
| process.stderr.write(chalk.dim("Suggestions:\n")); | ||
@@ -598,3 +733,3 @@ if (looksLikeAirportCode(loc)) { | ||
| process.stderr.write(chalk.dim(` • Check the web UI for expanded search options:\n`)); | ||
| process.stderr.write(chalk.dim(` ${deriveBaseUrl(getApiUrl())}/plans/${tripPlanId}\n`)); | ||
| process.stderr.write(chalk.dim(` ${clientPlanUrl(tripPlanId)}\n`)); | ||
| return; | ||
@@ -727,3 +862,3 @@ } | ||
| selectionId: selectionId, | ||
| url: `${deriveBaseUrl(getApiUrl())}/plans/${tripPlanId}`, | ||
| ...planUrls(tripPlanId), | ||
| }, options, searchResults, opts.full, "--sort"), null, 2) + "\n"); | ||
@@ -733,3 +868,3 @@ return; | ||
| if (opts.agent) { | ||
| const planUrl = `${deriveBaseUrl(getApiUrl())}/plans/${tripPlanId}`; | ||
| const planUrl = clientPlanUrl(tripPlanId); | ||
| const lines = []; | ||
@@ -736,0 +871,0 @@ lines.push(`### Activities (${opts.destination})`); |
@@ -8,2 +8,3 @@ import { printPlanFooter } from "../plan-footer.js"; | ||
| import { deriveBaseUrl, shellArg } from "../utils.js"; | ||
| import { clientPlanUrl, planUrls } from "../plan-urls.js"; | ||
| import { GET_PLAN_STATUS } from "../queries.js"; | ||
@@ -325,3 +326,3 @@ import { resolveHotelCodes, buildPlanStatus } from "./plan-status.js"; | ||
| ...(roomStep ? { roomSelectionId: roomStep } : {}), | ||
| url: `${deriveBaseUrl(getApiUrl())}/plans/${state.tripPlanId}`, | ||
| ...planUrls(state.tripPlanId), | ||
| ...(waitOutcome !== undefined ? waitJsonFragment(waitOutcome) : {}), | ||
@@ -331,3 +332,3 @@ }, state.tripPlanId); | ||
| else if (opts.agent) { | ||
| const planUrl = `${deriveBaseUrl(getApiUrl())}/plans/${state.tripPlanId}`; | ||
| const planUrl = clientPlanUrl(state.tripPlanId); | ||
| const icon = state.type === "flights" ? "✈️" : state.type === "activities" ? "🎯" : "🏨"; | ||
@@ -334,0 +335,0 @@ const nextSteps = [ |
| import chalk from "chalk"; | ||
| import { graphql } from "../api.js"; | ||
| import { GET_SELECTION_WITH_MONITOR, GET_BLUEPRINT_MONITOR, REFRESH_SELECTION_OPTIONS, GET_HOTEL_OPTION_DATA, } from "../queries.js"; | ||
| import { GET_HOTEL_OPTION_DATA } from "../queries.js"; | ||
| import { deriveRoomStay } from "../hotel-format.js"; | ||
| import { jsonOutput } from "../output.js"; | ||
| import { CliError, CliErrorCode } from "../errors.js"; | ||
| import { classifySelection, isTerminal, } from "../selection-status.js"; | ||
| import { isTerminal } from "../selection-status.js"; | ||
| import { deriveChosen, deriveBlockedOn } from "../choices.js"; | ||
| import { loadSelectionState, pollSelectionOptions, DEFAULT_RETRY_AFTER_MS, } from "../selection-wait.js"; | ||
| import { spinnerAnimates, startSpinner } from "../spinner.js"; | ||
| export { deriveChosen, deriveBlockedOn }; | ||
| async function loadSelectionState(selectionId, retryAfterMs) { | ||
| const data = await graphql(GET_SELECTION_WITH_MONITOR, { tripPlanSelectionId: selectionId }); | ||
| const raw = data.getTripPlanSelection; | ||
| if (!raw || !raw.id) { | ||
| throw new CliError(CliErrorCode.NOT_FOUND, `Selection ${selectionId} not found.`); | ||
| } | ||
| let monitor = null; | ||
| if (raw.blueprintMonitorId) { | ||
| try { | ||
| const m = await graphql(GET_BLUEPRINT_MONITOR, { | ||
| id: raw.blueprintMonitorId, | ||
| }); | ||
| monitor = m.blueprintMonitor; | ||
| } | ||
| catch { | ||
| monitor = null; | ||
| } | ||
| } | ||
| const state = { | ||
| id: raw.id, | ||
| type: raw.type, | ||
| blueprintMonitorId: raw.blueprintMonitorId, | ||
| optionCount: (raw.options ?? []).length, | ||
| monitor: monitor | ||
| ? { | ||
| id: monitor.id, | ||
| fetchedAt: monitor.fetchedAt, | ||
| lastFetchAttempt: monitor.lastFetchAttempt, | ||
| lastFetchError: monitor.lastFetchError, | ||
| } | ||
| : null, | ||
| }; | ||
| return { raw, result: classifySelection(state, { retryAfterMs }) }; | ||
| } | ||
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | ||
| async function loadRoomStays(selectionId) { | ||
@@ -69,3 +35,3 @@ const stays = new Map(); | ||
| .action(async (selectionId, opts) => { | ||
| const retryAfterMs = 2000; | ||
| const retryAfterMs = DEFAULT_RETRY_AFTER_MS; | ||
| const parsedTimeout = parseInt(opts.timeout, 10); | ||
@@ -77,27 +43,15 @@ const timeoutMs = Math.max(1, Number.isNaN(parsedTimeout) ? 30 : parsedTimeout) * 1000; | ||
| if (opts.wait && !isTerminal(result.status)) { | ||
| try { | ||
| await graphql(REFRESH_SELECTION_OPTIONS, { selectionId }); | ||
| } | ||
| catch { | ||
| } | ||
| const startedAt = Date.now(); | ||
| const deadline = startedAt + timeoutMs; | ||
| let delay = retryAfterMs; | ||
| let attempt = 0; | ||
| const spinner = spinnerAnimates() ? startSpinner("Fetching options...") : null; | ||
| try { | ||
| while (!isTerminal(result.status) && Date.now() < deadline) { | ||
| const remaining = deadline - Date.now(); | ||
| await sleep(Math.min(delay, Math.max(0, remaining))); | ||
| ({ raw, result } = await loadSelectionState(selectionId, retryAfterMs)); | ||
| attempt++; | ||
| const elapsed = Math.round((Date.now() - startedAt) / 1000); | ||
| if (spinner) { | ||
| spinner.update(`Fetching options... (attempt ${attempt}, status=${result.status}, options=${result.optionCount}, ${elapsed}s)`); | ||
| } | ||
| else { | ||
| process.stderr.write(chalk.dim(` polling… status=${result.status} options=${result.optionCount} elapsed=${elapsed}s\n`)); | ||
| } | ||
| delay = Math.min(delay * 1.5, 8000); | ||
| } | ||
| ({ raw, result } = await pollSelectionOptions(selectionId, { raw, result }, { timeoutMs, retryAfterMs }, { | ||
| heartbeat: ({ attempt, status, optionCount, elapsedMs }) => { | ||
| const elapsed = Math.round(elapsedMs / 1000); | ||
| if (spinner) { | ||
| spinner.update(`Fetching options... (attempt ${attempt}, status=${status}, options=${optionCount}, ${elapsed}s)`); | ||
| } | ||
| else { | ||
| process.stderr.write(chalk.dim(` polling… status=${status} options=${optionCount} elapsed=${elapsed}s\n`)); | ||
| } | ||
| }, | ||
| })); | ||
| } | ||
@@ -107,5 +61,2 @@ finally { | ||
| } | ||
| if (!isTerminal(result.status)) { | ||
| result = { ...result, status: "FETCHING", retryAfterMs }; | ||
| } | ||
| } | ||
@@ -112,0 +63,0 @@ const sortedOptions = [...(raw.options ?? [])].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); |
| import chalk from "chalk"; | ||
| import { graphql } from "../api.js"; | ||
| import { jsonOutput } from "../output.js"; | ||
| import { deriveBaseUrl, shellArg } from "../utils.js"; | ||
| import { getApiUrl } from "../config.js"; | ||
| import { shellArg } from "../utils.js"; | ||
| import { planUrls } from "../plan-urls.js"; | ||
| import { GET_TRAVELLER_CHOICES } from "../queries.js"; | ||
@@ -94,3 +94,2 @@ function formatChoiceTraveller(t) { | ||
| if (opts.json) { | ||
| const planUrl = `${deriveBaseUrl(getApiUrl())}/plans/${opts.plan}`; | ||
| jsonOutput({ | ||
@@ -133,3 +132,3 @@ ok: true, | ||
| title: result.title, | ||
| url: planUrl, | ||
| ...planUrls(opts.plan), | ||
| travellerCount: allTravellerIds.length, | ||
@@ -136,0 +135,0 @@ }, |
@@ -6,4 +6,4 @@ import chalk from "chalk"; | ||
| import { LIST_TRIP_PLAN_TRAVELLER_GROUPS, GET_TRIP_PLAN_TRAVELLER_GROUP, CREATE_TRIP_PLAN_TRAVELLER_GROUP, UPDATE_TRIP_PLAN_TRAVELLER_GROUP, DELETE_TRIP_PLAN_TRAVELLER_GROUP, ADD_TRAVELLERS_TO_GROUP, REMOVE_TRAVELLERS_FROM_GROUP, } from "../queries.js"; | ||
| import { deriveBaseUrl, shellArg } from "../utils.js"; | ||
| import { getApiUrl } from "../config.js"; | ||
| import { shellArg } from "../utils.js"; | ||
| import { planUrls } from "../plan-urls.js"; | ||
| export function buildGroupPlanContext(plan) { | ||
@@ -13,3 +13,3 @@ return { | ||
| title: plan.title, | ||
| url: `${deriveBaseUrl(getApiUrl())}/plans/${plan.id}`, | ||
| ...planUrls(plan.id), | ||
| travellerCount: plan.travellers?.length ?? null, | ||
@@ -16,0 +16,0 @@ }; |
@@ -7,3 +7,4 @@ import { printPlanFooter } from "../plan-footer.js"; | ||
| import { getApiUrl, getUserContext } from "../config.js"; | ||
| import { validateDate, deriveBaseUrl, shellArg } from "../utils.js"; | ||
| import { validateDate, deriveBaseUrl, shellArg, maskLoyaltyValue } from "../utils.js"; | ||
| import { clientPlanUrl, planUrls } from "../plan-urls.js"; | ||
| import { jsonOutput, fatal, warn } from "../output.js"; | ||
@@ -18,5 +19,2 @@ import { CliError, CliErrorCode } from "../errors.js"; | ||
| } | ||
| function maskLoyaltyValue(value) { | ||
| return value.length > 4 ? `••••${value.slice(-4)}` : "••••"; | ||
| } | ||
| function requirePassportNumberWithMetadata(opts) { | ||
@@ -108,2 +106,3 @@ if (opts.passportNumber) | ||
| let phone = opts.phone; | ||
| let selfHotelLoyalty; | ||
| if (opts.self) { | ||
@@ -127,2 +126,9 @@ const ctx = getUserContext(); | ||
| filled.push("gender"); | ||
| if (ctx.hotelLoyaltyPrograms?.length && opts.hotelLoyalty.length === 0) { | ||
| selfHotelLoyalty = ctx.hotelLoyaltyPrograms.map((p) => ({ | ||
| chainCode: p.chainCode, | ||
| membershipNumber: p.membershipNumber, | ||
| })); | ||
| filled.push(`hotel loyalty (${selfHotelLoyalty.map((p) => p.chainCode).join(", ")})`); | ||
| } | ||
| if (!opts.json && filled.length > 0) { | ||
@@ -214,8 +220,9 @@ console.log(chalk.dim(` Auto-filled from profile: ${filled.join(", ")}`)); | ||
| input.hotelLoyaltyPrograms = toHotelLoyaltyInput(opts.hotelLoyalty); | ||
| else if (selfHotelLoyalty) | ||
| input.hotelLoyaltyPrograms = selfHotelLoyalty; | ||
| const data = await graphql(CREATE_TRAVELLER, { tripPlanId: opts.plan, input }); | ||
| const t = data.createTripPlanTraveller; | ||
| const baseUrl = deriveBaseUrl(getApiUrl()); | ||
| const planUrl = `${baseUrl}/plans/${opts.plan}`; | ||
| if (opts.json) { | ||
| process.stdout.write(JSON.stringify({ ...t, url: planUrl }, null, 2) + "\n"); | ||
| process.stdout.write(JSON.stringify({ ...t, ...planUrls(opts.plan, baseUrl) }, null, 2) + "\n"); | ||
| return; | ||
@@ -255,5 +262,5 @@ } | ||
| const baseUrl = deriveBaseUrl(getApiUrl()); | ||
| const planUrl = `${baseUrl}/plans/${opts.plan}`; | ||
| const planUrl = clientPlanUrl(opts.plan, baseUrl); | ||
| if (opts.json) { | ||
| process.stdout.write(JSON.stringify({ travellers: list, url: planUrl }, null, 2) + "\n"); | ||
| process.stdout.write(JSON.stringify({ travellers: list, ...planUrls(opts.plan, baseUrl) }, null, 2) + "\n"); | ||
| return; | ||
@@ -260,0 +267,0 @@ } |
+1
-0
@@ -28,2 +28,3 @@ export var CliErrorCode; | ||
| CliErrorCode["ALREADY_BOOKED"] = "ALREADY_BOOKED"; | ||
| CliErrorCode["PLAN_BLOCKED"] = "PLAN_BLOCKED"; | ||
| CliErrorCode["CONFIRMATION_REQUIRED"] = "CONFIRMATION_REQUIRED"; | ||
@@ -30,0 +31,0 @@ CliErrorCode["INVALID_INPUT"] = "INVALID_INPUT"; |
+92
-3
@@ -54,5 +54,40 @@ import { z } from "zod"; | ||
| } | ||
| export function buildUpdateTravellerArgs(i) { | ||
| const args = ["travellers", "update", i.traveller_id]; | ||
| opt(args, "--first", i.first); | ||
| opt(args, "--last", i.last); | ||
| opt(args, "--gender", i.gender); | ||
| opt(args, "--dob", i.dob); | ||
| opt(args, "--email", i.email); | ||
| opt(args, "--phone", i.phone); | ||
| opt(args, "--type", i.type); | ||
| opt(args, "--passport-number", i.passport_number); | ||
| opt(args, "--passport-country", i.passport_country); | ||
| opt(args, "--passport-nationality", i.passport_nationality); | ||
| opt(args, "--passport-expiry", i.passport_expiry); | ||
| for (const p of i.frequent_flyer ?? []) | ||
| args.push("--frequent-flyer", p); | ||
| for (const p of i.hotel_loyalty ?? []) | ||
| args.push("--hotel-loyalty", p); | ||
| bool(args, "--clear-frequent-flyer", i.clear_frequent_flyer); | ||
| bool(args, "--clear-hotel-loyalty", i.clear_hotel_loyalty); | ||
| args.push("--json"); | ||
| return args; | ||
| } | ||
| export function buildGoalAddArgs(i) { | ||
| const args = ["plans", "goal-add", i.plan_id, "--type", i.type]; | ||
| opt(args, "--name", i.name); | ||
| opt(args, "--relative-day", i.relative_day); | ||
| opt(args, "--sort-order", i.sort_order); | ||
| opt(args, "--date", i.date); | ||
| opt(args, "--scope", i.scope); | ||
| opt(args, "--travellers", i.travellers); | ||
| opt(args, "--idempotency-key", i.idempotency_key); | ||
| args.push("--json"); | ||
| return args; | ||
| } | ||
| export function buildSearchFlightsArgs(i) { | ||
| const args = ["search", "flights", "--plan", i.plan_id, "--from", i.from, "--to", i.to, "--date", i.date]; | ||
| opt(args, "--return", i.return); | ||
| opt(args, "--sort", i.sort); | ||
| args.push("--json"); | ||
@@ -62,3 +97,6 @@ return args; | ||
| export function buildSearchHotelsArgs(i) { | ||
| return ["search", "hotels", "--plan", i.plan_id, "--location", i.location, "--checkin", i.checkin, "--checkout", i.checkout, "--json"]; | ||
| const args = ["search", "hotels", "--plan", i.plan_id, "--location", i.location, "--checkin", i.checkin, "--checkout", i.checkout]; | ||
| opt(args, "--sort", i.sort); | ||
| args.push("--json"); | ||
| return args; | ||
| } | ||
@@ -110,2 +148,3 @@ export function buildSearchActivitiesArgs(i) { | ||
| bool(args, "--rebook", i.rebook); | ||
| bool(args, "--force-checkout", i.force_checkout); | ||
| args.push("--json"); | ||
@@ -178,4 +217,45 @@ return args; | ||
| defineTool({ | ||
| name: "travellers_update", | ||
| description: "Update an existing traveller's record on a plan. Use to correct names or to fill the fields checkout requires: gender and date of birth (required at flight checkout) and passport data (hard-gates international reservations). Loyalty programs: passing frequent_flyer/hotel_loyalty REPLACES the existing list; clear_frequent_flyer/clear_hotel_loyalty remove all. At least one field must be provided.", | ||
| timeoutMs: T.short, | ||
| inputSchema: { | ||
| traveller_id: z.string().describe("Traveller id to update (from add_traveller / travellers list)."), | ||
| first: z.string().optional().describe("First name."), | ||
| last: z.string().optional().describe("Last name."), | ||
| gender: z.string().optional().describe("Gender: M | F | X (or Male | Female | Unspecified). Required at flight checkout."), | ||
| dob: z.string().optional().describe("Date of birth (YYYY-MM-DD). Required at flight checkout."), | ||
| email: z.string().optional().describe("Email address."), | ||
| phone: z.string().optional().describe("Contact phone number."), | ||
| type: z.string().optional().describe("Traveller type: Adult | Child | Infant."), | ||
| passport_number: z.string().optional().describe("Passport number (required to send any passport metadata; hard-gates international reservations)."), | ||
| passport_country: z.string().optional().describe("Passport issue country code (e.g. US). Requires passport_number."), | ||
| passport_nationality: z.string().optional().describe("Passport nationality country code (e.g. US). Requires passport_number."), | ||
| passport_expiry: z.string().optional().describe("Passport expiration (YYYY-MM). Requires passport_number."), | ||
| frequent_flyer: z.array(z.string()).optional().describe('Replace frequent-flyer programs with "AIRLINE:NUMBER", e.g. ["DL:1234567"]. Member number exactly as the airline issued it.'), | ||
| hotel_loyalty: z.array(z.string()).optional().describe('Replace hotel loyalty programs with "CHAIN:NUMBER", e.g. ["HI:12345678"]. Member number is digits only — do NOT include the chain code prefix.'), | ||
| clear_frequent_flyer: z.boolean().optional().describe("Remove all frequent-flyer programs (mutually exclusive with frequent_flyer)."), | ||
| clear_hotel_loyalty: z.boolean().optional().describe("Remove all hotel loyalty programs (mutually exclusive with hotel_loyalty)."), | ||
| }, | ||
| buildArgs: (i) => buildUpdateTravellerArgs(i), | ||
| }), | ||
| defineTool({ | ||
| name: "goal_add", | ||
| description: "Add a goal to a trip plan (no item/selection). A goal defines a slot the plan needs decided — e.g. an Activity goal is required before search_activities has anything to search against. type is a SelectionType (Activity, Flight, Hotel, HotelRoom, …), validated by the CLI. Returns the created goal; traveller assignment (if requested) is best-effort and surfaced in the result.", | ||
| timeoutMs: T.short, | ||
| inputSchema: { | ||
| plan_id: z.string().describe("Trip plan id."), | ||
| type: z.string().describe("SelectionType for the goal (e.g. Activity, Flight, Hotel, HotelRoom). Case-insensitive; validated against the CLI's supported types."), | ||
| name: z.string().optional().describe("Goal name. Defaults to '<type> goal' when omitted."), | ||
| relative_day: z.number().int().optional().describe("Day offset from trip start (integer)."), | ||
| sort_order: z.number().int().optional().describe("Initial sort order (integer)."), | ||
| date: z.string().optional().describe("Goal date (ISO 8601 date or datetime)."), | ||
| scope: z.string().optional().describe("Selection scope: Group | Traveller | Trip."), | ||
| travellers: z.string().optional().describe("Comma-separated traveller ids to assign after create (best-effort)."), | ||
| idempotency_key: z.string().optional().describe("Echoed in output for client-side retry tracking."), | ||
| }, | ||
| buildArgs: (i) => buildGoalAddArgs(i), | ||
| }), | ||
| defineTool({ | ||
| name: "search_flights", | ||
| description: "Search flights against the plan's Flight goal (REUSES the goal's selection — does not create a new one). Returns a compact envelope { selectionId, optionCount, topOptions[≤10] } (round trips also return returnSelectionId). If optionCount is 0 the async fetch is still running — poll get_selection_options with wait. Round trip: pick BOTH legs; the SAME optionId appears in both legs' lists (leg-mirrored) — picking the identical id on outbound and return is intended." + | ||
| description: "Search flights against the plan's Flight goal (REUSES the goal's selection — does not create a new one). Returns a compact envelope { selectionId, optionCount, topOptions[≤10], requestedParams } (round trips also return returnSelectionId). Because the selection is reused (not refetched), the envelope also echoes effectiveParams (the params the reused inventory was originally searched with) and a warnings[] entry starting SELECTION_REUSED_PARAMS_MISMATCH when the requested params differ — treat the results as reflecting effectiveParams in that case. If optionCount is 0 the async fetch is still running — poll get_selection_options with wait. Round trip: pick BOTH legs; the SAME optionId appears in both legs' lists (leg-mirrored) — picking the identical id on outbound and return is intended." + | ||
| INJECTION_NOTE, | ||
@@ -189,2 +269,6 @@ timeoutMs: T.search, | ||
| return: z.string().optional().describe("Return date (YYYY-MM-DD) for a round-trip."), | ||
| sort: z | ||
| .enum(["price", "duration", "stops"]) | ||
| .optional() | ||
| .describe("Optional factual single-field sort of the returned options: price (cheapest first), duration (shortest first), or stops (fewest first). Omit to preserve the server's default value ordering (index 0 is the server's value pick, NOT the cheapest)."), | ||
| }, | ||
@@ -195,3 +279,3 @@ buildArgs: (i) => buildSearchFlightsArgs(i), | ||
| name: "search_hotels", | ||
| description: "Search hotels against the plan's Hotel goal. Returns a compact envelope { selectionId, optionCount, topOptions[≤10] }. Prices are STAY TOTALS, not nightly. If optionCount is 0 the async fetch is still running — poll get_selection_options with wait, then select." + | ||
| description: "Search hotels against the plan's Hotel goal (REUSES the goal's selection). Returns a compact envelope { selectionId, optionCount, topOptions[≤10], requestedParams }. Because the selection is reused (not refetched), the envelope also echoes effectiveParams (the params the reused inventory was originally searched with) and a warnings[] entry starting SELECTION_REUSED_PARAMS_MISMATCH when the requested params differ — treat the results as reflecting effectiveParams in that case. Prices are STAY TOTALS, not nightly. If optionCount is 0 the async fetch is still running — poll get_selection_options with wait, then select." + | ||
| INJECTION_NOTE, | ||
@@ -204,2 +288,6 @@ timeoutMs: T.search, | ||
| checkout: z.string().describe("Check-out date (YYYY-MM-DD). Ranges are INCLUSIVE of the end date."), | ||
| sort: z | ||
| .enum(["price"]) | ||
| .optional() | ||
| .describe("Optional factual sort of the returned options by price (cheapest first). Omit to preserve the server's default value ordering (index 0 is the server's value pick, NOT the cheapest). Duration/stops do not apply to hotels."), | ||
| }, | ||
@@ -283,2 +371,3 @@ buildArgs: (i) => buildSearchHotelsArgs(i), | ||
| rebook: z.boolean().optional().describe("Proceed even though a Paid checkout already exists (intentional second charge)."), | ||
| force_checkout: z.boolean().optional().describe("Skip the client-side readiness guard (refuses checkout on hard traveller-data/other blockers) and trust the server's own validation."), | ||
| }, | ||
@@ -285,0 +374,0 @@ buildArgs: (i) => buildBookArgs(i), |
| import chalk from "chalk"; | ||
| import { graphql } from "./api.js"; | ||
| import { getApiUrl } from "./config.js"; | ||
| import { deriveBaseUrl, formatDateRange } from "./utils.js"; | ||
| import { formatDateRange } from "./utils.js"; | ||
| import { clientPlanUrl, planUrls } from "./plan-urls.js"; | ||
| const PLAN_FOOTER_QUERY = `query PlanFooter($id: String!) { tripPlan(id: $id) { title startDate endDate travellers { id } items { id } } }`; | ||
@@ -12,3 +12,3 @@ export async function printPlanFooter(planId) { | ||
| return; | ||
| const url = `${deriveBaseUrl(getApiUrl())}/plans/${planId}`; | ||
| const url = clientPlanUrl(planId); | ||
| const dates = formatDateRange(p.startDate, p.endDate); | ||
@@ -31,3 +31,3 @@ const tc = p.travellers?.length ?? 0; | ||
| title: p.title, | ||
| url: `${deriveBaseUrl(getApiUrl())}/plans/${planId}`, | ||
| ...planUrls(planId), | ||
| dates: formatDateRange(p.startDate, p.endDate), | ||
@@ -34,0 +34,0 @@ travellerCount: p.travellers?.length ?? 0, |
+8
-0
@@ -320,2 +320,10 @@ export const GET_CART = ` | ||
| `; | ||
| export const UPDATE_MY_USER = ` | ||
| mutation UpdateMyUser($input: UpdateUserInput!) { | ||
| updateMyUser(input: $input) { | ||
| passport { last4 issueCountry nationalityCountry expirationDate } | ||
| frequentFlyerPrograms { airlineCode membershipNumber } | ||
| } | ||
| } | ||
| `; | ||
| export const CREATE_TRAVELLER = ` | ||
@@ -322,0 +330,0 @@ mutation CreateTraveller($tripPlanId: String!, $input: CreateTripPlanTravellerInput!) { |
+90
-0
@@ -109,1 +109,91 @@ import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, chmodSync } from "fs"; | ||
| } | ||
| const SELECTION_PARAMS_FILE = join(CONFIG_DIR, "selection-params.json"); | ||
| const SELECTION_PARAMS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; | ||
| const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); | ||
| const STRING_PARAM_KEYS = ["origin", "destination", "depart", "return", "checkin", "checkout"]; | ||
| function sanitizeSelectionParams(raw) { | ||
| const clean = {}; | ||
| for (const k of STRING_PARAM_KEYS) { | ||
| const v = raw[k]; | ||
| if (v === undefined || v === null) | ||
| continue; | ||
| if (typeof v !== "string") | ||
| return null; | ||
| clean[k] = v; | ||
| } | ||
| if (raw.partySize !== undefined && raw.partySize !== null) { | ||
| if (typeof raw.partySize !== "number" || !Number.isFinite(raw.partySize)) | ||
| return null; | ||
| clean.partySize = raw.partySize; | ||
| } | ||
| return clean; | ||
| } | ||
| function loadSelectionParamsMap() { | ||
| if (!existsSync(SELECTION_PARAMS_FILE)) | ||
| return Object.create(null); | ||
| try { | ||
| const raw = readFileSync(SELECTION_PARAMS_FILE, "utf-8"); | ||
| const parsed = JSON.parse(raw); | ||
| const map = parsed?.selections; | ||
| if (typeof map !== "object" || map === null) | ||
| return Object.create(null); | ||
| const out = Object.create(null); | ||
| for (const [id, v] of Object.entries(map)) { | ||
| if (DANGEROUS_KEYS.has(id)) | ||
| continue; | ||
| if (typeof v !== "object" || v === null) | ||
| continue; | ||
| const e = v; | ||
| if (typeof e.timestamp !== "string" || typeof e.params !== "object" || e.params === null) | ||
| continue; | ||
| const params = sanitizeSelectionParams(e.params); | ||
| if (!params) | ||
| continue; | ||
| out[id] = { params, timestamp: e.timestamp }; | ||
| } | ||
| return out; | ||
| } | ||
| catch (err) { | ||
| if (err instanceof SyntaxError) { | ||
| try { | ||
| unlinkSync(SELECTION_PARAMS_FILE); | ||
| } | ||
| catch { } | ||
| } | ||
| return Object.create(null); | ||
| } | ||
| } | ||
| export function getSelectionSearchParams(selectionId) { | ||
| return loadSelectionParamsMap()[selectionId]?.params ?? null; | ||
| } | ||
| export function rememberSelectionSearchParams(selectionId, params, now = new Date()) { | ||
| try { | ||
| const map = loadSelectionParamsMap(); | ||
| const cutoff = now.getTime() - SELECTION_PARAMS_MAX_AGE_MS; | ||
| let mutated = false; | ||
| for (const [id, e] of Object.entries(map)) { | ||
| const t = new Date(e.timestamp).getTime(); | ||
| if (!Number.isFinite(t) || t < cutoff) { | ||
| delete map[id]; | ||
| mutated = true; | ||
| } | ||
| } | ||
| if (!map[selectionId]) { | ||
| map[selectionId] = { params, timestamp: now.toISOString() }; | ||
| mutated = true; | ||
| } | ||
| if (!mutated) | ||
| return; | ||
| mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); | ||
| chmodSync(CONFIG_DIR, 0o700); | ||
| writeFileSync(SELECTION_PARAMS_FILE, JSON.stringify({ selections: map }, null, 2), { mode: 0o600 }); | ||
| chmodSync(SELECTION_PARAMS_FILE, 0o600); | ||
| } | ||
| catch { | ||
| } | ||
| } | ||
| export function clearSelectionSearchParams() { | ||
| if (existsSync(SELECTION_PARAMS_FILE)) { | ||
| unlinkSync(SELECTION_PARAMS_FILE); | ||
| } | ||
| } |
+3
-0
@@ -7,2 +7,5 @@ import chalk from "chalk"; | ||
| export { formatPrice, cents } from "./format.js"; | ||
| export function maskLoyaltyValue(value) { | ||
| return value.length > 4 ? `••••${value.slice(-4)}` : "••••"; | ||
| } | ||
| export function extractFlightToken(bookingData) { | ||
@@ -9,0 +12,0 @@ if (!bookingData) |
+1
-1
| { | ||
| "name": "@voyagier/cli", | ||
| "version": "2.16.0", | ||
| "version": "2.17.0", | ||
| "mcpName": "com.voyagier/cli", | ||
@@ -5,0 +5,0 @@ "description": "Agent-ready travel CLI — search, plan, quote, and book real trips (flights, hotels, activities) against the Voyagier platform. Built for AI agents: --json everywhere, uniform error codes, price-gated checkout, printable agent reference (voyagier agent-docs).", |
+1
-1
@@ -192,3 +192,3 @@ # @voyagier/cli | ||
| Once your account is granted API access, mint a personal access token at [voyagier.com/me/settings/tokens](https://voyagier.com/me/settings/tokens) and you're in. Two account tiers use the CLI today: | ||
| Once your account is granted API access, mint a personal access token at [travel.voyagier.com/me/settings/tokens](https://travel.voyagier.com/me/settings/tokens) and you're in. Two account tiers use the CLI today: | ||
@@ -195,0 +195,0 @@ - **Travel advisors** — manage a book of clients (`voyagier clients`); plans are created against a client (`--client`). |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1245806
2.71%73
2.82%19550
3.35%