Files
xo-cli/src/cli/commands/invitation.ts

774 lines
28 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,
} from "../../utils/invitation-flow.js";
import { encodeExtendedJson } from "../../utils/ext-json.js";
import { resolveTemplate } from "../utils.js";
const DEFAULT_FEE = 500n;
const DUST_THRESHOLD = 546n;
/**
* 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"];
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);
const requiredWithFee =
(await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE;
autoSelectGreedyUtxos(selectable, requiredWithFee);
inputs = selectable
.filter((u) => u.selected)
.map((u) => ({
outpointTransactionHash: hexToBin(u.outpointTransactionHash),
outpointIndex: u.outpointIndex,
}));
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)`);
} 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)`,
);
}
const txHash = entry.substring(0, separatorIndex);
const vout = parseInt(entry.substring(separatorIndex + 1), 10);
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;
}
}
}
const template = await deps.app.engine.getTemplate(
invitation.data.templateIdentifier,
);
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,
]),
);
let totalInputSats = 0n;
for (const input of inputs) {
const txHashHex = binToHex(input.outpointTransactionHash);
const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`);
if (!utxo) {
deps.io.err(
`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`,
);
return null;
}
totalInputSats += BigInt(utxo.valueSatoshis);
}
deps.io.verbose(`Total input value: ${totalInputSats} satoshis`);
const requiredSats = await invitation.getSatsOut();
deps.io.verbose(`Required output value: ${requiredSats} satoshis`);
const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE;
deps.io.verbose(
`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`,
);
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 (changeAmount >= DUST_THRESHOLD) {
outputs.push({ valueSatoshis: changeAmount });
deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`);
} 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")}
- list ${dim("List all invitations")}
${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;
templateName?: string;
actionIdentifier?: string;
status?: string;
entities?: { entityIdentifier: string; roles: (string | undefined)[] }[];
inputs?: unknown[];
outputs?: unknown[];
variables?: unknown[];
};
/**
* 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 (!subCommand) {
deps.io.verbose("No sub-command provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.subcommand.missing",
"No sub-command provided",
);
}
switch (subCommand) {
case "create": {
const templateQuery = args[1];
const actionIdentifier = args[2];
deps.io.verbose(
`Template query: ${templateQuery}, action identifier: ${actionIdentifier}`,
);
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",
);
}
const template = await resolveTemplate(deps, templateQuery);
const templateIdentifier = generateTemplateIdentifier(template);
const rawInvitation = await deps.app.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`);
const invitationInstance = await deps.app.createInvitation(rawInvitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
const variables = parseVariablesFromOptions(options);
deps.io.verbose(`Variables: ${formatObject(variables)}`);
if (variables.length > 0) {
await invitationInstance.addVariables(variables);
}
const params = await buildAppendParams(deps, invitationInstance, options);
if (!params) {
throw new CommandError(
"invitation.create.append_params_failed",
"Failed to build append parameters",
);
}
const { inputs, outputs } = params;
if (inputs.length > 0 || outputs.length > 0) {
await invitationInstance.append({ inputs, outputs });
}
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitationInstance.data.invitationIdentifier}.json`;
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
writeFileSync(
invitationFilePath,
encodeExtendedJson(invitationInstance.data, 2),
);
deps.io.out(
`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`,
);
const missingRequirements =
await invitationInstance.getMissingRequirements();
const hasMissing =
(missingRequirements.variables?.length ?? 0) > 0 ||
(missingRequirements.inputs?.length ?? 0) > 0 ||
(missingRequirements.outputs?.length ?? 0) > 0 ||
(missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0);
if (hasMissing) {
deps.io.out(`\n${bold("Remaining requirements:")}`);
deps.io.out(formatObject(missingRequirements));
} else {
const shouldSign =
options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true";
if (shouldSign) {
await invitationInstance.sign();
deps.io.out(
`Invitation signed: ${invitationInstance.data.invitationIdentifier}`,
);
}
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 {
invitationIdentifier: invitationInstance.data.invitationIdentifier,
};
}
case "append": {
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.append.identifier_missing",
"No invitation identifier provided",
);
}
const invitation = deps.app.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationIdentifier,
);
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)}`);
const variables = parseVariablesFromOptions(options);
deps.io.verbose(`Variables to append: ${formatObject(variables)}`);
if (variables.length > 0) {
await invitation.addVariables(variables);
}
const params = await buildAppendParams(deps, invitation, options);
if (!params) {
throw new CommandError(
"invitation.append.params_failed",
"Failed to build append parameters",
);
}
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);
}
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}`);
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`;
writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2));
deps.io.out(
`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`,
);
const missingRequirements = await invitation.getMissingRequirements();
const hasMissing =
(missingRequirements.variables?.length ?? 0) > 0 ||
(missingRequirements.inputs?.length ?? 0) > 0 ||
(missingRequirements.outputs?.length ?? 0) > 0 ||
(missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0);
if (hasMissing) {
deps.io.out(`\n${bold("Remaining requirements:")}`);
deps.io.out(formatObject(missingRequirements));
} else {
const shouldSign =
options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true";
if (shouldSign) {
await invitation.sign();
deps.io.out(`Invitation signed: ${invitationIdentifier}`);
}
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": {
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.sign.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.sign.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
}
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
await invitation.sign();
deps.io.verbose(`Invitation signed: ${formatObject(invitation.data)}`);
deps.io.out(`Invitation signed: ${invitationIdentifier}`);
return { invitationIdentifier };
}
case "broadcast": {
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.broadcast.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.broadcast.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
}
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
const txHash = await invitation.broadcast();
deps.io.verbose(
`Invitation broadcasted: ${formatObject(invitation.data)}`,
);
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
return { invitationIdentifier, txHash };
}
case "requirements": {
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.requirements.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.requirements.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
}
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
const requirements = await deps.app.engine.listRequirements(
invitation.data,
);
deps.io.verbose(`Requirements: ${formatObject(requirements)}`);
deps.io.out(formatObject(requirements));
return { invitationIdentifier };
}
case "inspect": {
const invitationFilePath = args[1];
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
if (!invitationFilePath) {
deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.inspect.file_missing",
"No invitation file provided",
);
}
const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`);
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
const invitationInstance = await deps.app.createInvitation(invitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
const template = await deps.app.engine.getTemplate(
invitationInstance.data.templateIdentifier,
);
const action =
template?.actions[invitationInstance.data.actionIdentifier];
deps.io.verbose(`Action: ${formatObject(action)}`);
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}`,
);
}
const status = invitationInstance.status;
deps.io.verbose(`Status: ${status}`);
const entities = Array.from(
new Set(
invitationInstance.data.commits.map(
(commit) => commit.entityIdentifier,
),
),
);
deps.io.verbose(`Entities: ${formatObject(entities)}`);
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),
};
});
const inputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.inputs ?? [],
);
deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
const outputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.outputs ?? [],
);
deps.io.verbose(`Outputs: ${formatObject(outputs)}`);
const variables = invitationInstance.data.commits.flatMap(
(commit) => commit.data.variables ?? [],
);
deps.io.verbose(`Variables: ${formatObject(variables)}`);
return {
templateName: template?.name ?? "Unknown",
actionIdentifier: invitationInstance.data.actionIdentifier,
status: status,
entities: entitiesWithRoles,
inputs: inputs,
outputs: outputs,
variables: variables,
};
}
case "import": {
const invitationFilePath = args[1];
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
if (!invitationFilePath) {
deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.import.file_missing",
"No invitation file provided",
);
}
const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`);
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
const xoInvitation = await deps.app.engine.createInvitation(invitation);
const invitationInstance = await deps.app.createInvitation(xoInvitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
return {
invitationIdentifier: invitationInstance.data.invitationIdentifier,
};
}
case "list": {
const invitations = await Promise.all(
deps.app.invitations.map(async (invitation) => {
const template = await deps.app.engine.getTemplate(
invitation.data.templateIdentifier,
);
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)}`);
const formattedInvitations = invitations.map(
(invitation) =>
`${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`,
);
deps.io.out(formattedInvitations.join("\n"));
return { count: invitations.length };
}
default:
deps.io.verbose(`Unknown invitation sub-command: ${subCommand}`);
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.subcommand.unknown",
`Unknown invitation sub-command: ${subCommand}`,
);
}
};