This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
export interface PigApiClientOptions {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PigRequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export class PigApiError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PigApiError';
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export class PigApiClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(private readonly options: PigApiClientOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.timeoutMs = options.timeoutMs ?? 30_000;
|
||||
}
|
||||
|
||||
async request<T>(path: string, options: PigRequestOptions = {}): Promise<T> {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
for (const [name, value] of Object.entries(options.query ?? {})) {
|
||||
if (value !== undefined) url.searchParams.set(name, String(value));
|
||||
}
|
||||
|
||||
const hasBody = options.body !== undefined;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImpl(url, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${this.options.apiKey}`,
|
||||
...(hasBody ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
body: hasBody ? JSON.stringify(options.body) : undefined,
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown network failure';
|
||||
throw new PigApiError('network_error', `Could not reach the PIG API: ${message}`);
|
||||
}
|
||||
|
||||
const raw = await response.text();
|
||||
let payload: unknown = null;
|
||||
if (raw) {
|
||||
try {
|
||||
payload = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
if (response.ok) {
|
||||
throw new PigApiError(
|
||||
'invalid_response',
|
||||
'The PIG API returned a non-JSON response.',
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
payload = raw.slice(0, 1_000);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = record(payload);
|
||||
const nestedError = record(body?.error);
|
||||
const code =
|
||||
typeof body?.code === 'string'
|
||||
? body.code
|
||||
: typeof nestedError?.code === 'string'
|
||||
? nestedError.code
|
||||
: `http_${response.status}`;
|
||||
const message =
|
||||
typeof body?.error === 'string'
|
||||
? body.error
|
||||
: typeof nestedError?.message === 'string'
|
||||
? nestedError.message
|
||||
: `PIG API request failed with HTTP ${response.status}.`;
|
||||
throw new PigApiError(code, message, response.status, payload);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
|
||||
import { runCli } from './cli';
|
||||
|
||||
process.exitCode = await runCli(process.argv.slice(2));
|
||||
Reference in New Issue
Block a user