885 lines
30 KiB
TypeScript
885 lines
30 KiB
TypeScript
import { PigApiClient, PigApiError } from './api';
|
|
|
|
const VERSION = '0.1.0';
|
|
|
|
const HELP = `Usage: pig [--json] [--api-url URL] [--api-key KEY] <command>
|
|
|
|
Read commands:
|
|
me
|
|
accounts [list] [--side SIDE] [--query TEXT]
|
|
accounts get <account-id>
|
|
deals demand | supply
|
|
commitments [list]
|
|
allocations [list]
|
|
capacity availability [--gpu-type TYPE]
|
|
capacity match --gpu-count N [filters]
|
|
capacity search [--gpu-type TYPE] [--min-gpu-count N] [--max-price-cents N]
|
|
capacity margin
|
|
capacity idle [--threshold FRACTION] [--within-days N]
|
|
|
|
Write commands (requires an API key with write scope and the relevant role):
|
|
commitments create --account-id ID --name NAME --gpu-type TYPE --gpu-count N
|
|
--starts-at ISO --ends-at ISO --total-gpu-hours N --cost-per-gpu-hour-cents N
|
|
commitments update <commitment-id> [fields]
|
|
allocations create --commitment-id ID --demand-deal-id ID --gpu-hours N
|
|
--price-per-gpu-hour-cents N --starts-at ISO --ends-at ISO --status STATUS
|
|
allocations hold --commitment-id ID --demand-deal-id ID --gpu-hours N
|
|
--starts-at ISO --ends-at ISO --hold-expires-at ISO
|
|
allocations release <allocation-id> [--reason TEXT]
|
|
|
|
Configuration:
|
|
PIG_API_URL and PIG_API_KEY, overridden by --api-url and --api-key.
|
|
--json writes only JSON to stdout; errors are JSON on stderr with a non-zero exit.`;
|
|
|
|
type Writer = (value: string) => void;
|
|
|
|
export interface RunCliOptions {
|
|
env?: NodeJS.ProcessEnv;
|
|
fetchImpl?: typeof fetch;
|
|
stdout?: Writer;
|
|
stderr?: Writer;
|
|
}
|
|
|
|
class CliUsageError extends Error {
|
|
readonly code = 'invalid_usage';
|
|
}
|
|
|
|
interface GlobalOptions {
|
|
json: boolean;
|
|
help: boolean;
|
|
version: boolean;
|
|
apiUrl?: string;
|
|
apiKey?: string;
|
|
tokens: string[];
|
|
}
|
|
|
|
type OptionKind = 'flag' | 'value' | 'repeat';
|
|
type OptionSpec = Readonly<Record<string, OptionKind>>;
|
|
type ParsedValue = boolean | string | string[];
|
|
|
|
interface ParsedOptions {
|
|
options: Map<string, ParsedValue>;
|
|
positionals: string[];
|
|
}
|
|
|
|
interface CommandResult {
|
|
kind: string;
|
|
data: unknown;
|
|
}
|
|
|
|
function usage(message: string): never {
|
|
throw new CliUsageError(message);
|
|
}
|
|
|
|
function globalValue(args: readonly string[], index: number, name: string): string {
|
|
const value = args[index + 1];
|
|
if (!value || value.startsWith('--')) usage(`${name} requires a value.`);
|
|
return value;
|
|
}
|
|
|
|
function parseGlobals(args: readonly string[]): GlobalOptions {
|
|
const result: GlobalOptions = {
|
|
json: false,
|
|
help: false,
|
|
version: false,
|
|
tokens: [],
|
|
};
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const token = args[index];
|
|
if (token === '--json') {
|
|
result.json = true;
|
|
} else if (token === '--help' || token === '-h') {
|
|
result.help = true;
|
|
} else if (token === '--version') {
|
|
result.version = true;
|
|
} else if (token === '--api-url') {
|
|
result.apiUrl = globalValue(args, index, '--api-url');
|
|
index += 1;
|
|
} else if (token?.startsWith('--api-url=')) {
|
|
result.apiUrl = token.slice('--api-url='.length);
|
|
if (!result.apiUrl) usage('--api-url requires a value.');
|
|
} else if (token === '--api-key') {
|
|
result.apiKey = globalValue(args, index, '--api-key');
|
|
index += 1;
|
|
} else if (token?.startsWith('--api-key=')) {
|
|
result.apiKey = token.slice('--api-key='.length);
|
|
if (!result.apiKey) usage('--api-key requires a value.');
|
|
} else if (token !== undefined) {
|
|
result.tokens.push(token);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function candidateSecrets(args: readonly string[], env: NodeJS.ProcessEnv): string[] {
|
|
const values = new Set<string>();
|
|
if (env.PIG_API_KEY) values.add(env.PIG_API_KEY);
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const token = args[index];
|
|
if (token === '--api-key' && args[index + 1]) values.add(args[index + 1]!);
|
|
if (token?.startsWith('--api-key=')) values.add(token.slice('--api-key='.length));
|
|
}
|
|
return [...values].filter(Boolean);
|
|
}
|
|
|
|
function redact(value: string, secrets: readonly string[]): string {
|
|
return secrets.reduce(
|
|
(redacted, secret) => redacted.split(secret).join('[REDACTED]'),
|
|
value,
|
|
);
|
|
}
|
|
|
|
function parseOptions(args: readonly string[], spec: OptionSpec): ParsedOptions {
|
|
const options = new Map<string, ParsedValue>();
|
|
const positionals: string[] = [];
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const token = args[index];
|
|
if (!token?.startsWith('--')) {
|
|
if (token !== undefined) positionals.push(token);
|
|
continue;
|
|
}
|
|
|
|
const equalsAt = token.indexOf('=');
|
|
const name = token.slice(2, equalsAt === -1 ? undefined : equalsAt);
|
|
const inline = equalsAt === -1 ? undefined : token.slice(equalsAt + 1);
|
|
const kind = spec[name];
|
|
if (!kind) usage(`Unknown option --${name}.`);
|
|
|
|
if (kind === 'flag') {
|
|
if (inline !== undefined) usage(`--${name} does not take a value.`);
|
|
options.set(name, true);
|
|
continue;
|
|
}
|
|
|
|
const value = inline ?? args[index + 1];
|
|
if (value === undefined || (inline === undefined && value.startsWith('--'))) {
|
|
usage(`--${name} requires a value.`);
|
|
}
|
|
if (inline === undefined) index += 1;
|
|
|
|
if (kind === 'repeat') {
|
|
const existing = options.get(name);
|
|
options.set(name, [...(Array.isArray(existing) ? existing : []), value]);
|
|
} else {
|
|
if (options.has(name)) usage(`--${name} may only be provided once.`);
|
|
options.set(name, value);
|
|
}
|
|
}
|
|
|
|
return { options, positionals };
|
|
}
|
|
|
|
function expectPositionals(
|
|
positionals: readonly string[],
|
|
minimum: number,
|
|
maximum: number,
|
|
commandUsage: string,
|
|
): void {
|
|
if (positionals.length < minimum || positionals.length > maximum) {
|
|
usage(`Usage: ${commandUsage}`);
|
|
}
|
|
}
|
|
|
|
function option(options: Map<string, ParsedValue>, name: string): string | undefined {
|
|
const value = options.get(name);
|
|
return typeof value === 'string' ? value : undefined;
|
|
}
|
|
|
|
function requiredOption(options: Map<string, ParsedValue>, name: string): string {
|
|
const value = option(options, name);
|
|
if (value === undefined || value.length === 0) usage(`--${name} is required.`);
|
|
return value;
|
|
}
|
|
|
|
function repeated(options: Map<string, ParsedValue>, name: string): string[] | undefined {
|
|
const value = options.get(name);
|
|
return Array.isArray(value) ? value : undefined;
|
|
}
|
|
|
|
interface NumberRules {
|
|
integer?: boolean;
|
|
minimum?: number;
|
|
maximum?: number;
|
|
nullable?: boolean;
|
|
}
|
|
|
|
function numeric(
|
|
options: Map<string, ParsedValue>,
|
|
name: string,
|
|
rules: NumberRules = {},
|
|
): number | null | undefined {
|
|
const raw = option(options, name);
|
|
if (raw === undefined) return undefined;
|
|
if (rules.nullable && raw === 'null') return null;
|
|
const value = Number(raw);
|
|
if (!Number.isFinite(value)) usage(`--${name} must be a number.`);
|
|
if (rules.integer && !Number.isInteger(value)) usage(`--${name} must be an integer.`);
|
|
if (rules.minimum !== undefined && value < rules.minimum) {
|
|
usage(`--${name} must be at least ${rules.minimum}.`);
|
|
}
|
|
if (rules.maximum !== undefined && value > rules.maximum) {
|
|
usage(`--${name} must be at most ${rules.maximum}.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requiredNumeric(
|
|
options: Map<string, ParsedValue>,
|
|
name: string,
|
|
rules: NumberRules = {},
|
|
): number {
|
|
const value = numeric(options, name, rules);
|
|
if (typeof value !== 'number') usage(`--${name} is required.`);
|
|
return value;
|
|
}
|
|
|
|
function booleanValue(
|
|
options: Map<string, ParsedValue>,
|
|
name: string,
|
|
): boolean | undefined {
|
|
const raw = option(options, name);
|
|
if (raw === undefined) return undefined;
|
|
if (raw === 'true') return true;
|
|
if (raw === 'false') return false;
|
|
usage(`--${name} must be true or false.`);
|
|
}
|
|
|
|
function nullableString(
|
|
options: Map<string, ParsedValue>,
|
|
name: string,
|
|
): string | null | undefined {
|
|
const value = option(options, name);
|
|
return value === 'null' ? null : value;
|
|
}
|
|
|
|
function setDefined(target: Record<string, unknown>, name: string, value: unknown): void {
|
|
if (value !== undefined) target[name] = value;
|
|
}
|
|
|
|
const ACCOUNT_LIST_OPTIONS: OptionSpec = {
|
|
side: 'value',
|
|
query: 'value',
|
|
};
|
|
|
|
const CAPACITY_MATCH_OPTIONS: OptionSpec = {
|
|
'gpu-type': 'value',
|
|
'gpu-type-alternative': 'repeat',
|
|
'gpu-count': 'value',
|
|
'total-gpu-hours': 'value',
|
|
'fast-fabric': 'flag',
|
|
'min-security-tier': 'value',
|
|
'starts-at': 'value',
|
|
'ends-at': 'value',
|
|
'max-price-cents': 'value',
|
|
};
|
|
|
|
const COMMITMENT_OPTIONS: OptionSpec = {
|
|
'account-id': 'value',
|
|
'site-id': 'value',
|
|
'supply-deal-id': 'value',
|
|
name: 'value',
|
|
'gpu-type': 'value',
|
|
socket: 'value',
|
|
'gpu-count': 'value',
|
|
interconnect: 'value',
|
|
'security-tier': 'value',
|
|
'starts-at': 'value',
|
|
'ends-at': 'value',
|
|
'total-gpu-hours': 'value',
|
|
'cost-per-gpu-hour-cents': 'value',
|
|
currency: 'value',
|
|
'shape-interval': 'repeat',
|
|
'shape-quantity': 'repeat',
|
|
'clear-shape': 'flag',
|
|
'colocate-with': 'repeat',
|
|
'clear-colocate': 'flag',
|
|
'is-contiguous': 'value',
|
|
'minimum-spend-cents': 'value',
|
|
'auto-renew': 'value',
|
|
'notice-days': 'value',
|
|
'take-or-pay-floor-pct': 'value',
|
|
'prepaid-pct': 'value',
|
|
'prepaid-amount-cents': 'value',
|
|
'useful-life-years': 'value',
|
|
'salvage-value-pct': 'value',
|
|
'depreciation-start-at': 'value',
|
|
'cost-of-capital-bps': 'value',
|
|
'financing-instrument': 'value',
|
|
'oversubscription-pct': 'value',
|
|
notes: 'value',
|
|
};
|
|
|
|
const COMMITMENT_UPDATE_OPTIONS: OptionSpec = {
|
|
...COMMITMENT_OPTIONS,
|
|
'terminated-at': 'value',
|
|
};
|
|
|
|
const ALLOCATION_BASE_OPTIONS: OptionSpec = {
|
|
'commitment-id': 'value',
|
|
'demand-deal-id': 'value',
|
|
'gpu-hours': 'value',
|
|
'price-per-gpu-hour-cents': 'value',
|
|
currency: 'value',
|
|
'starts-at': 'value',
|
|
'ends-at': 'value',
|
|
guarantee: 'value',
|
|
priority: 'value',
|
|
'compliance-decision-id': 'value',
|
|
notes: 'value',
|
|
};
|
|
|
|
function buildCommitmentBody(
|
|
options: Map<string, ParsedValue>,
|
|
update: boolean,
|
|
): Record<string, unknown> {
|
|
const body: Record<string, unknown> = {};
|
|
|
|
const requiredText = (name: string) =>
|
|
update ? option(options, name) : requiredOption(options, name);
|
|
const requiredNumber = (name: string, rules: NumberRules) =>
|
|
update ? numeric(options, name, rules) : requiredNumeric(options, name, rules);
|
|
|
|
setDefined(body, 'accountId', requiredText('account-id'));
|
|
setDefined(body, 'siteId', nullableString(options, 'site-id'));
|
|
setDefined(body, 'supplyDealId', nullableString(options, 'supply-deal-id'));
|
|
setDefined(body, 'name', requiredText('name'));
|
|
setDefined(body, 'gpuType', requiredText('gpu-type'));
|
|
setDefined(body, 'socket', nullableString(options, 'socket'));
|
|
setDefined(body, 'gpuCount', requiredNumber('gpu-count', { integer: true, minimum: 1 }));
|
|
setDefined(body, 'interconnectType', option(options, 'interconnect'));
|
|
setDefined(body, 'securityTier', option(options, 'security-tier'));
|
|
setDefined(body, 'startsAt', requiredText('starts-at'));
|
|
setDefined(body, 'endsAt', requiredText('ends-at'));
|
|
setDefined(body, 'totalGpuHours', requiredNumber('total-gpu-hours', { minimum: 0.01 }));
|
|
setDefined(
|
|
body,
|
|
'costPerGpuHourCents',
|
|
requiredNumber('cost-per-gpu-hour-cents', { integer: true, minimum: 0 }),
|
|
);
|
|
setDefined(body, 'currency', option(options, 'currency'));
|
|
setDefined(body, 'isContiguous', booleanValue(options, 'is-contiguous'));
|
|
setDefined(
|
|
body,
|
|
'minimumSpendCents',
|
|
numeric(options, 'minimum-spend-cents', { integer: true, minimum: 0, nullable: true }),
|
|
);
|
|
setDefined(body, 'isAutoRenew', booleanValue(options, 'auto-renew'));
|
|
setDefined(
|
|
body,
|
|
'noticeDays',
|
|
numeric(options, 'notice-days', { integer: true, minimum: 0, nullable: true }),
|
|
);
|
|
setDefined(
|
|
body,
|
|
'takeOrPayFloorPct',
|
|
numeric(options, 'take-or-pay-floor-pct', { minimum: 0, maximum: 100, nullable: true }),
|
|
);
|
|
setDefined(
|
|
body,
|
|
'prepaidPct',
|
|
numeric(options, 'prepaid-pct', { minimum: 0, maximum: 100, nullable: true }),
|
|
);
|
|
setDefined(
|
|
body,
|
|
'prepaidAmountCents',
|
|
numeric(options, 'prepaid-amount-cents', { integer: true, minimum: 0, nullable: true }),
|
|
);
|
|
setDefined(
|
|
body,
|
|
'usefulLifeYears',
|
|
numeric(options, 'useful-life-years', { minimum: 0, maximum: 100, nullable: true }),
|
|
);
|
|
setDefined(
|
|
body,
|
|
'salvageValuePct',
|
|
numeric(options, 'salvage-value-pct', { minimum: 0, maximum: 100, nullable: true }),
|
|
);
|
|
setDefined(body, 'depreciationStartAt', nullableString(options, 'depreciation-start-at'));
|
|
setDefined(
|
|
body,
|
|
'costOfCapitalBps',
|
|
numeric(options, 'cost-of-capital-bps', { integer: true, minimum: 0, nullable: true }),
|
|
);
|
|
setDefined(body, 'financingInstrument', nullableString(options, 'financing-instrument'));
|
|
setDefined(
|
|
body,
|
|
'oversubscriptionPct',
|
|
numeric(options, 'oversubscription-pct', { minimum: 0, maximum: 1_000 }),
|
|
);
|
|
setDefined(body, 'notes', nullableString(options, 'notes'));
|
|
|
|
if (update) setDefined(body, 'terminatedAt', nullableString(options, 'terminated-at'));
|
|
|
|
const intervals = repeated(options, 'shape-interval');
|
|
const quantityValues = repeated(options, 'shape-quantity');
|
|
const clearShape = options.get('clear-shape') === true;
|
|
if (clearShape && (intervals || quantityValues)) {
|
|
usage('--clear-shape cannot be combined with shape intervals or quantities.');
|
|
}
|
|
if (clearShape) body.shape = null;
|
|
if (intervals || quantityValues) {
|
|
if (!intervals || !quantityValues) {
|
|
usage('--shape-interval and --shape-quantity must be provided together.');
|
|
}
|
|
const quantities = quantityValues.map((raw) => {
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value < 0) {
|
|
usage('--shape-quantity must contain non-negative integers.');
|
|
}
|
|
return value;
|
|
});
|
|
body.shape = { intervals, quantities };
|
|
}
|
|
|
|
const colocateWith = repeated(options, 'colocate-with');
|
|
const clearColocate = options.get('clear-colocate') === true;
|
|
if (clearColocate && colocateWith) {
|
|
usage('--clear-colocate cannot be combined with --colocate-with.');
|
|
}
|
|
if (clearColocate) body.colocateWith = [];
|
|
if (colocateWith) body.colocateWith = colocateWith;
|
|
|
|
if (update && Object.keys(body).length === 0) {
|
|
usage('At least one commitment field is required for update.');
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function buildAllocationBase(
|
|
options: Map<string, ParsedValue>,
|
|
priceRequired: boolean,
|
|
): Record<string, unknown> {
|
|
const body: Record<string, unknown> = {
|
|
capacityCommitmentId: requiredOption(options, 'commitment-id'),
|
|
demandDealId: requiredOption(options, 'demand-deal-id'),
|
|
gpuHours: requiredNumeric(options, 'gpu-hours', { minimum: 0.01 }),
|
|
startsAt: requiredOption(options, 'starts-at'),
|
|
endsAt: requiredOption(options, 'ends-at'),
|
|
};
|
|
const price = priceRequired
|
|
? requiredNumeric(options, 'price-per-gpu-hour-cents', { integer: true, minimum: 0 })
|
|
: numeric(options, 'price-per-gpu-hour-cents', { integer: true, minimum: 0 });
|
|
setDefined(body, 'pricePerGpuHourCents', price);
|
|
setDefined(body, 'currency', option(options, 'currency'));
|
|
setDefined(body, 'guaranteeType', option(options, 'guarantee'));
|
|
setDefined(body, 'priority', numeric(options, 'priority', { integer: true, minimum: 0 }));
|
|
setDefined(body, 'complianceDecisionId', nullableString(options, 'compliance-decision-id'));
|
|
setDefined(body, 'notes', nullableString(options, 'notes'));
|
|
return body;
|
|
}
|
|
|
|
async function accountsCommand(
|
|
api: PigApiClient,
|
|
tokens: readonly string[],
|
|
): Promise<CommandResult> {
|
|
if (tokens[0] === 'get') {
|
|
const parsed = parseOptions(tokens.slice(1), {});
|
|
expectPositionals(parsed.positionals, 1, 1, 'pig accounts get <account-id>');
|
|
return {
|
|
kind: 'account',
|
|
data: await api.request(`/api/accounts/${encodeURIComponent(parsed.positionals[0]!)}`),
|
|
};
|
|
}
|
|
|
|
const listTokens = tokens[0] === 'list' ? tokens.slice(1) : tokens;
|
|
if (listTokens[0] && !listTokens[0]!.startsWith('--')) {
|
|
usage('Usage: pig accounts [list] [--side SIDE] [--query TEXT]');
|
|
}
|
|
const parsed = parseOptions(listTokens, ACCOUNT_LIST_OPTIONS);
|
|
expectPositionals(parsed.positionals, 0, 0, 'pig accounts [list] [options]');
|
|
return {
|
|
kind: 'accounts',
|
|
data: await api.request('/api/accounts', {
|
|
query: { side: option(parsed.options, 'side'), q: option(parsed.options, 'query') },
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function dealsCommand(
|
|
api: PigApiClient,
|
|
tokens: readonly string[],
|
|
): Promise<CommandResult> {
|
|
const parsed = parseOptions(tokens, {});
|
|
expectPositionals(parsed.positionals, 1, 1, 'pig deals demand|supply');
|
|
const side = parsed.positionals[0];
|
|
if (side !== 'demand' && side !== 'supply') usage('Deal side must be demand or supply.');
|
|
return { kind: `${side}-deals`, data: await api.request(`/api/deals/${side}`) };
|
|
}
|
|
|
|
async function commitmentsCommand(
|
|
api: PigApiClient,
|
|
tokens: readonly string[],
|
|
): Promise<CommandResult> {
|
|
const action = tokens[0];
|
|
if (!action || action === 'list') {
|
|
const parsed = parseOptions(action ? tokens.slice(1) : tokens, {});
|
|
expectPositionals(parsed.positionals, 0, 0, 'pig commitments [list]');
|
|
return { kind: 'commitments', data: await api.request('/api/commitments') };
|
|
}
|
|
if (action === 'create') {
|
|
const parsed = parseOptions(tokens.slice(1), COMMITMENT_OPTIONS);
|
|
expectPositionals(parsed.positionals, 0, 0, 'pig commitments create [fields]');
|
|
return {
|
|
kind: 'commitment-created',
|
|
data: await api.request('/api/commitments', {
|
|
method: 'POST',
|
|
body: buildCommitmentBody(parsed.options, false),
|
|
}),
|
|
};
|
|
}
|
|
if (action === 'update') {
|
|
const parsed = parseOptions(tokens.slice(1), COMMITMENT_UPDATE_OPTIONS);
|
|
expectPositionals(parsed.positionals, 1, 1, 'pig commitments update <id> [fields]');
|
|
return {
|
|
kind: 'commitment-updated',
|
|
data: await api.request(
|
|
`/api/commitments/${encodeURIComponent(parsed.positionals[0]!)}`,
|
|
{ method: 'PATCH', body: buildCommitmentBody(parsed.options, true) },
|
|
),
|
|
};
|
|
}
|
|
usage('Usage: pig commitments list|create|update');
|
|
}
|
|
|
|
async function allocationsCommand(
|
|
api: PigApiClient,
|
|
tokens: readonly string[],
|
|
): Promise<CommandResult> {
|
|
const action = tokens[0];
|
|
if (!action || action === 'list') {
|
|
const parsed = parseOptions(action ? tokens.slice(1) : tokens, {});
|
|
expectPositionals(parsed.positionals, 0, 0, 'pig allocations [list]');
|
|
return { kind: 'allocations', data: await api.request('/api/allocations') };
|
|
}
|
|
if (action === 'create') {
|
|
const parsed = parseOptions(tokens.slice(1), {
|
|
...ALLOCATION_BASE_OPTIONS,
|
|
status: 'value',
|
|
});
|
|
expectPositionals(parsed.positionals, 0, 0, 'pig allocations create [fields]');
|
|
const body = buildAllocationBase(parsed.options, true);
|
|
body.status = requiredOption(parsed.options, 'status');
|
|
return {
|
|
kind: 'allocation-created',
|
|
data: await api.request('/api/allocations', { method: 'POST', body }),
|
|
};
|
|
}
|
|
if (action === 'hold') {
|
|
const parsed = parseOptions(tokens.slice(1), {
|
|
...ALLOCATION_BASE_OPTIONS,
|
|
'hold-expires-at': 'value',
|
|
'hold-opportunity-cost-cents': 'value',
|
|
});
|
|
expectPositionals(parsed.positionals, 0, 0, 'pig allocations hold [fields]');
|
|
const body = buildAllocationBase(parsed.options, false);
|
|
body.holdExpiresAt = requiredOption(parsed.options, 'hold-expires-at');
|
|
setDefined(
|
|
body,
|
|
'holdOpportunityCostCents',
|
|
numeric(parsed.options, 'hold-opportunity-cost-cents', {
|
|
integer: true,
|
|
minimum: 0,
|
|
nullable: true,
|
|
}),
|
|
);
|
|
return {
|
|
kind: 'allocation-held',
|
|
data: await api.request('/api/allocations/holds', { method: 'POST', body }),
|
|
};
|
|
}
|
|
if (action === 'release') {
|
|
const parsed = parseOptions(tokens.slice(1), { reason: 'value' });
|
|
expectPositionals(parsed.positionals, 1, 1, 'pig allocations release <id> [--reason TEXT]');
|
|
const body: Record<string, unknown> = {};
|
|
setDefined(body, 'reason', option(parsed.options, 'reason'));
|
|
return {
|
|
kind: 'allocation-released',
|
|
data: await api.request(
|
|
`/api/allocations/${encodeURIComponent(parsed.positionals[0]!)}/release`,
|
|
{ method: 'POST', body },
|
|
),
|
|
};
|
|
}
|
|
usage('Usage: pig allocations list|create|hold|release');
|
|
}
|
|
|
|
async function capacityCommand(
|
|
api: PigApiClient,
|
|
tokens: readonly string[],
|
|
): Promise<CommandResult> {
|
|
const action = tokens[0];
|
|
if (action === 'availability') {
|
|
const parsed = parseOptions(tokens.slice(1), { 'gpu-type': 'value' });
|
|
expectPositionals(parsed.positionals, 0, 0, 'pig capacity availability [options]');
|
|
return {
|
|
kind: 'capacity-availability',
|
|
data: await api.request('/api/capacity/availability', {
|
|
query: { gpuType: option(parsed.options, 'gpu-type') },
|
|
}),
|
|
};
|
|
}
|
|
if (action === 'match') {
|
|
const parsed = parseOptions(tokens.slice(1), CAPACITY_MATCH_OPTIONS);
|
|
expectPositionals(parsed.positionals, 0, 0, 'pig capacity match [options]');
|
|
const body: Record<string, unknown> = {
|
|
gpuCount: requiredNumeric(parsed.options, 'gpu-count', { integer: true, minimum: 1 }),
|
|
};
|
|
setDefined(body, 'gpuType', option(parsed.options, 'gpu-type'));
|
|
setDefined(body, 'gpuTypeAlternatives', repeated(parsed.options, 'gpu-type-alternative'));
|
|
setDefined(body, 'totalGpuHours', numeric(parsed.options, 'total-gpu-hours', { minimum: 0.01 }));
|
|
if (parsed.options.get('fast-fabric') === true) body.requiresHighSpeedInterconnect = true;
|
|
setDefined(body, 'minSecurityTier', option(parsed.options, 'min-security-tier'));
|
|
setDefined(body, 'startsAt', option(parsed.options, 'starts-at'));
|
|
setDefined(body, 'endsAt', option(parsed.options, 'ends-at'));
|
|
setDefined(
|
|
body,
|
|
'maxPricePerGpuHourCents',
|
|
numeric(parsed.options, 'max-price-cents', { integer: true, minimum: 1 }),
|
|
);
|
|
return {
|
|
kind: 'capacity-matches',
|
|
data: await api.request('/api/capacity/match', { method: 'POST', body }),
|
|
};
|
|
}
|
|
if (action === 'search') {
|
|
const parsed = parseOptions(tokens.slice(1), {
|
|
'gpu-type': 'value',
|
|
'min-gpu-count': 'value',
|
|
'max-price-cents': 'value',
|
|
'fast-fabric': 'flag',
|
|
limit: 'value',
|
|
});
|
|
expectPositionals(parsed.positionals, 0, 0, 'pig capacity search [options]');
|
|
return {
|
|
kind: 'inventory',
|
|
data: await api.request('/api/inventory', {
|
|
query: {
|
|
gpuType: option(parsed.options, 'gpu-type'),
|
|
minGpuCount:
|
|
numeric(parsed.options, 'min-gpu-count', { integer: true, minimum: 1 }) ?? undefined,
|
|
maxPriceCents:
|
|
numeric(parsed.options, 'max-price-cents', { integer: true, minimum: 1 }) ?? undefined,
|
|
fastFabric: parsed.options.get('fast-fabric') === true ? true : undefined,
|
|
limit:
|
|
numeric(parsed.options, 'limit', { integer: true, minimum: 1, maximum: 100 }) ??
|
|
undefined,
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
if (action === 'margin') {
|
|
const parsed = parseOptions(tokens.slice(1), {});
|
|
expectPositionals(parsed.positionals, 0, 0, 'pig capacity margin');
|
|
return { kind: 'margin', data: await api.request('/api/capacity/margin') };
|
|
}
|
|
if (action === 'idle') {
|
|
const parsed = parseOptions(tokens.slice(1), {
|
|
threshold: 'value',
|
|
'within-days': 'value',
|
|
});
|
|
expectPositionals(parsed.positionals, 0, 0, 'pig capacity idle [options]');
|
|
return {
|
|
kind: 'idle',
|
|
data: await api.request('/api/capacity/idle', {
|
|
query: {
|
|
threshold:
|
|
numeric(parsed.options, 'threshold', { minimum: 0, maximum: 1 }) ?? undefined,
|
|
withinDays:
|
|
numeric(parsed.options, 'within-days', { integer: true, minimum: 1 }) ?? undefined,
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
usage('Usage: pig capacity availability|match|search|margin|idle');
|
|
}
|
|
|
|
async function dispatch(api: PigApiClient, tokens: readonly string[]): Promise<CommandResult> {
|
|
const command = tokens[0];
|
|
const rest = tokens.slice(1);
|
|
if (command === 'me') {
|
|
const parsed = parseOptions(rest, {});
|
|
expectPositionals(parsed.positionals, 0, 0, 'pig me');
|
|
return { kind: 'me', data: await api.request('/api/me') };
|
|
}
|
|
if (command === 'accounts') return accountsCommand(api, rest);
|
|
if (command === 'deals') return dealsCommand(api, rest);
|
|
if (command === 'commitments') return commitmentsCommand(api, rest);
|
|
if (command === 'allocations') return allocationsCommand(api, rest);
|
|
if (command === 'capacity') return capacityCommand(api, rest);
|
|
usage(`Unknown command ${JSON.stringify(command)}. Run pig --help for usage.`);
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function summaryLine(value: unknown): string {
|
|
const row = asRecord(value);
|
|
if (!row) return String(value);
|
|
const nested = asRecord(row.commitment) ?? asRecord(row.deal) ?? row;
|
|
const name = typeof nested.name === 'string' ? nested.name : undefined;
|
|
const id = typeof nested.id === 'string' ? nested.id : undefined;
|
|
const gpuType = typeof nested.gpuType === 'string' ? nested.gpuType : undefined;
|
|
const gpuCount = typeof nested.gpuCount === 'number' ? nested.gpuCount : undefined;
|
|
const stage = typeof nested.stage === 'string' ? nested.stage : undefined;
|
|
const status = typeof nested.status === 'string' ? nested.status : undefined;
|
|
const accountName = typeof row.accountName === 'string' ? row.accountName : undefined;
|
|
return [
|
|
name ?? accountName ?? id ?? 'record',
|
|
gpuType,
|
|
gpuCount ? `${gpuCount} GPUs` : undefined,
|
|
stage ?? status,
|
|
id,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' | ');
|
|
}
|
|
|
|
function humanOutput(result: CommandResult): string {
|
|
const data = result.data;
|
|
if (result.kind === 'me') {
|
|
const me = asRecord(data);
|
|
if (me) {
|
|
const teams = Array.isArray(me.teams)
|
|
? me.teams.map((team) => {
|
|
const value = asRecord(team);
|
|
return value ? `${String(value.team)} (${String(value.role)})` : String(team);
|
|
})
|
|
: [];
|
|
return [
|
|
`${String(me.name ?? 'Unknown')} <${String(me.email ?? 'no email')}>`,
|
|
teams.length ? `Teams: ${teams.join(', ')}` : 'Teams: none',
|
|
me.isPlatformAdmin === true ? 'Platform admin.' : undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(data)) {
|
|
if (data.length === 0) return 'No results.';
|
|
return `${data.length} result(s)\n${data.map((row) => `- ${summaryLine(row)}`).join('\n')}`;
|
|
}
|
|
|
|
if (result.kind.endsWith('-deals')) {
|
|
const value = asRecord(data);
|
|
if (value && Array.isArray(value.deals)) {
|
|
return `${value.deals.length} deal(s)\n${value.deals
|
|
.map((row) => `- ${summaryLine(row)}`)
|
|
.join('\n')}`;
|
|
}
|
|
}
|
|
|
|
if (result.kind === 'margin') {
|
|
const value = asRecord(data);
|
|
const totals = asRecord(value?.totals);
|
|
if (totals) {
|
|
const marginPct =
|
|
typeof totals.grossMarginPct === 'number'
|
|
? `${(totals.grossMarginPct * 100).toFixed(1)}%`
|
|
: 'n/a';
|
|
return [
|
|
`Revenue: ${String(totals.revenueCents ?? 0)} cents`,
|
|
`Cost: ${String(totals.costCents ?? 0)} cents`,
|
|
`Gross margin: ${String(totals.grossMarginCents ?? 0)} cents (${marginPct})`,
|
|
`Idle: ${String(totals.idleGpuHours ?? 0)} GPU-hours`,
|
|
].join('\n');
|
|
}
|
|
}
|
|
|
|
if (
|
|
result.kind.endsWith('-created') ||
|
|
result.kind.endsWith('-updated') ||
|
|
result.kind.endsWith('-held') ||
|
|
result.kind.endsWith('-released')
|
|
) {
|
|
return `${result.kind.replaceAll('-', ' ')}: ${summaryLine(data)}`;
|
|
}
|
|
|
|
return JSON.stringify(data, null, 2);
|
|
}
|
|
|
|
function errorShape(error: unknown): { error: Record<string, unknown>; exitCode: number } {
|
|
if (error instanceof PigApiError) {
|
|
return {
|
|
error: {
|
|
code: error.code,
|
|
message: error.message,
|
|
...(error.status !== undefined ? { status: error.status } : {}),
|
|
...(error.details !== undefined ? { details: error.details } : {}),
|
|
},
|
|
exitCode: 1,
|
|
};
|
|
}
|
|
if (error instanceof CliUsageError) {
|
|
return { error: { code: error.code, message: error.message }, exitCode: 2 };
|
|
}
|
|
return {
|
|
error: {
|
|
code: 'internal_error',
|
|
message: error instanceof Error ? error.message : 'Unexpected CLI failure.',
|
|
},
|
|
exitCode: 1,
|
|
};
|
|
}
|
|
|
|
function validateApiUrl(value: string): string {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
usage('PIG_API_URL/--api-url must be a valid HTTP(S) URL.');
|
|
}
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
usage('PIG_API_URL/--api-url must use HTTP or HTTPS.');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export async function runCli(args: readonly string[], options: RunCliOptions = {}): Promise<number> {
|
|
const env = options.env ?? process.env;
|
|
const stdout = options.stdout ?? ((value: string) => process.stdout.write(value));
|
|
const stderr = options.stderr ?? ((value: string) => process.stderr.write(value));
|
|
const secrets = candidateSecrets(args, env);
|
|
const jsonRequested = args.includes('--json');
|
|
|
|
try {
|
|
const global = parseGlobals(args);
|
|
if (global.help || global.tokens.length === 0) {
|
|
stdout(global.json ? `${JSON.stringify({ usage: HELP })}\n` : `${HELP}\n`);
|
|
return 0;
|
|
}
|
|
if (global.version) {
|
|
stdout(global.json ? `${JSON.stringify({ version: VERSION })}\n` : `${VERSION}\n`);
|
|
return 0;
|
|
}
|
|
|
|
const apiUrl = global.apiUrl ?? env.PIG_API_URL;
|
|
const apiKey = global.apiKey ?? env.PIG_API_KEY;
|
|
if (!apiUrl) usage('Set PIG_API_URL or pass --api-url.');
|
|
if (!apiKey) usage('Set PIG_API_KEY or pass --api-key.');
|
|
|
|
const api = new PigApiClient({
|
|
baseUrl: validateApiUrl(apiUrl),
|
|
apiKey,
|
|
fetchImpl: options.fetchImpl,
|
|
});
|
|
const result = await dispatch(api, global.tokens);
|
|
const output = global.json ? JSON.stringify(result.data ?? null) : humanOutput(result);
|
|
stdout(`${redact(output, secrets)}\n`);
|
|
return 0;
|
|
} catch (error) {
|
|
const shaped = errorShape(error);
|
|
const output = jsonRequested
|
|
? JSON.stringify({ error: shaped.error })
|
|
: `Error [${String(shaped.error.code)}]: ${String(shaped.error.message)}`;
|
|
stderr(`${redact(output, secrets)}\n`);
|
|
return shaped.exitCode;
|
|
}
|
|
}
|