import { readFileSync, writeFileSync } from "fs"; import path from "path"; import { generateTemplateIdentifier } from "@xo-cash/engine"; import { binToHex, hexToBin } from "@bitauth/libauth"; import { bold, dim, formatObject } from "../utils.js"; import type { CommandDependencies, CommandIO } from "./types.js"; import { CommandError } from "./types.js"; import type { Invitation } from "../../services/invitation.js"; import { resolveProvidedLockingBytecodeHex, mapUnspentOutputsToSelectable, autoSelectGreedyUtxos, hasMissingRequirements, } from "../../utils/invitation-flow.js"; import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; import type { XOInvitation } from "@xo-cash/types"; import { resolveTemplate } from "../utils.js"; const DEFAULT_FEE = 500n; const DUST_THRESHOLD = 546n; /** * Serializes an invitation to pretty-printed JSON for file export. */ const formatInvitationForFile = ( invitation: XOInvitation, indent = 2, ): string => JSON.stringify(JSON.parse(serializeInvitation(invitation)), null, indent); /** * Result of parsing CLI options into inputs and outputs for an append call. * A `null` return signals a fatal error that was already logged to stderr. */ interface BuildAppendResult { inputs: { outpointTransactionHash: Uint8Array; outpointIndex: number }[]; outputs: any[]; } /** * Extracts invitation variables from CLI option flags. * Keys starting with "var" are treated as variables — the prefix is stripped * and the next character is lowercased to reconstruct the camelCase identifier. * * When a `-role` flag is present the role identifier is attached to every * variable so the engine stores them under the correct role-level requirements. * * @example `-var-requested-satoshis 1000 -role sender` → `{ variableIdentifier: "requestedSatoshis", value: "1000", roleIdentifier: "sender" }` */ function parseVariablesFromOptions( options: Record, ): { variableIdentifier: string; value: string; roleIdentifier?: string }[] { const roleIdentifier = options["role"]; // Parse the variables from the options by checking if its starts with "var" return Object.entries(options) .filter(([key]) => key.startsWith("var")) .map(([key, value]) => ({ variableIdentifier: key.substring(3, 4).toLowerCase() + key.substring(4), value, ...(roleIdentifier ? { roleIdentifier } : {}), })); } /** * Parses CLI options into the inputs and outputs needed for an invitation * append call. Shared by both `create` and `append` so the same flags * (`--add-input`, `--add-output`, `--auto-inputs`, `-role`) work in either. * * Variables should already be committed to the invitation before calling this * so that `getSatsOut()` can resolve variable-dependent output values for the * automatic change calculation. * * @param deps - Command dependencies (engine, logger, etc.) * @param invitation - The invitation instance (variables should already be committed). * @param options - Parsed CLI option flags. * @returns The structured params, or `null` when a fatal error was printed. */ async function buildAppendParams( deps: CommandDependencies, invitation: Invitation, options: Record, ): Promise { // --- Inputs --- // Accepts comma-separated : pairs via --add-input, // OR automatic selection via --auto-inputs. let inputs: { outpointTransactionHash: Uint8Array; outpointIndex: number }[] = []; if (options["autoInputs"] === "true") { // Auto-select UTXOs using the greedy algorithm from invitation-flow. const suitableResources = await invitation.findSuitableResources(); const selectable = mapUnspentOutputsToSelectable(suitableResources); // Get the required sats out with the default fee const requiredWithFee = (await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE; autoSelectGreedyUtxos(selectable, requiredWithFee); // Get the inputs from the selectable UTXOs inputs = selectable .filter((u) => u.selected) .map((u) => ({ outpointTransactionHash: hexToBin(u.outpointTransactionHash), outpointIndex: u.outpointIndex, })); // If no inputs are found, print a message and return null if (inputs.length === 0) { deps.io.err("No suitable UTXOs found for auto-input selection."); return null; } deps.io.verbose(`Auto-selected ${inputs.length} input(s)`); } // If the add input option is provided, parse the inputs from the options else if (options["addInput"]) { inputs = options["addInput"].split(",").map((entry) => { const separatorIndex = entry.lastIndexOf(":"); if (separatorIndex === -1) { throw new Error( `Invalid input format "${entry}". Expected : (e.g. abc123:0)`, ); } // Get the tx hash and vout from the entry const txHash = entry.substring(0, separatorIndex); const vout = parseInt(entry.substring(separatorIndex + 1), 10); // If the tx hash or vout is not a string or isNaN, print a message and throw an error if (!txHash || isNaN(vout)) { throw new Error( `Invalid input format "${entry}". Expected : (e.g. abc123:0)`, ); } return { outpointTransactionHash: hexToBin(txHash), outpointIndex: vout, }; }); } deps.io.verbose( `Inputs: ${formatObject(inputs.map((i) => ({ txHash: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })))}`, ); // --- Outputs --- // When --add-output is provided, use those identifiers explicitly. // Otherwise, auto-discover all required outputs from the template so the // user doesn't have to name them manually. const roleIdentifier = options["role"]; let outputIdentifiers: string[] = []; if (options["addOutput"]) { outputIdentifiers = options["addOutput"].split(","); } else { // Pull every output identifier the template requires (top-level + role-specific). const requirements = await invitation.getRequirements(); const discovered = new Set(); for (const id of requirements.outputs ?? []) discovered.add(id); if (requirements.roles) { for (const role of Object.values(requirements.roles)) { for (const id of role.outputs ?? []) discovered.add(id); } } outputIdentifiers = [...discovered]; if (outputIdentifiers.length > 0) { deps.io.verbose( `Auto-discovered output(s) from template: ${outputIdentifiers.join(", ")}`, ); } } // Build a variable-values map from all committed variables so // resolveProvidedLockingBytecodeHex can resolve outputs whose locking // script depends on a variable (e.g. ). const variableValuesByIdentifier: Record = {}; for (const commit of invitation.data.commits) { for (const v of commit.data?.variables ?? []) { if (v.variableIdentifier && typeof v.value === "string") { variableValuesByIdentifier[v.variableIdentifier] = v.value; } } } // Get the template from the engine const template = await deps.app.engine.getTemplate( invitation.data.templateIdentifier, ); // Get the outputs from the template const outputs: any[] = await Promise.all( outputIdentifiers.map(async (outputId) => { // Try variable-based resolution first (e.g. sendSatoshis → recipientLockingscript) const providedHex = template ? resolveProvidedLockingBytecodeHex( template, outputId, variableValuesByIdentifier, ) : undefined; const lockingBytecodeHex = providedHex ?? (await invitation.generateLockingBytecode(outputId, roleIdentifier)); deps.io.verbose( `Locking bytecode for output "${outputId}": ${lockingBytecodeHex}`, ); return { outputIdentifier: outputId, lockingBytecode: new Uint8Array(Buffer.from(lockingBytecodeHex, "hex")), }; }), ); deps.io.verbose( `Outputs: ${formatObject(outputs.map((o) => o.outputIdentifier))}`, ); // --- Auto change output --- // When inputs are provided, look up each UTXO's value, compute the // required sats, and return the excess minus fees back to the user. if (inputs.length > 0) { const allUtxos = await deps.app.engine.listUnspentOutputsData(); const utxoMap = new Map( allUtxos.map((u) => [ `${u.outpointTransactionHash}:${u.outpointIndex}`, u, ]), ); // Sum the total input sats let totalInputSats = 0n; // Iterate through the inputs and sum the valueSatoshis for (const input of inputs) { // Get the tx hash hex const txHashHex = binToHex(input.outpointTransactionHash); // Get the utxo from the utxo map const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`); if (!utxo) { // If the utxo is not found, print a message and return null deps.io.err( `UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`, ); return null; } // Sum the valueSatoshis totalInputSats += BigInt(utxo.valueSatoshis); } deps.io.verbose(`Total input value: ${totalInputSats} satoshis`); // Get the required sats out const requiredSats = await invitation.getSatsOut(); deps.io.verbose(`Required output value: ${requiredSats} satoshis`); // Get the change amount by subtracting the required sats out from the total input sats and the default fee const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE; deps.io.verbose( `Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`, ); // If the change amount is less than 0, print a message and return null if (changeAmount < 0n) { deps.io.err( `Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`, ); return null; } // If the change amount is greater than or equal to the dust threshold, add the change output if (changeAmount >= DUST_THRESHOLD) { outputs.push({ valueSatoshis: changeAmount }); deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`); } // If the change amount is greater than 0, print a message else if (changeAmount > 0n) { deps.io.out( `Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`, ); } } return { inputs, outputs }; } /** * Prints the help message for the invitation command */ export const printInvitationHelp = (io: CommandIO): void => { io.out( ` ${bold("Usage:")} xo-cli invitation ${bold("Sub-commands:")} - create ${dim("Create a new invitation")} - append ${dim("Add variables/outputs to an invitation")} - sign ${dim("Sign an invitation")} - broadcast ${dim("Broadcast an invitation")} - requirements ${dim("Show requirements for an invitation")} - import ${dim("Import an invitation from a file")} - export [output-file] ${dim("Export an invitation to stdout or a file")} - inspect ${dim("Inspect an invitation")} - list ${dim("List all invitations")} ${bold("Export options:")} -o --output ${dim("Output filename for the exported invitation")} ${bold("Create / Append options:")} -var- ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")} --add-input ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")} --add-output ${dim("Override output(s) — omit to auto-discover from template")} --auto-inputs ${dim("Automatically select UTXOs as inputs")} -role ${dim("Role for output bytecode generation (fallback)")} --sign ${dim("Auto-sign after all requirements are satisfied")} --broadcast ${dim("Auto-broadcast after signing (implies --sign)")} ${dim("When inputs are provided, a change output is automatically added if the")} ${dim("input total exceeds the required amount + fee.")} `, ); }; /** * Result data returned by invitation commands on success. */ export type InvitationCommandResult = { invitationIdentifier?: string; txHash?: string; count?: number; outputFile?: string; templateName?: string; actionIdentifier?: string; status?: string; entities?: { entityIdentifier: string; roles: (string | undefined)[] }[]; inputs?: unknown[]; outputs?: unknown[]; variables?: unknown[]; }; /** * Handles the invitation export command. * Throws CommandError on failure, returns result data on success. * @param deps - The command dependencies. * @param args - Positional args after "export", e.g. ["invitation-id"] or ["invitation-id", "invitation.json"]. * @param options - Parsed option flags. */ export const handleInvitationExportCommand = async ( deps: CommandDependencies, args: string[], options: Record, ): Promise<{ invitationIdentifier?: string; outputFile?: string }> => { const invitationIdentifier = args[0]; deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`); if (!invitationIdentifier) { deps.io.verbose("No invitation identifier provided"); printInvitationHelp(deps.io); throw new CommandError( "invitation.export.identifier_missing", "No invitation identifier provided", ); } const invitation = deps.app.invitations.find( (candidate) => candidate.data.invitationIdentifier === invitationIdentifier, ); if (!invitation) { deps.io.err(`Invitation not found: ${invitationIdentifier}`); throw new CommandError( "invitation.export.not_found", `Invitation not found: ${invitationIdentifier}`, ); } const serializedInvitation = serializeInvitation(invitation.data); const outputFile = options["output"] ?? args[1]; if (!outputFile) { deps.io.out(serializedInvitation); return { invitationIdentifier }; } const outputPath = path.resolve(process.cwd(), outputFile); try { writeFileSync(outputPath, serializedInvitation); } catch (error) { throw new CommandError( "invitation.export.write_failed", `Failed to export invitation to file: ${outputPath} (${error instanceof Error ? error.message : "unknown error"})`, ); } deps.io.out(`Invitation exported to: ${outputPath}`); return { invitationIdentifier, outputFile: outputPath }; }; /** * Handles the invitation command. * Throws CommandError on failure, returns result data on success. * @param deps - The command dependencies. * @param args - Positional args after the command name, e.g. ["create", "template.json", "action-id"]. * @param options - Parsed option flags, e.g. { varRequestedSatohis: "1000", role: "receiver" }. */ export const handleInvitationCommand = async ( deps: CommandDependencies, args: string[], options: Record, ): Promise => { const subCommand = args[0]; deps.io.verbose(`Invitation sub-command: ${subCommand}`); // If there was no subcommand provided, print the help message and throw an error if (!subCommand) { deps.io.verbose("No sub-command provided"); printInvitationHelp(deps.io); throw new CommandError( "invitation.subcommand.missing", "No sub-command provided", ); } // Switch statement to handle the different subcommands switch (subCommand) { case "create": { // Get the template query and action identifier from the arguments const templateQuery = args[1]; const actionIdentifier = args[2]; deps.io.verbose( `Template query: ${templateQuery}, action identifier: ${actionIdentifier}`, ); // If they didnt provide us with a template query or action identifier, print the help message and throw an error // TODO: Should probably print a specific help message for this command? if (!templateQuery || !actionIdentifier) { deps.io.verbose("No template file or action identifier provided"); printInvitationHelp(deps.io); throw new CommandError( "invitation.create.arguments_missing", "No template file or action identifier provided", ); } // Resolve the template, this will check both filepath and identifier. Because we are flexible here, we will need to generate the identifier again after const template = await resolveTemplate(deps, templateQuery); const templateIdentifier = generateTemplateIdentifier(template); // Create an XOInvitation. We will convert this into our own invitation instance afterwards const rawInvitation = await deps.app.engine.createInvitation({ templateIdentifier, actionIdentifier, }); deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`); // Create our own invitation instance out of the raw XOInvitation. This will also initate the SSE Session const invitationInstance = await deps.app.createInvitation(rawInvitation); deps.io.verbose( `Invitation instance created: ${formatObject(invitationInstance.data)}`, ); // Read the variables that were passed in via `-var- ` const variables = parseVariablesFromOptions(options); deps.io.verbose(`Variables: ${formatObject(variables)}`); if (variables.length > 0) { await invitationInstance.addVariables(variables); } // Build the parameters for the append call. This will resolve the inputs and outputs for the invitation. const params = await buildAppendParams(deps, invitationInstance, options); if (!params) { throw new CommandError( "invitation.create.append_params_failed", "Failed to build append parameters", ); } // Append the inputs and outputs to the invitation const { inputs, outputs } = params; deps.io.verbose(`Inputs: ${formatObject(inputs)}`); deps.io.verbose(`Outputs: ${formatObject(outputs)}`); if (inputs.length > 0 || outputs.length > 0) { await invitationInstance.append({ inputs, outputs }); } // Write the invitation to a file in the working directory // TODO: Support the -o flag to specify the output path const invitationFilePath = `${deps.paths.workingDir}/inv-${invitationInstance.data.invitationIdentifier}.json`; deps.io.verbose(`Invitation file path: ${invitationFilePath}`); writeFileSync( invitationFilePath, formatInvitationForFile(invitationInstance.data), ); deps.io.out( `Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`, ); // Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles. const missingRequirements = await invitationInstance.getMissingRequirements(); const hasMissing = hasMissingRequirements(missingRequirements.templateRequirements) || missingRequirements.inputsMissingSignatures.length > 0; deps.io.verbose( `Missing requirements: ${formatObject(missingRequirements)}`, ); deps.io.verbose(`Has missing requirements: ${hasMissing}`); // If there are missing requirements, print them out if (hasMissing) { deps.io.out(`\n${bold("Remaining requirements:")}`); deps.io.out(formatObject(missingRequirements)); } else { // If there are no missing requirements, sign the invitation if the user has requested it const shouldSign = options["sign"] === "true" || options["broadcast"] === "true"; const shouldBroadcast = options["broadcast"] === "true"; deps.io.verbose(`Should sign: ${shouldSign}`); deps.io.verbose(`Should broadcast: ${shouldBroadcast}`); // Sign the invitation if the user has requested it if (shouldSign) { await invitationInstance.sign(); deps.io.out( `Invitation signed: ${invitationInstance.data.invitationIdentifier}`, ); } // Broadcast the transaction if the user has requested it if (shouldBroadcast) { const txHash = await invitationInstance.broadcast(); deps.io.out(`Transaction broadcast: ${bold(txHash)}`); } else if (!shouldSign) { deps.io.out( `\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationInstance.data.invitationIdentifier}`, ); } } // Return the invitation identifier return { invitationIdentifier: invitationInstance.data.invitationIdentifier, }; } case "append": { // Get the invitation identifier from the arguments const invitationIdentifier = args[1]; deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`); // If they didnt provide us with an invitation identifier, print the help message and throw an error // TODO: Should probably print a specific help message for this command? if (!invitationIdentifier) { deps.io.verbose("No invitation identifier provided"); printInvitationHelp(deps.io); throw new CommandError( "invitation.append.identifier_missing", "No invitation identifier provided", ); } // Find the invitation instance in our list of invitations const invitation = deps.app.invitations.find( (inv) => inv.data.invitationIdentifier === invitationIdentifier, ); // If the invitation is not found, print an error and throw an error if (!invitation) { deps.io.err(`Invitation not found: ${invitationIdentifier}`); throw new CommandError( "invitation.append.not_found", `Invitation not found: ${invitationIdentifier}`, ); } deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`); // Parse the variables that were passed in via `-var- ` const variables = parseVariablesFromOptions(options); deps.io.verbose(`Variables to append: ${formatObject(variables)}`); if (variables.length > 0) { await invitation.addVariables(variables); } // Build the parameters for the append call. This will resolve the inputs and outputs for the invitation. const params = await buildAppendParams(deps, invitation, options); if (!params) { throw new CommandError( "invitation.append.params_failed", "Failed to build append parameters", ); } // If there are no variables, inputs, or outputs, print an error and throw an error const { inputs, outputs } = params; if ( variables.length === 0 && inputs.length === 0 && outputs.length === 0 ) { const error = "Nothing to append. Provide variables (-var- ), inputs (--add-input :), or outputs (--add-output )."; deps.io.err(error); throw new CommandError("invitation.append.empty", error); } // Append the inputs and outputs to the invitation if (inputs.length > 0 || outputs.length > 0) { await invitation.append({ inputs, outputs }); } deps.io.verbose(`Invitation appended: ${formatObject(invitation.data)}`); deps.io.out(`Invitation appended: ${invitationIdentifier}`); // Write the invitation to a file in the working directory // TODO: Support the -o flag to specify the output path const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`; writeFileSync( invitationFilePath, formatInvitationForFile(invitation.data), ); deps.io.out( `Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`, ); // Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles. const missingRequirements = await invitation.getMissingRequirements(); const hasMissing = hasMissingRequirements(missingRequirements.templateRequirements) || missingRequirements.inputsMissingSignatures.length > 0; // If there are missing requirements, print them out if (hasMissing) { deps.io.out(`\n${bold("Remaining requirements:")}`); deps.io.out(formatObject(missingRequirements)); } else { // If there are no missing requirements, sign the invitation if the user has requested it const shouldSign = options["sign"] === "true" || options["broadcast"] === "true"; const shouldBroadcast = options["broadcast"] === "true"; // Sign the invitation if the user has requested it if (shouldSign) { await invitation.sign(); deps.io.out(`Invitation signed: ${invitationIdentifier}`); } // Broadcast the transaction if the user has requested it if (shouldBroadcast) { const txHash = await invitation.broadcast(); deps.io.out(`Transaction broadcast: ${bold(txHash)}`); } else if (!shouldSign) { deps.io.out( `\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationIdentifier}`, ); } } return { invitationIdentifier }; } case "sign": { // Get the invitation identifier from the arguments const invitationIdentifier = args[1]; deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`); // If they didnt provide us with an invitation identifier, print the help message and throw an error // TODO: Should probably print a specific help message for this command? if (!invitationIdentifier) { deps.io.verbose("No invitation identifier provided"); printInvitationHelp(deps.io); throw new CommandError( "invitation.sign.identifier_missing", "No invitation identifier provided", ); } // Find the invitation instance in our list of invitations const invitation = deps.app.invitations.find( (candidate) => candidate.data.invitationIdentifier === invitationIdentifier, ); // If the invitation is not found, print an error and throw an error if (!invitation) { deps.io.err(`Invitation not found: ${invitationIdentifier}`); throw new CommandError( "invitation.sign.not_found", `Invitation not found: ${invitationIdentifier}`, ); } deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`); // Sign the invitation await invitation.sign(); deps.io.verbose(`Invitation signed: ${formatObject(invitation.data)}`); deps.io.out(`Invitation signed: ${invitationIdentifier}`); // Return the invitation identifier return { invitationIdentifier }; } case "broadcast": { // Get the invitation identifier from the arguments const invitationIdentifier = args[1]; deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`); // If they didnt provide us with an invitation identifier, print the help message and throw an error // TODO: Should probably print a specific help message for this command? if (!invitationIdentifier) { deps.io.verbose("No invitation identifier provided"); printInvitationHelp(deps.io); throw new CommandError( "invitation.broadcast.identifier_missing", "No invitation identifier provided", ); } // Find the invitation instance in our list of invitations const invitation = deps.app.invitations.find( (candidate) => candidate.data.invitationIdentifier === invitationIdentifier, ); // If the invitation is not found, print an error and throw an error if (!invitation) { deps.io.err(`Invitation not found: ${invitationIdentifier}`); throw new CommandError( "invitation.broadcast.not_found", `Invitation not found: ${invitationIdentifier}`, ); } deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`); // Broadcast the transaction const txHash = await invitation.broadcast(); deps.io.verbose( `Invitation broadcasted: ${formatObject(invitation.data)}`, ); deps.io.out(`Transaction broadcast: ${bold(txHash)}`); // Return the invitation identifier and transaction hash return { invitationIdentifier, txHash }; } case "requirements": { // Get the invitation identifier from the arguments const invitationIdentifier = args[1]; deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`); // If they didnt provide us with an invitation identifier, print the help message and throw an error // TODO: Should probably print a specific help message for this command? if (!invitationIdentifier) { deps.io.verbose("No invitation identifier provided"); printInvitationHelp(deps.io); throw new CommandError( "invitation.requirements.identifier_missing", "No invitation identifier provided", ); } // Find the invitation instance in our list of invitations const invitation = deps.app.invitations.find( (candidate) => candidate.data.invitationIdentifier === invitationIdentifier, ); // If the invitation is not found, print an error and throw an error if (!invitation) { deps.io.err(`Invitation not found: ${invitationIdentifier}`); throw new CommandError( "invitation.requirements.not_found", `Invitation not found: ${invitationIdentifier}`, ); } deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`); // List the requirements for the invitation const requirements = await deps.app.engine.listRequirements( invitation.data, ); deps.io.verbose(`Requirements: ${formatObject(requirements)}`); deps.io.out(formatObject(requirements)); // Return the invitation identifier return { invitationIdentifier }; } case "inspect": { // Get the invitation file path from the arguments const invitationFilePath = args[1]; // If they didnt provide us with an invitation file path, print the help message and throw an error // TODO: Should probably print a specific help message for this command? deps.io.verbose(`Invitation file path: ${invitationFilePath}`); // Read the invitation file if (!invitationFilePath) { deps.io.verbose("No invitation file provided"); printInvitationHelp(deps.io); throw new CommandError( "invitation.inspect.file_missing", "No invitation file provided", ); } // Read the invitation file (XOInvitation format, can be passed to the engine directly) const invitationFile = readFileSync(invitationFilePath, "utf8"); deps.io.verbose(`Invitation file: ${invitationFile}`); const invitation = deserializeInvitation(invitationFile); deps.io.verbose(`Invitation: ${formatObject(invitation)}`); // Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format) const invitationInstance = await deps.app.createInvitation(invitation); deps.io.verbose( `Invitation created: ${formatObject(invitationInstance.data)}`, ); // Get the template for the invitation const template = await deps.app.engine.getTemplate( invitationInstance.data.templateIdentifier, ); // Get the action for the invitation const action = template?.actions[invitationInstance.data.actionIdentifier]; deps.io.verbose(`Action: ${formatObject(action)}`); // If the action is not found, print an error and throw an error if (!action) { deps.io.err( `Action not found: ${invitationInstance.data.actionIdentifier}`, ); throw new CommandError( "invitation.inspect.action_not_found", `Action not found: ${invitationInstance.data.actionIdentifier}`, ); } // Get the status for the invitation const status = invitationInstance.status; deps.io.verbose(`Status: ${status}`); // Get the entities for the invitation const entities = Array.from( new Set( invitationInstance.data.commits.map( (commit) => commit.entityIdentifier, ), ), ); deps.io.verbose(`Entities: ${formatObject(entities)}`); // Get the entities with roles for the invitation const entitiesWithRoles = entities.map((entity) => { return { entityIdentifier: entity, roles: invitationInstance.data.commits .filter((commit) => commit.entityIdentifier === entity) .map((commit) => { return [ ...(commit.data.inputs?.map((input) => input.roleIdentifier) ?? []), ...(commit.data.outputs?.map( (output) => output.roleIdentifier, ) ?? []), ...(commit.data.variables?.map( (variable) => variable.roleIdentifier, ) ?? []), ]; }) .flat() .filter((role) => role !== undefined), }; }); // Get the inputs for the invitation const inputs = invitationInstance.data.commits.flatMap( (commit) => commit.data.inputs ?? [], ); deps.io.verbose(`Inputs: ${formatObject(inputs)}`); // Get the outputs for the invitation const outputs = invitationInstance.data.commits.flatMap( (commit) => commit.data.outputs ?? [], ); deps.io.verbose(`Outputs: ${formatObject(outputs)}`); // Get the variables for the invitation const variables = invitationInstance.data.commits.flatMap( (commit) => commit.data.variables ?? [], ); deps.io.verbose(`Variables: ${formatObject(variables)}`); // Return the invitation details return { templateName: template?.name ?? "Unknown", actionIdentifier: invitationInstance.data.actionIdentifier, status: status, entities: entitiesWithRoles, inputs: inputs, outputs: outputs, variables: variables, }; } case "import": { // Get the invitation file path from the arguments const invitationFilePath = args[1]; deps.io.verbose(`Invitation file path: ${invitationFilePath}`); // If they didnt provide us with an invitation file path, print the help message and throw an error // TODO: Should probably print a specific help message for this command? if (!invitationFilePath) { deps.io.verbose("No invitation file provided"); printInvitationHelp(deps.io); throw new CommandError( "invitation.import.file_missing", "No invitation file provided", ); } // Read the invitation file (XOInvitation format, can be passed to the engine directly) const invitationFile = readFileSync(invitationFilePath, "utf8"); deps.io.verbose(`Invitation file: ${invitationFile}`); const invitation = deserializeInvitation(invitationFile); deps.io.verbose(`Invitation: ${formatObject(invitation)}`); const template = await deps.app.engine.getTemplate( invitation.templateIdentifier, ); if (!template) { throw new CommandError( "invitation.import.template_not_found", `Template not found: ${invitation.templateIdentifier}. ` + `Import the matching template first with: xo-cli template import `, ); } // Accept and track the invitation. Invitation.create calls acceptInvitation // internally — do not call engine.createInvitation here, that creates a new // invitation and discards the imported commits. const invitationInstance = await deps.app.createInvitation(invitation); deps.io.verbose( `Invitation created: ${formatObject(invitationInstance.data)}`, ); // Return the invitation identifier return { invitationIdentifier: invitationInstance.data.invitationIdentifier, }; } case "export": { return handleInvitationExportCommand(deps, args.slice(1), options); } case "list": { // List all the invitations const invitations = await Promise.all( // Iterate over the invitations and compile them into a list of data that we can use to display them with another loop later. deps.app.invitations.map(async (invitation) => { // Get the template for the invitation const template = await deps.app.engine.getTemplate( invitation.data.templateIdentifier, ); // Get the role identifier for the invitation return { invitationIdentifier: invitation.data.invitationIdentifier, templateIdentifier: invitation.data.templateIdentifier, actionIdentifier: invitation.data.actionIdentifier, templateName: template?.name ?? "Unknown", status: invitation.status, roleIdentifier: "TODO: Get role identifier", }; }), ); deps.io.verbose(`Invitations: ${formatObject(invitations)}`); // Format the invitations into a list of strings that we can display to the user const formattedInvitations = invitations.map( (invitation) => `${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`, ); // Display the invitations to the user deps.io.out(formattedInvitations.join("\n")); // Return the number of invitations return { count: invitations.length }; } default: // If the sub-command is not found, print an error and throw an error deps.io.verbose(`Unknown invitation sub-command: ${subCommand}`); printInvitationHelp(deps.io); throw new CommandError( "invitation.subcommand.unknown", `Unknown invitation sub-command: ${subCommand}`, ); } };