@voyagier/cli
Advanced tools
| import chalk from "chalk"; | ||
| import { graphql } from "../api.js"; | ||
| import { CREATE_TRIP_PLAN, CREATE_TRAVELLER_BRIEF, LIST_TRIP_PLAN_GOALS, DELETE_TRIP_PLAN_GOAL, } from "../queries.js"; | ||
| import { shellArg } from "../utils.js"; | ||
| import { progress, warn } from "../output.js"; | ||
| import { CliError, CliErrorCode } from "../errors.js"; | ||
| import { resolveClient } from "./clients.js"; | ||
| export function selectGoalsToPrune(goals, shape) { | ||
| const prune = new Map(); | ||
| const warnings = []; | ||
| if (shape.oneWay) { | ||
| const returnGoals = goals.filter(g => g.type === "Flight" && /return/i.test(g.name ?? "")); | ||
| if (returnGoals.length === 0) { | ||
| warnings.push("--one-way: no return-flight goal found to prune (scaffold may have changed). Inspect `plans goals <planId>` and remove it with `plans goal-remove <goalId> --force`."); | ||
| } | ||
| for (const g of returnGoals) | ||
| prune.set(g.id, g); | ||
| } | ||
| if (shape.flightOnly) { | ||
| const hotelGoals = goals.filter(g => g.type === "Hotel"); | ||
| if (hotelGoals.length === 0) { | ||
| warnings.push("--flight-only: no Hotel goal found to prune (scaffold may have changed). Inspect `plans goals <planId>`."); | ||
| } | ||
| for (const g of hotelGoals) | ||
| prune.set(g.id, g); | ||
| } | ||
| if (shape.hotelOnly) { | ||
| const flightish = goals.filter(g => g.type === "Flight" || g.type === "FlightJourney"); | ||
| if (flightish.length === 0) { | ||
| warnings.push("--hotel-only: no Flight/FlightJourney goals found to prune (scaffold may have changed). Inspect `plans goals <planId>`."); | ||
| } | ||
| for (const g of flightish) | ||
| prune.set(g.id, g); | ||
| } | ||
| return { prune: [...prune.values()], warnings }; | ||
| } | ||
| export function validateShapeFlags(opts) { | ||
| const anyShape = !!(opts.oneWay || opts.flightOnly || opts.hotelOnly); | ||
| if (!anyShape) | ||
| return; | ||
| if (opts.plan) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "Shape flags (--one-way/--flight-only/--hotel-only) only apply when scaffolding a NEW plan. For an existing plan, prune goals directly: `voyagier plans goals <planId>` then `voyagier plans goal-remove <goalId> --force`."); | ||
| } | ||
| if (opts.oneWay && opts.return) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "--one-way conflicts with --return. Drop one."); | ||
| } | ||
| if (opts.hotelOnly && opts.flightOnly) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "--hotel-only conflicts with --flight-only. Pick one."); | ||
| } | ||
| if (opts.hotelOnly && opts.oneWay) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "--hotel-only conflicts with --one-way (a hotel-only plan has no flights)."); | ||
| } | ||
| if (opts.hotelOnly && (opts.to || opts.from || opts.depart || opts.return)) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "--hotel-only conflicts with flight flags (--from/--to/--depart/--return). A hotel-only plan has no flights."); | ||
| } | ||
| if (opts.flightOnly && (opts.hotel || opts.checkin || opts.checkout)) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "--flight-only conflicts with hotel flags (--hotel/--checkin/--checkout). Drop one."); | ||
| } | ||
| } | ||
| export function parseTravellers(names) { | ||
| return names.split(",") | ||
| .map(name => name.trim()) | ||
| .filter(Boolean) | ||
| .map(name => { | ||
| const parts = name.split(/\s+/); | ||
| if (parts.length === 1) | ||
| return { firstName: parts[0], lastName: parts[0] }; | ||
| return { firstName: parts.slice(0, -1).join(" "), lastName: parts[parts.length - 1] }; | ||
| }); | ||
| } | ||
| export async function addTravellers(tripPlanId, names, opts) { | ||
| const parsed = parseTravellers(names); | ||
| if (parsed.length === 0) | ||
| return []; | ||
| if (!opts?.quiet && opts?.progress !== false) | ||
| progress("Adding travellers..."); | ||
| const ids = []; | ||
| for (const t of parsed) { | ||
| const tData = await graphql(CREATE_TRAVELLER_BRIEF, { tripPlanId, input: { firstName: t.firstName, lastName: t.lastName, declaredTravellerType: "Adult" } }); | ||
| ids.push(tData.createTripPlanTraveller.id); | ||
| } | ||
| return ids; | ||
| } | ||
| const MONTH_ABBR = [ | ||
| "Jan", "Feb", "Mar", "Apr", "May", "Jun", | ||
| "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", | ||
| ]; | ||
| function formatMonthYear(dateStr, now) { | ||
| let d = null; | ||
| const m = dateStr ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr.trim()) : null; | ||
| if (m) { | ||
| const [y, mo, day] = [Number(m[1]), Number(m[2]), Number(m[3])]; | ||
| const parsed = new Date(y, mo - 1, day); | ||
| if (parsed.getFullYear() === y && parsed.getMonth() === mo - 1 && parsed.getDate() === day) | ||
| d = parsed; | ||
| } | ||
| if (!d) | ||
| d = now; | ||
| return `${MONTH_ABBR[d.getMonth()]} ${d.getFullYear()}`; | ||
| } | ||
| export function generateTripTitle(args, now = new Date()) { | ||
| const destination = (args.to || args.hotel)?.trim(); | ||
| const monYear = formatMonthYear(args.depart || args.checkin, now); | ||
| return `${destination || "Trip"} · ${monYear}`; | ||
| } | ||
| export async function scaffoldPlan(opts) { | ||
| const chatty = !opts.quiet; | ||
| const resolved = await resolveClient(opts.client, { | ||
| interactive: opts.interactive, | ||
| carryFlags: opts.clientHintFlags, | ||
| }); | ||
| if (resolved.autoResolved && chatty) { | ||
| const note = resolved.isSelf | ||
| ? `auto-resolved client: you (${resolved.name}, self)\n` | ||
| : `auto-resolved client: ${resolved.name} (${resolved.id})\n`; | ||
| process.stderr.write(chalk.dim(note)); | ||
| } | ||
| if (chatty && opts.progress !== false) | ||
| progress("Creating trip plan..."); | ||
| const planInput = { clientId: resolved.id, title: opts.title }; | ||
| const planData = await graphql(CREATE_TRIP_PLAN, { input: planInput }, { dryRun: opts.dryRun }); | ||
| const plan = planData.createTripPlan; | ||
| const travellerIds = opts.travellers | ||
| ? await addTravellers(plan.id, opts.travellers, { quiet: opts.quiet, progress: opts.progress }) | ||
| : []; | ||
| const prunedGoals = []; | ||
| const pruneWarnings = []; | ||
| const shape = { | ||
| oneWay: !!opts.shape?.oneWay, | ||
| flightOnly: !!opts.shape?.flightOnly, | ||
| hotelOnly: !!opts.shape?.hotelOnly, | ||
| }; | ||
| if (shape.oneWay || shape.flightOnly || shape.hotelOnly) { | ||
| if (chatty && opts.progress !== false) | ||
| progress("Pruning goals to match trip shape..."); | ||
| const goalsData = await graphql(LIST_TRIP_PLAN_GOALS, { tripPlanId: plan.id }); | ||
| const { prune, warnings } = selectGoalsToPrune(goalsData.tripPlanGoals ?? [], shape); | ||
| pruneWarnings.push(...warnings); | ||
| for (const g of prune) { | ||
| try { | ||
| const del = await graphql(DELETE_TRIP_PLAN_GOAL, { id: g.id }); | ||
| if (del.deleteTripPlanGoal === true) { | ||
| prunedGoals.push({ id: g.id, name: g.name ?? undefined, type: g.type }); | ||
| } | ||
| else { | ||
| pruneWarnings.push(`Server declined to delete goal "${g.name ?? g.id}" (${g.type}). Remove it manually: voyagier plans goal-remove ${shellArg(g.id)} --force`); | ||
| } | ||
| } | ||
| catch (err) { | ||
| const message = (err instanceof Error ? err.message : String(err)).replace(/\s+/g, " "); | ||
| pruneWarnings.push(`Failed to delete goal "${g.name ?? g.id}" (${g.type}): ${message}. Remove it manually: voyagier plans goal-remove ${shellArg(g.id)} --force`); | ||
| } | ||
| } | ||
| for (const w of pruneWarnings) | ||
| warn(w); | ||
| } | ||
| return { | ||
| plan, | ||
| client: { id: resolved.id, name: resolved.name, autoResolved: resolved.autoResolved, isSelf: resolved.isSelf }, | ||
| travellerIds, | ||
| prunedGoals, | ||
| pruneWarnings, | ||
| }; | ||
| } |
| import { flushTelemetry } from "./telemetry.js"; | ||
| export async function gracefulExit(code) { | ||
| process.exitCode = code; | ||
| await flushTelemetry(250); | ||
| process.exit(code); | ||
| } |
| import { createInterface } from "readline/promises"; | ||
| import chalk from "chalk"; | ||
| export function isInteractive(opts = {}) { | ||
| const noInput = opts.noInput === true || opts.input === false; | ||
| return (process.stdin.isTTY === true && | ||
| !process.env.CI && | ||
| !opts.json && | ||
| !opts.agent && | ||
| !noInput); | ||
| } | ||
| export async function promptText(question, opts = {}) { | ||
| const rl = createInterface({ input: process.stdin, output: process.stderr }); | ||
| try { | ||
| const answer = (await rl.question(question)).trim(); | ||
| if (!answer && opts.default !== undefined) | ||
| return opts.default; | ||
| return answer; | ||
| } | ||
| finally { | ||
| rl.close(); | ||
| } | ||
| } | ||
| export async function promptPick(question, items, render, onGiveUp) { | ||
| const rl = createInterface({ input: process.stdin, output: process.stderr }); | ||
| try { | ||
| const MAX_ATTEMPTS = 3; | ||
| for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { | ||
| process.stderr.write(question + "\n"); | ||
| items.forEach((item, i) => { | ||
| process.stderr.write(` [${i + 1}] ${render(item)}\n`); | ||
| }); | ||
| const raw = (await rl.question("> ")).trim(); | ||
| const n = Number(raw); | ||
| if (raw !== "" && Number.isInteger(n) && n >= 1 && n <= items.length) { | ||
| return items[n - 1]; | ||
| } | ||
| if (attempt < MAX_ATTEMPTS - 1) { | ||
| process.stderr.write(chalk.dim(`Please enter a number between 1 and ${items.length}.\n`)); | ||
| } | ||
| } | ||
| throw onGiveUp; | ||
| } | ||
| finally { | ||
| rl.close(); | ||
| } | ||
| } |
+4
-2
@@ -21,3 +21,3 @@ # Voyagier CLI — Agent Reference | ||
| - **Computed itinerary.** `voyagier itinerary <planId>` reads the platform's `tripPlanEvents` resolver. | ||
| - **Advisor CRM.** `voyagier clients` manages clients; a plan requires a `clientId`. | ||
| - **Advisor CRM.** `voyagier clients` manages clients; a plan requires a `clientId`. **Exception — planning for the account owner themself** (trip-planner accounts; `whoami` shows the tier): skip client management entirely and omit `--client` — the CLI resolves the owner automatically. Don't `clients upsert` the owner's own email; that creates a redundant client record. | ||
| - **Self-check.** `voyagier doctor` verifies auth, schema reachability, state, and version. | ||
@@ -53,3 +53,5 @@ | ||
| # 1) Resolve a client (idempotent by email) | ||
| # 1) Resolve a client (idempotent by email). SKIP this step when the trip is | ||
| # for the account owner themself (trip-planner accounts) — just omit | ||
| # --client in step 2 and the CLI resolves the owner automatically. | ||
| voyagier clients upsert --email "smith@example.com" --name "Smith Family" --type Individual --json | ||
@@ -56,0 +58,0 @@ # Returns: { client: { id, name, ... }, ok: true, created: true|false } |
+24
-1
| import { getApiUrl, getToken } from "./config.js"; | ||
| import { getTraceId } from "./telemetry.js"; | ||
| import { gracefulExit } from "./exit.js"; | ||
| import { verbose } from "./verbose.js"; | ||
@@ -22,3 +23,3 @@ import { CliError, CliErrorCode, authFailedMessage } from "./errors.js"; | ||
| process.stderr.write("--- END DRY RUN ---\n\n"); | ||
| process.exit(0); | ||
| await gracefulExit(0); | ||
| } | ||
@@ -86,2 +87,24 @@ let res; | ||
| } | ||
| const legacyModeQueries = new Set(); | ||
| export function __resetFieldFallbackCache() { | ||
| legacyModeQueries.clear(); | ||
| } | ||
| export async function graphqlWithFieldFallback(enrichedQuery, legacyQuery, fieldPattern, variables, options) { | ||
| if (legacyModeQueries.has(enrichedQuery)) { | ||
| return await graphql(legacyQuery, variables, options); | ||
| } | ||
| try { | ||
| return await graphql(enrichedQuery, variables, options); | ||
| } | ||
| catch (err) { | ||
| const isUnknownNewField = err instanceof CliError && | ||
| err.code === CliErrorCode.SCHEMA_DRIFT && | ||
| fieldPattern.test(err.message); | ||
| if (isUnknownNewField) { | ||
| legacyModeQueries.add(enrichedQuery); | ||
| return await graphql(legacyQuery, variables, options); | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| export async function streamChat(sessionId, message, callbacks) { | ||
@@ -88,0 +111,0 @@ const apiUrl = getApiUrl(); |
@@ -148,4 +148,5 @@ import chalk from "chalk"; | ||
| } | ||
| if (r.tripPlanItem) | ||
| console.log(` Item: ${r.tripPlanItem.title}`); | ||
| const item = r.tripPlanPlace ?? r.tripPlanProduct; | ||
| if (item) | ||
| console.log(` Item: ${item.name}`); | ||
| if (r.travellers && r.travellers.length > 0) { | ||
@@ -152,0 +153,0 @@ const names = r.travellers.map(t => `${t.firstName} ${t.lastName}`).join(", "); |
| import chalk from "chalk"; | ||
| import { createInterface } from "readline"; | ||
| import { graphql, streamChat } from "../api.js"; | ||
| import { gracefulExit } from "../exit.js"; | ||
| import { CliError, CliErrorCode } from "../errors.js"; | ||
@@ -88,3 +89,3 @@ import { CREATE_CHAT_SESSION, LIST_CHAT_SESSIONS } from "../queries.js"; | ||
| process.stdout.write("\n"); | ||
| process.exit(0); | ||
| await gracefulExit(0); | ||
| } | ||
@@ -141,5 +142,5 @@ catch (err) { | ||
| }); | ||
| rl.on("close", () => { | ||
| rl.on("close", async () => { | ||
| console.log(chalk.dim("\nSession ended.")); | ||
| process.exit(0); | ||
| await gracefulExit(0); | ||
| }); | ||
@@ -146,0 +147,0 @@ } |
+38
-14
| import chalk from "chalk"; | ||
| import { graphql } from "../api.js"; | ||
| import { graphql, graphqlWithFieldFallback } from "../api.js"; | ||
| import { jsonOutput, fatal } from "../output.js"; | ||
| import { CliError, CliErrorCode } from "../errors.js"; | ||
| import { LIST_TRIP_PLAN_CLIENTS, GET_TRIP_PLAN_CLIENT, CREATE_TRIP_PLAN_CLIENT, UPDATE_TRIP_PLAN_CLIENT, } from "../queries.js"; | ||
| import { promptPick } from "../prompt.js"; | ||
| import { LIST_TRIP_PLAN_CLIENTS, LIST_TRIP_PLAN_CLIENTS_WITH_SELF, GET_TRIP_PLAN_CLIENT, CREATE_TRIP_PLAN_CLIENT, UPDATE_TRIP_PLAN_CLIENT, } from "../queries.js"; | ||
| import { shellArg } from "../utils.js"; | ||
@@ -29,3 +30,6 @@ const VALID_TYPES = ["individual", "company", "group"]; | ||
| while (page <= CLIENTS_MAX_PAGES) { | ||
| const data = await graphql(LIST_TRIP_PLAN_CLIENTS, { page, limit: CLIENTS_PAGE_SIZE }); | ||
| const data = await graphqlWithFieldFallback(LIST_TRIP_PLAN_CLIENTS_WITH_SELF, LIST_TRIP_PLAN_CLIENTS, /isSelf/, { | ||
| page, | ||
| limit: CLIENTS_PAGE_SIZE, | ||
| }); | ||
| const items = data.tripPlanClients.items ?? []; | ||
@@ -48,6 +52,7 @@ out.push(...items); | ||
| const typeLabel = chalk.cyan(`[${c.clientType}]`); | ||
| const selfMarker = c.isSelf ? " " + chalk.magenta("(self)") : ""; | ||
| const contact = c.email ? chalk.dim(` <${c.email}>`) : ""; | ||
| return `${statusBadge} ${typeLabel} ${chalk.bold(c.name)}${contact} ${chalk.dim(c.id)}`; | ||
| return `${statusBadge} ${typeLabel} ${chalk.bold(c.name)}${selfMarker}${contact} ${chalk.dim(c.id)}`; | ||
| } | ||
| export async function resolveClient(explicit) { | ||
| export async function resolveClient(explicit, options = {}) { | ||
| if (explicit === "") { | ||
@@ -61,3 +66,3 @@ throw new CliError(CliErrorCode.CLIENT_REQUIRED, "--client was provided but empty. Pass an id, email, name, or omit the flag to auto-resolve."); | ||
| if (!match) { | ||
| throw new CliError(CliErrorCode.NOT_FOUND, `No ACTIVE client found with email "${explicit}".\n Fix: voyagier clients list --json (then pick an id)\n Or: voyagier clients create --name "..." --type individual --email "${explicit}"`); | ||
| throw new CliError(CliErrorCode.NOT_FOUND, `No ACTIVE client found with email "${explicit}".\n Fix: voyagier clients list (then pass --client <id|name|email>)\n Or: voyagier clients create --name "..." --type individual --email "${explicit}"`); | ||
| } | ||
@@ -73,7 +78,12 @@ return { id: match.id, name: match.name, autoResolved: false }; | ||
| if (matches.length === 0) { | ||
| throw new CliError(CliErrorCode.NOT_FOUND, `No ACTIVE client found matching "${explicit}".\n Fix: voyagier clients list --json (then pick an id)`); | ||
| throw new CliError(CliErrorCode.NOT_FOUND, `No ACTIVE client found matching "${explicit}".\n Fix: voyagier clients list (then pass --client <id|name|email>)`); | ||
| } | ||
| if (matches.length > 1) { | ||
| const list = matches.map((c) => ` ${c.id} ${c.name}`).join("\n"); | ||
| throw new CliError(CliErrorCode.MULTIPLE_CLIENTS, `Multiple ACTIVE clients matched "${explicit}". Specify --client <id>:\n${list}`); | ||
| const ambiguous = new CliError(CliErrorCode.MULTIPLE_CLIENTS, `Multiple ACTIVE clients matched "${explicit}". Specify --client <id|email>:\n${list}\n Tip: an email or id is unambiguous.`); | ||
| if (options.interactive) { | ||
| const chosen = await promptPick(`Multiple ACTIVE clients matched "${explicit}". Which one?`, matches, (c) => `${c.name}${c.email ? ` <${c.email}>` : ""}`, ambiguous); | ||
| return { id: chosen.id, name: chosen.name, autoResolved: false }; | ||
| } | ||
| throw ambiguous; | ||
| } | ||
@@ -87,10 +97,24 @@ return { id: matches[0].id, name: matches[0].name, autoResolved: false }; | ||
| } | ||
| if (active.length > 1) { | ||
| const list = active.map((c) => ` ${c.id} ${c.name}`).join("\n"); | ||
| throw new CliError(CliErrorCode.MULTIPLE_CLIENTS, `Multiple ACTIVE clients found. Specify --client <id>:\n${list}\n Fix: voyagier plan-trip --client <id>`); | ||
| if (active.length === 1) { | ||
| return { id: active[0].id, name: active[0].name, autoResolved: true, isSelf: active[0].isSelf === true }; | ||
| } | ||
| return { id: active[0].id, name: active[0].name, autoResolved: true }; | ||
| const selfClients = active.filter((c) => c.isSelf === true); | ||
| if (selfClients.length === 1) { | ||
| return { id: selfClients[0].id, name: selfClients[0].name, autoResolved: true, isSelf: true }; | ||
| } | ||
| const list = active.map((c) => ` ${c.id} ${c.name}${c.isSelf ? " (self)" : ""}`).join("\n"); | ||
| const selfHint = selfClients.length > 0 | ||
| ? "\n Note: more than one client is flagged as your self client — pass --client <id> explicitly." | ||
| : ""; | ||
| const exampleName = shellArg(active[0].name || "Client Name"); | ||
| const carry = options.carryFlags ? ` ${options.carryFlags}` : ""; | ||
| const ambiguous = new CliError(CliErrorCode.MULTIPLE_CLIENTS, `Multiple ACTIVE clients found. Specify --client <id|name|email>:\n${list}${selfHint}\n Fix: voyagier plan-trip --client ${exampleName}${carry} (--client accepts an id, name, or email)`); | ||
| if (options.interactive) { | ||
| const chosen = await promptPick("Multiple ACTIVE clients found. Which one?", active, (c) => `${c.name}${c.isSelf ? " (self)" : ""}${c.email ? ` <${c.email}>` : ""}`, ambiguous); | ||
| return { id: chosen.id, name: chosen.name, autoResolved: false }; | ||
| } | ||
| throw ambiguous; | ||
| } | ||
| export async function resolveClientId(explicit) { | ||
| return (await resolveClient(explicit)).id; | ||
| export async function resolveClientId(explicit, options) { | ||
| return (await resolveClient(explicit, options)).id; | ||
| } | ||
@@ -97,0 +121,0 @@ export function registerClientsCommands(program) { |
@@ -6,2 +6,3 @@ import chalk from "chalk"; | ||
| import { graphql, AuthError } from "../api.js"; | ||
| import { gracefulExit } from "../exit.js"; | ||
| import { CONFIG_DIR, credentialsExist, getApiUrl, getUserContext } from "../config.js"; | ||
@@ -373,3 +374,3 @@ import { sanitizeExternalText } from "../utils.js"; | ||
| if (overall === "FAIL") | ||
| process.exit(1); | ||
| await gracefulExit(1); | ||
| return; | ||
@@ -398,4 +399,4 @@ } | ||
| if (overall === "FAIL") | ||
| process.exit(1); | ||
| await gracefulExit(1); | ||
| }); | ||
| } |
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| import { createServer } from "../mcp/server.js"; | ||
| import { TOOLS } from "../mcp/tools.js"; | ||
| import { gracefulExit } from "../exit.js"; | ||
| export function registerMcpCommand(program) { | ||
@@ -22,3 +23,3 @@ program | ||
| process.exitCode = 0; | ||
| setTimeout(() => process.exit(0), 2000).unref(); | ||
| setTimeout(() => void gracefulExit(0), 2000).unref(); | ||
| }; | ||
@@ -25,0 +26,0 @@ process.on("SIGINT", () => void shutdown()); |
@@ -7,2 +7,86 @@ import chalk from "chalk"; | ||
| import { SEARCH_PLACES, SEARCH_EXTERNAL_PLACES, GET_PLACE_BY_ID, GET_PLACE_BY_EXTERNAL_ID, GET_TRIP_PLAN_PLACES, UPSERT_TRIP_PLAN_PLACE, REMOVE_TRIP_PLAN_PLACE, HIGHLIGHT_TRIP_PLACE, UNHIGHLIGHT_TRIP_PLACE, GET_HIGHLIGHTED_TRIP_PLACES, } from "../queries.js"; | ||
| function geoPointToLocation(g) { | ||
| const c = g?.coordinates; | ||
| if (!Array.isArray(c) || c.length < 2) | ||
| return null; | ||
| const [lng, lat] = c; | ||
| if (typeof lat !== "number" || typeof lng !== "number") | ||
| return null; | ||
| return { latitude: lat, longitude: lng }; | ||
| } | ||
| function normalizeSearchPlace(raw) { | ||
| return { | ||
| id: raw.id, | ||
| name: raw.name ?? null, | ||
| description: raw.description ?? null, | ||
| location: geoPointToLocation(raw.location), | ||
| address: raw.address | ||
| ? { | ||
| street: raw.address.streetAddress ?? null, | ||
| city: raw.address.addressLocality ?? null, | ||
| state: raw.address.addressRegion ?? null, | ||
| postalCode: raw.address.postalCode ?? null, | ||
| country: raw.address.addressCountry ?? null, | ||
| } | ||
| : null, | ||
| country: raw.country ?? null, | ||
| locality: raw.locality ?? null, | ||
| }; | ||
| } | ||
| function normalizeExternalPlace(raw) { | ||
| const main = raw.geocodes?.main; | ||
| const location = main && typeof main.latitude === "number" && typeof main.longitude === "number" | ||
| ? { latitude: main.latitude, longitude: main.longitude } | ||
| : null; | ||
| const loc = raw.location; | ||
| return { | ||
| id: raw.id, | ||
| name: raw.name ?? null, | ||
| description: null, | ||
| location, | ||
| address: loc | ||
| ? { | ||
| street: loc.address ?? null, | ||
| city: loc.locality ?? null, | ||
| state: loc.region ?? null, | ||
| postalCode: loc.postcode ?? null, | ||
| country: loc.country ?? null, | ||
| } | ||
| : null, | ||
| country: null, | ||
| locality: null, | ||
| }; | ||
| } | ||
| function normalizeTripPlanPlace(raw) { | ||
| return { | ||
| id: raw.id, | ||
| name: raw.name ?? null, | ||
| placeId: raw.placeId ?? null, | ||
| tripPlanId: raw.tripPlanId ?? null, | ||
| type: raw.type ?? null, | ||
| types: raw.types ?? null, | ||
| countryId: raw.countryId ?? null, | ||
| countryName: raw.countryName ?? null, | ||
| description: raw.description ?? null, | ||
| iataCode: raw.iataCode ?? null, | ||
| url: raw.url ?? null, | ||
| placeTimezone: raw.placeTimezone ?? null, | ||
| location: geoPointToLocation(raw.location), | ||
| }; | ||
| } | ||
| function normalizeHighlightedTripPlace(raw) { | ||
| return { | ||
| id: raw.id, | ||
| ranking: raw.ranking ?? null, | ||
| category: raw.category ?? null, | ||
| detectedPlace: raw.detectedPlace | ||
| ? { | ||
| id: raw.detectedPlace.id, | ||
| name: raw.detectedPlace.name ?? null, | ||
| placeId: raw.detectedPlace.placeId ?? null, | ||
| location: geoPointToLocation(raw.detectedPlace.location), | ||
| } | ||
| : null, | ||
| }; | ||
| } | ||
| const VALID_HIGHLIGHT_CATEGORIES = ["attraction", "hotel", "restaurant"]; | ||
@@ -107,3 +191,3 @@ const HIGHLIGHT_CATEGORY_MAP = { | ||
| }); | ||
| places = data.searchExternalPlaces ?? []; | ||
| places = (data.searchExternalPlaces ?? []).map(normalizeExternalPlace); | ||
| total = places.length; | ||
@@ -123,3 +207,3 @@ } | ||
| }); | ||
| places = data.searchPlaces?.items ?? []; | ||
| places = (data.searchPlaces?.items ?? []).map(normalizeSearchPlace); | ||
| total = data.searchPlaces?.count ?? places.length; | ||
@@ -180,7 +264,7 @@ } | ||
| const data = await graphql(GET_PLACE_BY_EXTERNAL_ID, { externalId: id }); | ||
| place = data.getPlaceByExternalId; | ||
| place = data.getPlaceByExternalId ? normalizeSearchPlace(data.getPlaceByExternalId) : null; | ||
| } | ||
| else { | ||
| const data = await graphql(GET_PLACE_BY_ID, { id }); | ||
| place = data.getPlaceById; | ||
| place = data.getPlaceById ? normalizeSearchPlace(data.getPlaceById) : null; | ||
| } | ||
@@ -259,3 +343,3 @@ if (!place) { | ||
| const data = await graphql(UPSERT_TRIP_PLAN_PLACE, { input }, { dryRun: opts.dryRun }); | ||
| const place = data.upsertTripPlanPlace; | ||
| const place = normalizeTripPlanPlace(data.upsertTripPlanPlace); | ||
| if (opts.json) { | ||
@@ -297,3 +381,3 @@ jsonOutput({ | ||
| const data = await graphql(GET_HIGHLIGHTED_TRIP_PLACES, { tripId: planId, category }); | ||
| const highlighted = data.highlightedTripPlaces ?? []; | ||
| const highlighted = (data.highlightedTripPlaces ?? []).map(normalizeHighlightedTripPlace); | ||
| if (opts.json) { | ||
@@ -343,3 +427,3 @@ jsonOutput({ | ||
| const data = await graphql(GET_TRIP_PLAN_PLACES, { tripPlanId: planId }); | ||
| const placesList = data.getTripPlanPlaces ?? []; | ||
| const placesList = (data.getTripPlanPlaces ?? []).map(normalizeTripPlanPlace); | ||
| if (opts.json) { | ||
@@ -346,0 +430,0 @@ jsonOutput({ |
+59
-117
| import chalk from "chalk"; | ||
| import { graphql } from "../api.js"; | ||
| import { getApiUrl } from "../config.js"; | ||
| import { GET_TRIP_PLAN_BASIC, CREATE_TRIP_PLAN_BASIC, CREATE_TRAVELLER_BRIEF, GET_TRAVELLERS_BRIEF, LIST_TRIP_PLAN_GOALS, DELETE_TRIP_PLAN_GOAL, } from "../queries.js"; | ||
| import { GET_TRIP_PLAN_BASIC, GET_TRAVELLERS_BRIEF, } from "../queries.js"; | ||
| import { validateDate, warnPastDate, validateIata, deriveBaseUrl, shellArg } from "../utils.js"; | ||
| import { progress, warn, fatal, jsonOutput } from "../output.js"; | ||
| import { CliError, CliErrorCode } from "../errors.js"; | ||
| import { resolveClient } from "./clients.js"; | ||
| import { scaffoldPlan, addTravellers, validateShapeFlags, selectGoalsToPrune, generateTripTitle } from "./scaffold.js"; | ||
| import { isInteractive, promptText } from "../prompt.js"; | ||
| export { validateShapeFlags, selectGoalsToPrune }; | ||
| export function buildClientHintFlags(opts) { | ||
| const parts = []; | ||
| if (opts.title) | ||
| parts.push(`--title ${shellArg(opts.title)}`); | ||
| if (opts.from) | ||
| parts.push(`--from ${shellArg(opts.from)}`); | ||
| if (opts.to) | ||
| parts.push(`--to ${shellArg(opts.to)}`); | ||
| if (opts.depart) | ||
| parts.push(`--depart ${shellArg(opts.depart)}`); | ||
| if (opts.return) | ||
| parts.push(`--return ${shellArg(opts.return)}`); | ||
| if (opts.hotel) | ||
| parts.push(`--hotel ${shellArg(opts.hotel)}`); | ||
| if (opts.travellers) | ||
| parts.push(`--travellers ${shellArg(opts.travellers)}`); | ||
| if (opts.oneWay) | ||
| parts.push("--one-way"); | ||
| if (opts.flightOnly) | ||
| parts.push("--flight-only"); | ||
| if (opts.hotelOnly) | ||
| parts.push("--hotel-only"); | ||
| return parts.join(" "); | ||
| } | ||
| export function parseDurationMinutes(duration) { | ||
@@ -39,65 +65,2 @@ if (!duration) | ||
| } | ||
| export function selectGoalsToPrune(goals, shape) { | ||
| const prune = new Map(); | ||
| const warnings = []; | ||
| if (shape.oneWay) { | ||
| const returnGoals = goals.filter(g => g.type === "Flight" && /return/i.test(g.name ?? "")); | ||
| if (returnGoals.length === 0) { | ||
| warnings.push("--one-way: no return-flight goal found to prune (scaffold may have changed). Inspect `plans goals <planId>` and remove it with `plans goal-remove <goalId> --force`."); | ||
| } | ||
| for (const g of returnGoals) | ||
| prune.set(g.id, g); | ||
| } | ||
| if (shape.flightOnly) { | ||
| const hotelGoals = goals.filter(g => g.type === "Hotel"); | ||
| if (hotelGoals.length === 0) { | ||
| warnings.push("--flight-only: no Hotel goal found to prune (scaffold may have changed). Inspect `plans goals <planId>`."); | ||
| } | ||
| for (const g of hotelGoals) | ||
| prune.set(g.id, g); | ||
| } | ||
| if (shape.hotelOnly) { | ||
| const flightish = goals.filter(g => g.type === "Flight" || g.type === "FlightJourney"); | ||
| if (flightish.length === 0) { | ||
| warnings.push("--hotel-only: no Flight/FlightJourney goals found to prune (scaffold may have changed). Inspect `plans goals <planId>`."); | ||
| } | ||
| for (const g of flightish) | ||
| prune.set(g.id, g); | ||
| } | ||
| return { prune: [...prune.values()], warnings }; | ||
| } | ||
| export function validateShapeFlags(opts) { | ||
| const anyShape = !!(opts.oneWay || opts.flightOnly || opts.hotelOnly); | ||
| if (!anyShape) | ||
| return; | ||
| if (opts.plan) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "Shape flags (--one-way/--flight-only/--hotel-only) only apply when scaffolding a NEW plan. For an existing plan, prune goals directly: `voyagier plans goals <planId>` then `voyagier plans goal-remove <goalId> --force`."); | ||
| } | ||
| if (opts.oneWay && opts.return) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "--one-way conflicts with --return. Drop one."); | ||
| } | ||
| if (opts.hotelOnly && opts.flightOnly) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "--hotel-only conflicts with --flight-only. Pick one."); | ||
| } | ||
| if (opts.hotelOnly && opts.oneWay) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "--hotel-only conflicts with --one-way (a hotel-only plan has no flights)."); | ||
| } | ||
| if (opts.hotelOnly && (opts.to || opts.from || opts.depart || opts.return)) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "--hotel-only conflicts with flight flags (--from/--to/--depart/--return). A hotel-only plan has no flights."); | ||
| } | ||
| if (opts.flightOnly && (opts.hotel || opts.checkin || opts.checkout)) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "--flight-only conflicts with hotel flags (--hotel/--checkin/--checkout). Drop one."); | ||
| } | ||
| } | ||
| function parseTravellers(names) { | ||
| return names.split(",") | ||
| .map(name => name.trim()) | ||
| .filter(Boolean) | ||
| .map(name => { | ||
| const parts = name.split(/\s+/); | ||
| if (parts.length === 1) | ||
| return { firstName: parts[0], lastName: parts[0] }; | ||
| return { firstName: parts.slice(0, -1).join(" "), lastName: parts[parts.length - 1] }; | ||
| }); | ||
| } | ||
| export function registerPlanTripCommand(program) { | ||
@@ -149,2 +112,3 @@ program | ||
| .option("--agent", "Output plain markdown for AI agents") | ||
| .option("--no-input", "Never prompt for missing input; fail instead (for scripts, agents, CI)") | ||
| .action(async (opts) => { | ||
@@ -155,3 +119,8 @@ const json = !!opts.json; | ||
| if (!opts.plan && !opts.title) { | ||
| fatal("--title is required when --plan is not provided."); | ||
| if (isInteractive(opts)) { | ||
| opts.title = await promptText("Trip title: ", { default: generateTripTitle(opts) }); | ||
| } | ||
| if (!opts.title) { | ||
| fatal("--title is required when --plan is not provided."); | ||
| } | ||
| } | ||
@@ -179,3 +148,11 @@ validateShapeFlags(opts); | ||
| } | ||
| const shape = { | ||
| oneWay: !!opts.oneWay, | ||
| flightOnly: !!opts.flightOnly, | ||
| hotelOnly: !!opts.hotelOnly, | ||
| }; | ||
| let plan; | ||
| let travellerIds = []; | ||
| let prunedGoals = []; | ||
| let pruneWarnings = []; | ||
| if (opts.plan) { | ||
@@ -186,25 +163,21 @@ if (!json && !agent) | ||
| plan = planData.tripPlan; | ||
| if (opts.travellers) { | ||
| travellerIds = await addTravellers(plan.id, opts.travellers, { quiet: json || agent }); | ||
| } | ||
| } | ||
| else { | ||
| const resolved = await resolveClient(opts.client); | ||
| if (resolved.autoResolved) { | ||
| process.stderr.write(`auto-resolved client: ${resolved.name} (${resolved.id})\n`); | ||
| } | ||
| if (!json && !agent) | ||
| progress("Creating trip plan..."); | ||
| const planInput = { clientId: resolved.id, title: opts.title }; | ||
| const planData = await graphql(CREATE_TRIP_PLAN_BASIC, { input: planInput }); | ||
| plan = planData.createTripPlan; | ||
| const scaffolded = await scaffoldPlan({ | ||
| client: opts.client, | ||
| title: opts.title, | ||
| travellers: opts.travellers, | ||
| shape, | ||
| quiet: json || agent, | ||
| interactive: isInteractive(opts), | ||
| clientHintFlags: buildClientHintFlags(opts), | ||
| }); | ||
| plan = scaffolded.plan; | ||
| travellerIds = scaffolded.travellerIds; | ||
| prunedGoals = scaffolded.prunedGoals; | ||
| pruneWarnings = scaffolded.pruneWarnings; | ||
| } | ||
| const travellers = []; | ||
| if (opts.travellers) { | ||
| if (!json && !agent) | ||
| progress("Adding travellers..."); | ||
| const parsed = parseTravellers(opts.travellers); | ||
| for (const t of parsed) { | ||
| const tData = await graphql(CREATE_TRAVELLER_BRIEF, { tripPlanId: plan.id, input: { firstName: t.firstName, lastName: t.lastName, declaredTravellerType: "Adult" } }); | ||
| travellers.push(tData.createTripPlanTraveller); | ||
| } | ||
| } | ||
| let travellerIds = travellers.map(t => t.id); | ||
| if (travellerIds.length === 0) { | ||
@@ -217,33 +190,2 @@ const tData = await graphql(GET_TRAVELLERS_BRIEF, { tripPlanId: plan.id }); | ||
| } | ||
| const prunedGoals = []; | ||
| const pruneWarnings = []; | ||
| const shape = { | ||
| oneWay: !!opts.oneWay, | ||
| flightOnly: !!opts.flightOnly, | ||
| hotelOnly: !!opts.hotelOnly, | ||
| }; | ||
| if (shape.oneWay || shape.flightOnly || shape.hotelOnly) { | ||
| if (!json && !agent) | ||
| progress("Pruning goals to match trip shape..."); | ||
| const goalsData = await graphql(LIST_TRIP_PLAN_GOALS, { tripPlanId: plan.id }); | ||
| const { prune, warnings } = selectGoalsToPrune(goalsData.tripPlanGoals ?? [], shape); | ||
| pruneWarnings.push(...warnings); | ||
| for (const g of prune) { | ||
| try { | ||
| const del = await graphql(DELETE_TRIP_PLAN_GOAL, { id: g.id }); | ||
| if (del.deleteTripPlanGoal === true) { | ||
| prunedGoals.push(g); | ||
| } | ||
| else { | ||
| pruneWarnings.push(`Server declined to delete goal "${g.name ?? g.id}" (${g.type}). Remove it manually: voyagier plans goal-remove ${shellArg(g.id)} --force`); | ||
| } | ||
| } | ||
| catch (err) { | ||
| const message = (err instanceof Error ? err.message : String(err)).replace(/\s+/g, " "); | ||
| pruneWarnings.push(`Failed to delete goal "${g.name ?? g.id}" (${g.type}): ${message}. Remove it manually: voyagier plans goal-remove ${shellArg(g.id)} --force`); | ||
| } | ||
| } | ||
| for (const w of pruneWarnings) | ||
| warn(w); | ||
| } | ||
| const baseUrl2 = deriveBaseUrl(getApiUrl()); | ||
@@ -250,0 +192,0 @@ const planUrl = `${baseUrl2}/plans/${plan.id}`; |
@@ -8,25 +8,36 @@ import chalk from "chalk"; | ||
| import { CliError, CliErrorCode } from "../../errors.js"; | ||
| import { resolveClient } from "../clients.js"; | ||
| import { scaffoldPlan, generateTripTitle } from "../scaffold.js"; | ||
| import { isInteractive, promptText } from "../../prompt.js"; | ||
| import { planUrl, typeIcon, chosenOption } from "./types.js"; | ||
| import { CREATE_TRIP_PLAN, GET_TRIP_PLANS, GET_TRIP_PLAN, GET_TRIP_PLAN_SUMMARY, UPDATE_TRIP_PLAN, GET_TRIP_PLAN_WITH_DESC, DELETE_TRIP_PLAN, } from "../../queries.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"; | ||
| export function registerCrudCommands(plans) { | ||
| plans | ||
| .command("create") | ||
| .description("Create a new trip plan") | ||
| .requiredOption("--title <title>", "Trip plan title") | ||
| .description("Create a new trip plan (alias of `plan-trip` — the full trip starter)") | ||
| .option("--title <title>", "Trip plan title; prompted when omitted at a TTY") | ||
| .option("--client <ref>", "Client id, email, or name. Omit to auto-resolve when exactly one ACTIVE client exists.") | ||
| .option("--json", "Output raw JSON") | ||
| .option("--dry-run", "Show the GraphQL query without executing") | ||
| .action(async (opts) => { | ||
| .option("--no-input", "Never prompt for missing input; fail instead (for scripts, agents, CI)") | ||
| .action(async (opts, command) => { | ||
| if (!opts.title) { | ||
| if (isInteractive(opts)) { | ||
| opts.title = await promptText("Trip title: ", { default: generateTripTitle({}) }); | ||
| } | ||
| if (!opts.title) { | ||
| command.error("error: required option '--title <title>' not specified", { | ||
| exitCode: 1, | ||
| code: "commander.missingMandatoryOptionValue", | ||
| }); | ||
| } | ||
| } | ||
| try { | ||
| const resolved = await resolveClient(opts.client); | ||
| if (resolved.autoResolved) { | ||
| process.stderr.write(chalk.dim(`auto-resolved client: ${resolved.name} (${resolved.id})\n`)); | ||
| } | ||
| const input = { | ||
| clientId: resolved.id, | ||
| const { plan } = await scaffoldPlan({ | ||
| client: opts.client, | ||
| title: opts.title, | ||
| }; | ||
| const data = await graphql(CREATE_TRIP_PLAN, { input }, { dryRun: opts.dryRun }); | ||
| const plan = data.createTripPlan; | ||
| dryRun: opts.dryRun, | ||
| interactive: isInteractive(opts), | ||
| clientHintFlags: opts.title ? `--title ${shellArg(opts.title)}` : undefined, | ||
| progress: false, | ||
| }); | ||
| if (opts.json) { | ||
@@ -45,3 +56,4 @@ const planSummary = await getPlanSummary(plan.id); | ||
| } | ||
| console.log(chalk.dim(`\n Next: voyagier travellers add --plan ${shellArg(plan.id)} --first <name> --last <name> --type ADULT`)); | ||
| console.log(chalk.dim(`\n Tip: voyagier plan-trip is the full trip starter (travellers, trip shape, first searches).`)); | ||
| console.log(chalk.dim(` Next: voyagier travellers add --plan ${shellArg(plan.id)} --first <name> --last <name> --type ADULT`)); | ||
| await printPlanFooter(plan.id); | ||
@@ -48,0 +60,0 @@ } |
@@ -42,4 +42,7 @@ import chalk from "chalk"; | ||
| const limit = parseInt(opts.limit, 10); | ||
| const data = await graphql(GET_COMMENTS, { itemId, limit }); | ||
| const comments = data.tripPlanItemComments; | ||
| if (!Number.isInteger(limit) || limit < 1) { | ||
| throw new CliError(CliErrorCode.VALIDATION, "--limit must be a positive integer."); | ||
| } | ||
| const data = await graphql(GET_COMMENTS, { itemId, limit, page: 1 }); | ||
| const comments = data.tripPlanItemComments.items; | ||
| if (opts.json) { | ||
@@ -46,0 +49,0 @@ process.stdout.write(JSON.stringify({ itemId, comments }, null, 2) + "\n"); |
@@ -16,2 +16,16 @@ import { printPlanFooter } from "../plan-footer.js"; | ||
| import { startSpinner } from "../spinner.js"; | ||
| import { isInteractive, promptText } from "../prompt.js"; | ||
| async function resolveDateOpt(current, opts, question, command) { | ||
| if (current) | ||
| return current; | ||
| if (isInteractive(opts)) { | ||
| const answer = await promptText(question); | ||
| if (answer) | ||
| return answer; | ||
| } | ||
| command.error("error: required option '--date <date>' not specified", { | ||
| exitCode: 1, | ||
| code: "commander.missingMandatoryOptionValue", | ||
| }); | ||
| } | ||
| export async function resolveOrCreateDecisionSelection(kind, goal, tripPlanId, createMutation, createResultKey, input, quiet, progress) { | ||
@@ -56,3 +70,3 @@ const existingId = resolveDecisionSelection(goal, kind); | ||
| } | ||
| throw new CliError(CliErrorCode.VALIDATION, '--plan <id> is required. Create one first:\n voyagier plans create --title "My Trip"'); | ||
| throw new CliError(CliErrorCode.VALIDATION, '--plan <id> is required. Create one first:\n voyagier plan-trip --client <id|name|email> --title "My Trip"'); | ||
| } | ||
@@ -185,3 +199,3 @@ function parseDurationMinutes(duration) { | ||
| .requiredOption("--to <code>", "Destination airport code (e.g., NRT)") | ||
| .requiredOption("--date <date>", "Departure date (YYYY-MM-DD)") | ||
| .option("--date <date>", "Departure date (YYYY-MM-DD); prompted when omitted at a TTY") | ||
| .option("--return <date>", "Return date (YYYY-MM-DD) for round-trip") | ||
@@ -194,3 +208,5 @@ .option("--max-stops <n>", "Maximum number of stops") | ||
| .option("--dry-run", "Show the GraphQL query without executing") | ||
| .action(async (opts) => { | ||
| .option("--no-input", "Never prompt for missing input; fail instead (for scripts, agents, CI)") | ||
| .action(async (opts, command) => { | ||
| opts.date = await resolveDateOpt(opts.date, opts, "Departure date (YYYY-MM-DD): ", command); | ||
| try { | ||
@@ -553,3 +569,3 @@ const quiet = !!(opts.json || opts.agent); | ||
| .requiredOption("--destination <place>", "Destination name (city or region)") | ||
| .requiredOption("--date <date>", "Travel date (YYYY-MM-DD)") | ||
| .option("--date <date>", "Travel date (YYYY-MM-DD); prompted when omitted at a TTY") | ||
| .option("--query <text>", "Free text search (e.g. 'snorkeling')") | ||
@@ -564,3 +580,5 @@ .option("--currency <code>", "Currency code", "USD") | ||
| .option("--verbose", "Show request details sent to the API") | ||
| .action(async (opts) => { | ||
| .option("--no-input", "Never prompt for missing input; fail instead (for scripts, agents, CI)") | ||
| .action(async (opts, command) => { | ||
| opts.date = await resolveDateOpt(opts.date, opts, "Travel date (YYYY-MM-DD): ", command); | ||
| try { | ||
@@ -567,0 +585,0 @@ validateDate(opts.date, "--date"); |
| import chalk from "chalk"; | ||
| import { credentialsExist, getUserContext, getApiUrl, saveUserContext } from "../config.js"; | ||
| import { graphql } from "../api.js"; | ||
| import { graphqlWithFieldFallback } from "../api.js"; | ||
| import { CliError, CliErrorCode, authFailedMessage } from "../errors.js"; | ||
@@ -12,2 +12,4 @@ import { deriveBaseUrl, shellArg } from "../utils.js"; | ||
| }; | ||
| const ME_QUERY_WITH_ROLES = `{ me { id firstName lastName email name dateOfBirth gender isAdmin isTravelAdvisor isTripPlanner canMintPats passport { last4 issueCountry nationalityCountry expirationDate } } }`; | ||
| const ME_QUERY_LEGACY = `{ me { id firstName lastName email name dateOfBirth gender passport { last4 issueCountry nationalityCountry expirationDate } } }`; | ||
| export function registerWhoamiCommand(program) { | ||
@@ -27,3 +29,3 @@ program | ||
| try { | ||
| const data = await graphql(`{ me { id firstName lastName email name dateOfBirth gender passport { last4 issueCountry nationalityCountry expirationDate } } }`); | ||
| const data = await graphqlWithFieldFallback(ME_QUERY_WITH_ROLES, ME_QUERY_LEGACY, /isTripPlanner|canMintPats/); | ||
| me = data.me; | ||
@@ -55,2 +57,6 @@ } | ||
| ctx.passport = me.passport ?? undefined; | ||
| ctx.isAdmin = me.isAdmin ?? undefined; | ||
| ctx.isTravelAdvisor = me.isTravelAdvisor ?? undefined; | ||
| ctx.isTripPlanner = me.isTripPlanner ?? undefined; | ||
| ctx.canMintPats = me.canMintPats ?? undefined; | ||
| } | ||
@@ -65,2 +71,6 @@ else { | ||
| passport: me.passport ?? undefined, | ||
| isAdmin: me.isAdmin ?? undefined, | ||
| isTravelAdvisor: me.isTravelAdvisor ?? undefined, | ||
| isTripPlanner: me.isTripPlanner ?? undefined, | ||
| canMintPats: me.canMintPats ?? undefined, | ||
| homeAirports: [], | ||
@@ -81,2 +91,20 @@ preferredCabin: "economy", | ||
| const env = baseUrl.includes("dev.") ? "dev" : baseUrl.includes("staging.") ? "staging" : "prod"; | ||
| const roleFlags = {}; | ||
| if (profile.isAdmin !== undefined) | ||
| roleFlags.isAdmin = profile.isAdmin; | ||
| if (profile.isTravelAdvisor !== undefined) | ||
| roleFlags.isTravelAdvisor = profile.isTravelAdvisor; | ||
| if (profile.isTripPlanner !== undefined) | ||
| roleFlags.isTripPlanner = profile.isTripPlanner; | ||
| if (profile.canMintPats !== undefined) | ||
| roleFlags.canMintPats = profile.canMintPats; | ||
| const roleLabels = []; | ||
| if (profile.isAdmin) | ||
| roleLabels.push("Admin"); | ||
| if (profile.isTravelAdvisor) | ||
| roleLabels.push("Travel Advisor"); | ||
| if (profile.isTripPlanner) | ||
| roleLabels.push("Trip Planner"); | ||
| if (profile.canMintPats) | ||
| roleLabels.push("API Access"); | ||
| if (opts.json) { | ||
@@ -91,2 +119,3 @@ process.stdout.write(JSON.stringify({ | ||
| hasPassport: !!profile.passport, | ||
| ...roleFlags, | ||
| apiUrl, | ||
@@ -105,2 +134,5 @@ environment: env, | ||
| console.log(chalk.dim(` ${infoParts.join(" · ")}`)); | ||
| if (roleLabels.length > 0) { | ||
| console.log(chalk.dim(" Role: ") + chalk.cyan(roleLabels.join(" + "))); | ||
| } | ||
| const ready = []; | ||
@@ -107,0 +139,0 @@ const missing = []; |
+4
-3
@@ -5,2 +5,3 @@ #!/usr/bin/env node | ||
| import { trackCommand, getTraceId, isTelemetryEnabled, telemetryErrorCode } from "./telemetry.js"; | ||
| import { gracefulExit } from "./exit.js"; | ||
| import { credentialsExist } from "./config.js"; | ||
@@ -51,3 +52,3 @@ import { CliError } from "./errors.js"; | ||
| console.log(); | ||
| process.exit(0); | ||
| await gracefulExit(0); | ||
| } | ||
@@ -72,3 +73,3 @@ try { | ||
| } | ||
| process.exit(1); | ||
| await gracefulExit(1); | ||
| } | ||
@@ -78,4 +79,4 @@ else { | ||
| process.stderr.write(stack + "\n"); | ||
| process.exit(2); | ||
| await gracefulExit(2); | ||
| } | ||
| } |
+73
-56
@@ -161,7 +161,2 @@ export const GET_CART = ` | ||
| `; | ||
| export const CREATE_TRIP_PLAN_BASIC = ` | ||
| mutation CreateTripPlan($input: CreateTripPlanInput!) { | ||
| createTripPlan(input: $input) { id title startDate endDate } | ||
| } | ||
| `; | ||
| export const GET_TRIP_PLANS = ` | ||
@@ -247,7 +242,10 @@ query TripPlans($page: Int, $limit: Int) { | ||
| export const GET_COMMENTS = ` | ||
| query Comments($itemId: String!, $limit: Int) { | ||
| tripPlanItemComments(itemId: $itemId, limit: $limit) { | ||
| id text parentCommentId | ||
| author { id firstName lastName } | ||
| replies { id text author { firstName lastName } } | ||
| query Comments($itemId: String!, $limit: Int!, $page: Int!) { | ||
| tripPlanItemComments(itemId: $itemId, limit: $limit, page: $page) { | ||
| count | ||
| items { | ||
| id text parentCommentId | ||
| author { id firstName lastName } | ||
| replies { id text author { firstName lastName } } | ||
| } | ||
| } | ||
@@ -496,3 +494,4 @@ } | ||
| tripPlan { id title } | ||
| tripPlanItem { id title } | ||
| tripPlanPlace { id name } | ||
| tripPlanProduct { id name } | ||
| } | ||
@@ -505,3 +504,4 @@ } | ||
| tripPlan { id title } | ||
| tripPlanItem { id title } | ||
| tripPlanPlace { id name } | ||
| tripPlanProduct { id name } | ||
| } }`; | ||
@@ -519,3 +519,4 @@ export const REFRESH_BOOKING_RECORD = ` | ||
| tripPlan { id title } | ||
| tripPlanItem { id title } | ||
| tripPlanPlace { id name } | ||
| tripPlanProduct { id name } | ||
| travellers { firstName lastName } | ||
@@ -570,2 +571,27 @@ } | ||
| `; | ||
| export const LIST_TRIP_PLAN_CLIENTS_WITH_SELF = ` | ||
| query TripPlanClients($page: Int!, $limit: Int!) { | ||
| tripPlanClients(page: $page, limit: $limit) { | ||
| count | ||
| page | ||
| limit | ||
| items { | ||
| id | ||
| name | ||
| phone | ||
| avatarUrl | ||
| description | ||
| clientType | ||
| status | ||
| isSelf | ||
| createdAt | ||
| updatedAt | ||
| } | ||
| count | ||
| page | ||
| limit | ||
| } | ||
| } | ||
| `; | ||
| export const GET_TRIP_PLAN_CLIENT = ` | ||
@@ -818,11 +844,11 @@ query TripPlanClient($id: String!) { | ||
| location { | ||
| latitude | ||
| longitude | ||
| coordinates | ||
| type | ||
| } | ||
| address { | ||
| street | ||
| city | ||
| state | ||
| streetAddress | ||
| addressLocality | ||
| addressRegion | ||
| postalCode | ||
| country | ||
| addressCountry | ||
| } | ||
@@ -849,22 +875,15 @@ country { | ||
| name | ||
| description | ||
| geocodes { | ||
| main { | ||
| latitude | ||
| longitude | ||
| } | ||
| } | ||
| location { | ||
| latitude | ||
| longitude | ||
| } | ||
| address { | ||
| street | ||
| city | ||
| state | ||
| postalCode | ||
| address | ||
| locality | ||
| region | ||
| postcode | ||
| country | ||
| } | ||
| country { | ||
| id | ||
| name | ||
| } | ||
| locality { | ||
| id | ||
| name | ||
| } | ||
| } | ||
@@ -880,11 +899,11 @@ } | ||
| location { | ||
| latitude | ||
| longitude | ||
| coordinates | ||
| type | ||
| } | ||
| address { | ||
| street | ||
| city | ||
| state | ||
| streetAddress | ||
| addressLocality | ||
| addressRegion | ||
| postalCode | ||
| country | ||
| addressCountry | ||
| } | ||
@@ -909,11 +928,11 @@ country { | ||
| location { | ||
| latitude | ||
| longitude | ||
| coordinates | ||
| type | ||
| } | ||
| address { | ||
| street | ||
| city | ||
| state | ||
| streetAddress | ||
| addressLocality | ||
| addressRegion | ||
| postalCode | ||
| country | ||
| addressCountry | ||
| } | ||
@@ -944,8 +963,7 @@ country { | ||
| iataCode | ||
| image | ||
| url | ||
| placeTimezone | ||
| location { | ||
| latitude | ||
| longitude | ||
| coordinates | ||
| type | ||
| } | ||
@@ -966,4 +984,4 @@ } | ||
| location { | ||
| latitude | ||
| longitude | ||
| coordinates | ||
| type | ||
| } | ||
@@ -987,8 +1005,7 @@ } | ||
| iataCode | ||
| image | ||
| url | ||
| placeTimezone | ||
| location { | ||
| latitude | ||
| longitude | ||
| coordinates | ||
| type | ||
| } | ||
@@ -995,0 +1012,0 @@ } |
+27
-2
@@ -52,2 +52,23 @@ import { randomUUID, randomBytes } from "crypto"; | ||
| } | ||
| const pendingSends = new Set(); | ||
| export function __resetPendingSends() { | ||
| pendingSends.clear(); | ||
| } | ||
| export async function flushTelemetry(timeoutMs = 250) { | ||
| if (pendingSends.size === 0) | ||
| return; | ||
| const drained = Promise.allSettled([...pendingSends]).then(() => undefined); | ||
| let timer; | ||
| const capped = new Promise((resolve) => { | ||
| timer = setTimeout(resolve, timeoutMs); | ||
| timer.unref?.(); | ||
| }); | ||
| try { | ||
| await Promise.race([drained, capped]); | ||
| } | ||
| finally { | ||
| if (timer) | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
| export function trackCommand(event) { | ||
@@ -74,3 +95,3 @@ if (!isTelemetryEnabled()) | ||
| }; | ||
| fetch("https://http-intake.logs.datadoghq.com/api/v2/logs", { | ||
| const send = fetch("https://http-intake.logs.datadoghq.com/api/v2/logs", { | ||
| method: "POST", | ||
@@ -83,4 +104,8 @@ headers: { | ||
| signal: AbortSignal.timeout(3000), | ||
| }).catch(() => { | ||
| }).then(() => undefined, () => { | ||
| }); | ||
| pendingSends.add(send); | ||
| void send.finally(() => { | ||
| pendingSends.delete(send); | ||
| }); | ||
| } | ||
@@ -87,0 +112,0 @@ let _cliVersion = null; |
+6
-6
| { | ||
| "name": "@voyagier/cli", | ||
| "version": "2.14.0", | ||
| "version": "2.15.0", | ||
| "mcpName": "com.voyagier/cli", | ||
@@ -8,3 +8,3 @@ "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).", | ||
| "bin": { | ||
| "voyagier": "./dist/index.js" | ||
| "voyagier": "dist/index.js" | ||
| }, | ||
@@ -46,7 +46,7 @@ "scripts": { | ||
| "dependencies": { | ||
| "@modelcontextprotocol/sdk": "^1.29.0", | ||
| "chalk": "^5.6.2", | ||
| "@modelcontextprotocol/sdk": "^1.30.0", | ||
| "chalk": "^6.0.0", | ||
| "commander": "^15.0.0", | ||
| "graphql": "^17.0.2", | ||
| "zod": "^3.25.76" | ||
| "zod": "^4.4.3" | ||
| }, | ||
@@ -57,3 +57,3 @@ "devDependencies": { | ||
| "@types/jest": "^30.0.0", | ||
| "@types/node": "^26.1.1", | ||
| "@types/node": "^26.1.2", | ||
| "jest": "^30.4.2", | ||
@@ -60,0 +60,0 @@ "ts-jest": "^29.4.12", |
+13
-2
| # @voyagier/cli | ||
| [](https://github.com/Voyagier-Travel/voyagier-cli/actions/workflows/tests-and-coverage.yaml) | ||
| [](https://www.npmjs.com/package/@voyagier/cli) | ||
| [](https://www.npmjs.com/package/@voyagier/cli) | ||
| [](https://github.com/Voyagier-Travel/voyagier-cli#mcp-server) | ||
| [](https://github.com/Voyagier-Travel/voyagier-cli/blob/main/LICENSE) | ||
@@ -10,3 +12,3 @@ Search flights, book activities, manage trip plans — from your terminal. Everything syncs to [voyagier.com](https://voyagier.com). | ||
| ```bash | ||
| npm install -g @voyagier/cli | ||
| npm install -g voyagier # or the canonical package: @voyagier/cli | ||
| voyagier auth set-token <your-token> | ||
@@ -16,2 +18,4 @@ voyagier doctor # confirm auth + schema reachability | ||
| `voyagier` is a convenience alias that tracks the latest compatible `@voyagier/cli` release (currently `^2`). Pinning an exact version? Use the canonical package: `npm install -g @voyagier/cli@<version>`. | ||
| No install permissions (sandboxed agent, CI)? Every command works zero-install via `npx`: | ||
@@ -188,4 +192,11 @@ | ||
| Voyagier is currently invite-based. Advisors and agent builders can request access at [voyagier.com](https://voyagier.com) — once invited, you can self-serve a personal access token from your account. | ||
| Voyagier access is granted, not open signup — **request access at [voyagier.com/agents](https://voyagier.com/agents)**. That's the gate for advisors, trip-planner customers, and agent builders alike. | ||
| 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: | ||
| - **Travel advisors** — manage a book of clients (`voyagier clients`); plans are created against a client (`--client`). | ||
| - **Trip planners** — paying customers planning their own travel. Just run `voyagier plan-trip` — no client setup, no `--client` flag. (`voyagier whoami` shows your tier.) | ||
| Non-admin tokens expire (90 days max, 30 by default) — mint a fresh one when yours lapses. | ||
| > **Tip:** prefer `voyagier login` (interactive prompt) over `voyagier auth set-token <token>` — it keeps your token out of shell history. For scripts, use the `VOYAGIER_TOKEN` env var. | ||
@@ -192,0 +203,0 @@ |
+1
-1
| --- | ||
| name: voyagier-cli | ||
| version: 2.11.0 | ||
| version: 2.14.0 | ||
| description: "Book real travel from your terminal — search flights, hotels & activities, plan trips, and check out with a price-gated booking. For AI agents and travel advisors." | ||
@@ -5,0 +5,0 @@ metadata: |
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.
1208003
1.51%71
4.41%18831
2.18%204
5.7%26
4%+ Added
+ Added
- Removed
- Removed
Updated
Updated