1004 lines
38 KiB
TypeScript
1004 lines
38 KiB
TypeScript
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<string, string>,
|
|
): { 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<string, string>,
|
|
): Promise<BuildAppendResult | null> {
|
|
// --- Inputs ---
|
|
// Accepts comma-separated <txhash>:<vout> 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 <txhash>:<vout> (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 <txhash>:<vout> (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<string>();
|
|
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. <recipientLockingscript>).
|
|
const variableValuesByIdentifier: Record<string, string> = {};
|
|
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 <sub-command>
|
|
|
|
${bold("Sub-commands:")}
|
|
- create <template-file> <action-id> ${dim("Create a new invitation")}
|
|
- append <invitation-id> ${dim("Add variables/outputs to an invitation")}
|
|
- sign <invitation-id> ${dim("Sign an invitation")}
|
|
- broadcast <invitation-id> ${dim("Broadcast an invitation")}
|
|
- requirements <invitation-id> ${dim("Show requirements for an invitation")}
|
|
- import <invitation-file> ${dim("Import an invitation from a file")}
|
|
- export <invitation-id> [output-file] ${dim("Export an invitation to stdout or a file")}
|
|
- inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")}
|
|
- list ${dim("List all invitations")}
|
|
|
|
${bold("Export options:")}
|
|
-o --output <output-filename> ${dim("Output filename for the exported invitation")}
|
|
|
|
${bold("Create / Append options:")}
|
|
-var-<name> <value> ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")}
|
|
--add-input <txhash:vout> ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")}
|
|
--add-output <id> ${dim("Override output(s) — omit to auto-discover from template")}
|
|
--auto-inputs ${dim("Automatically select UTXOs as inputs")}
|
|
-role <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<string, string>,
|
|
): 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<string, string>,
|
|
): Promise<InvitationCommandResult> => {
|
|
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-<name> <value>`
|
|
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-<name> <value>`
|
|
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-<name> <value>), inputs (--add-input <txhash>:<vout>), or outputs (--add-output <identifier>).";
|
|
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 <template-file>`,
|
|
);
|
|
}
|
|
|
|
// 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}`,
|
|
);
|
|
}
|
|
};
|