@torknetwork/cli
Advanced tools
| import { Command } from '@oclif/core'; | ||
| import { type ReceiptSummary } from '../lib/api.js'; | ||
| export default class Logs extends Command { | ||
| static description: string; | ||
| static enableJsonFlag: boolean; | ||
| static examples: string[]; | ||
| static flags: { | ||
| follow: import("@oclif/core/interfaces").BooleanFlag<boolean>; | ||
| limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>; | ||
| }; | ||
| run(): Promise<{ | ||
| entries: ReceiptSummary[]; | ||
| total?: number; | ||
| }>; | ||
| } |
| import { Command, Flags } from '@oclif/core'; | ||
| import chalk from 'chalk'; | ||
| import { setTimeout as sleep } from 'node:timers/promises'; | ||
| import { ApiError, AuthError, NetworkError, TorkApi } from '../lib/api.js'; | ||
| import { getApiKey, getBaseUrl } from '../lib/config.js'; | ||
| import { ARROW } from '../lib/output.js'; | ||
| import { formatLogRow, logHeader, selectNew } from '../lib/receipts.js'; | ||
| const POLL_INTERVAL_MS = 5000; | ||
| export default class Logs extends Command { | ||
| static description = 'Show recent governance decisions for your org (backed by the receipts feed)'; | ||
| static enableJsonFlag = true; | ||
| static examples = ['<%= config.bin %> logs', '<%= config.bin %> logs --limit 50', '<%= config.bin %> logs --follow']; | ||
| static flags = { | ||
| follow: Flags.boolean({ description: 'poll for new decisions every 5s (Ctrl+C to stop)', exclusive: ['json'] }), | ||
| limit: Flags.integer({ default: 20, description: 'number of decisions to show', min: 1 }), | ||
| }; | ||
| async run() { | ||
| const { flags } = await this.parse(Logs); | ||
| const key = getApiKey(); | ||
| if (key === undefined) { | ||
| this.error(`Not authenticated. Run ${chalk.bold('tork login')} first (or set TORK_API_KEY).`, { exit: 1 }); | ||
| } | ||
| const api = new TorkApi(getBaseUrl(), key); | ||
| let page; | ||
| try { | ||
| page = await api.receipts(flags.limit); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof AuthError) { | ||
| this.error(`Authentication failed (HTTP ${error.status}). Your key may be revoked — run ${chalk.bold('tork login')} again.`, { exit: 1 }); | ||
| } | ||
| if (error instanceof ApiError && error.status === 404) { | ||
| this.error('The decision log requires server support — the receipts feed was not found on this server. Coming soon.', { exit: 1 }); | ||
| } | ||
| if (error instanceof NetworkError) { | ||
| this.error(`Network error: ${error.message}`, { exit: 1 }); | ||
| } | ||
| if (error instanceof ApiError) { | ||
| this.error(`Could not fetch the decision log (HTTP ${error.status}): ${error.bodyExcerpt || 'no response body'}`, { exit: 1 }); | ||
| } | ||
| throw error; | ||
| } | ||
| const seen = new Set(); | ||
| const entries = selectNew(page.receipts, seen); | ||
| if (!this.jsonEnabled()) { | ||
| if (entries.length === 0) { | ||
| this.log(chalk.dim('No governance decisions recorded yet — run `tork test` to send one.')); | ||
| } | ||
| else { | ||
| this.log(logHeader()); | ||
| for (const entry of entries) | ||
| this.log(formatLogRow(entry)); | ||
| } | ||
| } | ||
| if (flags.follow) { | ||
| this.log(chalk.dim(`… following (polling every ${POLL_INTERVAL_MS / 1000}s) — Ctrl+C to stop`)); | ||
| while (true) { | ||
| await sleep(POLL_INTERVAL_MS); | ||
| try { | ||
| const next = await api.receipts(Math.max(flags.limit, 20)); | ||
| for (const entry of selectNew(next.receipts, seen)) | ||
| this.log(formatLogRow(entry)); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof AuthError) { | ||
| this.error(`Authentication failed (HTTP ${error.status}) while following — stopping.`, { exit: 1 }); | ||
| } | ||
| // transient network/server hiccups should not kill a follow session | ||
| this.log(`${ARROW} ${chalk.dim(`poll failed (${error instanceof Error ? error.message : String(error)}) — retrying`)}`); | ||
| } | ||
| } | ||
| } | ||
| return { entries, ...(page.total === undefined ? {} : { total: page.total }) }; | ||
| } | ||
| } |
| import { Command } from '@oclif/core'; | ||
| import { type ReceiptSummary } from '../lib/api.js'; | ||
| interface Verification { | ||
| method: 'server' | 'structure'; | ||
| verified: boolean; | ||
| detail: string; | ||
| } | ||
| interface ReceiptsResult { | ||
| receipts?: ReceiptSummary[]; | ||
| total?: number; | ||
| receipt?: Record<string, unknown>; | ||
| verification?: Verification; | ||
| } | ||
| export default class Receipts extends Command { | ||
| static args: { | ||
| receipt_id: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>; | ||
| }; | ||
| static description: string; | ||
| static enableJsonFlag: boolean; | ||
| static examples: string[]; | ||
| static flags: { | ||
| limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>; | ||
| }; | ||
| run(): Promise<ReceiptsResult>; | ||
| private list; | ||
| private show; | ||
| /** | ||
| * Server-side merkle verification needs a fingerprint, which the receipts | ||
| * read API does not currently return — in that case fall back to a local | ||
| * structural check and say so. Never claim cryptographic verification here. | ||
| */ | ||
| private verify; | ||
| private rethrow; | ||
| } | ||
| export {}; |
| import { Args, Command, Flags } from '@oclif/core'; | ||
| import chalk from 'chalk'; | ||
| import { ApiError, AuthError, NetworkError, TorkApi } from '../lib/api.js'; | ||
| import { getApiKey, getBaseUrl } from '../lib/config.js'; | ||
| import { ARROW, CHECK, CROSS } from '../lib/output.js'; | ||
| import { checkReceiptStructure, colorAction, formatLogRow, formatTimestamp, logHeader, selectNew } from '../lib/receipts.js'; | ||
| export default class Receipts extends Command { | ||
| static args = { | ||
| receipt_id: Args.string({ description: 'fetch and verify a single receipt by id', required: false }), | ||
| }; | ||
| static description = 'List governance receipts, or fetch and verify one by id'; | ||
| static enableJsonFlag = true; | ||
| static examples = [ | ||
| '<%= config.bin %> receipts', | ||
| '<%= config.bin %> receipts --limit 50', | ||
| '<%= config.bin %> receipts tork_rcpt_1784117133408_6ce3853120157b4b', | ||
| ]; | ||
| static flags = { | ||
| limit: Flags.integer({ default: 20, description: 'number of receipts to list', min: 1 }), | ||
| }; | ||
| async run() { | ||
| const { args, flags } = await this.parse(Receipts); | ||
| const key = getApiKey(); | ||
| if (key === undefined) { | ||
| this.error(`Not authenticated. Run ${chalk.bold('tork login')} first (or set TORK_API_KEY).`, { exit: 1 }); | ||
| } | ||
| const api = new TorkApi(getBaseUrl(), key); | ||
| return args.receipt_id === undefined ? this.list(api, flags.limit) : this.show(api, args.receipt_id); | ||
| } | ||
| async list(api, limit) { | ||
| let page; | ||
| try { | ||
| page = await api.receipts(limit); | ||
| } | ||
| catch (error) { | ||
| this.rethrow(error, 'fetch receipts'); | ||
| } | ||
| const receipts = selectNew(page.receipts, new Set()).reverse(); // newest first, deduped | ||
| if (!this.jsonEnabled()) { | ||
| if (receipts.length === 0) { | ||
| this.log(chalk.dim('No receipts yet — run `tork test` to generate one.')); | ||
| } | ||
| else { | ||
| this.log(logHeader()); | ||
| for (const receipt of receipts) | ||
| this.log(formatLogRow(receipt)); | ||
| if (page.total !== undefined) | ||
| this.log(chalk.dim(`${receipts.length} of ${page.total} receipts`)); | ||
| } | ||
| } | ||
| return { receipts, ...(page.total === undefined ? {} : { total: page.total }) }; | ||
| } | ||
| async show(api, id) { | ||
| let receipt; | ||
| try { | ||
| receipt = await api.receipt(id); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof ApiError && !(error instanceof AuthError) && error.status === 404) { | ||
| this.error(`Receipt ${chalk.bold(id)} was not found.`, { exit: 1 }); | ||
| } | ||
| this.rethrow(error, 'fetch the receipt'); | ||
| } | ||
| const verification = await this.verify(api, receipt); | ||
| if (!this.jsonEnabled()) { | ||
| const field = (value) => typeof value === 'string' && value !== '' ? value : chalk.dim('— not exposed by the receipts API'); | ||
| const piiTypes = Array.isArray(receipt.pii_types) ? ` (${receipt.pii_types.join(', ')})` : ''; | ||
| this.log(`receipt: ${chalk.bold(String(receipt.receipt_id ?? id))}`); | ||
| this.log(`timestamp: ${formatTimestamp(typeof receipt.timestamp === 'string' ? receipt.timestamp : undefined)}`); | ||
| this.log(`action: ${colorAction(typeof receipt.action === 'string' ? receipt.action : undefined)}`); | ||
| this.log(`pii: ${String(receipt.pii_count ?? 0)}${piiTypes}`); | ||
| this.log(`policy: ${field(receipt.policy_version)}`); | ||
| this.log(`content hash: ${field(receipt.content_hash)}`); | ||
| this.log(`fingerprint: ${field(receipt.fingerprint)}`); | ||
| this.log(`governance DNA: ${field(receipt.governance_dna === undefined ? undefined : JSON.stringify(receipt.governance_dna))}`); | ||
| this.log(`signature: ${typeof receipt.hmac_signature === 'string' && receipt.hmac_signature !== '' ? 'yes' : `no ${chalk.dim('(hmac_signature not exposed by the receipts API)')}`}`); | ||
| this.log(`verification: ${verification.verified ? CHECK : verification.method === 'structure' ? ARROW : CROSS} ${verification.detail}`); | ||
| } | ||
| if (verification.method === 'server' && !verification.verified) | ||
| process.exitCode = 1; | ||
| return { receipt, verification }; | ||
| } | ||
| /** | ||
| * Server-side merkle verification needs a fingerprint, which the receipts | ||
| * read API does not currently return — in that case fall back to a local | ||
| * structural check and say so. Never claim cryptographic verification here. | ||
| */ | ||
| async verify(api, receipt) { | ||
| const fingerprint = receipt.fingerprint; | ||
| const orgId = receipt.organization_id ?? receipt.org_id; | ||
| const timestamp = receipt.timestamp; | ||
| const date = typeof timestamp === 'string' ? timestamp.slice(0, 10) : undefined; | ||
| if (typeof fingerprint === 'string' && fingerprint !== '' && typeof orgId === 'string' && date !== undefined) { | ||
| let result; | ||
| try { | ||
| result = await api.verify({ date, fingerprint, org_id: orgId }); | ||
| } | ||
| catch (error) { | ||
| return { | ||
| detail: `server verification unavailable (${error instanceof Error ? error.message : String(error)}) — showing receipt unverified`, | ||
| method: 'server', | ||
| verified: false, | ||
| }; | ||
| } | ||
| if (result.verified === true) { | ||
| return { detail: 'verified against the server merkle anchor', method: 'server', verified: true }; | ||
| } | ||
| return { detail: `failed — ${typeof result.message === 'string' ? result.message : 'server did not verify this fingerprint'}`, method: 'server', verified: false }; | ||
| } | ||
| const check = checkReceiptStructure(receipt); | ||
| const label = 'structure check only — server-side signature verification not exposed for this receipt'; | ||
| if (check.ok) { | ||
| return { detail: `${label} (structural fields OK; not returned: ${check.notExposed.join(', ') || 'none'})`, method: 'structure', verified: false }; | ||
| } | ||
| return { detail: `${label} — structural problems: ${check.problems.join('; ')}`, method: 'structure', verified: false }; | ||
| } | ||
| rethrow(error, doing) { | ||
| if (error instanceof AuthError) { | ||
| this.error(`Authentication failed (HTTP ${error.status}). Your key may be revoked — run ${chalk.bold('tork login')} again.`, { exit: 1 }); | ||
| } | ||
| if (error instanceof NetworkError) { | ||
| this.error(`Network error: ${error.message}`, { exit: 1 }); | ||
| } | ||
| if (error instanceof ApiError) { | ||
| this.error(`Could not ${doing} (HTTP ${error.status}): ${error.bodyExcerpt || 'no response body'}`, { exit: 1 }); | ||
| } | ||
| throw error; | ||
| } | ||
| } |
| import { Command } from '@oclif/core'; | ||
| import { type UsageSummary } from '../lib/usage.js'; | ||
| export default class Usage extends Command { | ||
| static description: string; | ||
| static enableJsonFlag: boolean; | ||
| static examples: string[]; | ||
| run(): Promise<{ | ||
| summary: UsageSummary; | ||
| raw: Record<string, unknown>; | ||
| }>; | ||
| } |
| import { Command } from '@oclif/core'; | ||
| import chalk from 'chalk'; | ||
| import { ApiError, AuthError, NetworkError, TorkApi } from '../lib/api.js'; | ||
| import { getApiKey, getBaseUrl } from '../lib/config.js'; | ||
| import { ARROW, CROSS } from '../lib/output.js'; | ||
| import { CRITICAL_THRESHOLD, formatBar, normalizeUsage, WARN_THRESHOLD } from '../lib/usage.js'; | ||
| export default class Usage extends Command { | ||
| static description = 'Show plan usage for the authenticated org (calls used / limit / remaining)'; | ||
| static enableJsonFlag = true; | ||
| static examples = ['<%= config.bin %> usage', '<%= config.bin %> usage --json']; | ||
| async run() { | ||
| const key = getApiKey(); | ||
| if (key === undefined) { | ||
| this.error(`Not authenticated. Run ${chalk.bold('tork login')} first (or set TORK_API_KEY).`, { exit: 1 }); | ||
| } | ||
| const api = new TorkApi(getBaseUrl(), key); | ||
| let raw; | ||
| try { | ||
| raw = await api.usage(); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof AuthError) { | ||
| this.error(`Authentication failed (HTTP ${error.status}). Your key may be revoked — run ${chalk.bold('tork login')} again.`, { exit: 1 }); | ||
| } | ||
| if (error instanceof ApiError && error.status === 404) { | ||
| this.error('Plan usage requires server support — /api/v1/usage was not found on this server. Coming soon.', { exit: 1 }); | ||
| } | ||
| if (error instanceof NetworkError) { | ||
| this.error(`Network error: ${error.message}`, { exit: 1 }); | ||
| } | ||
| if (error instanceof ApiError) { | ||
| this.error(`Could not fetch usage (HTTP ${error.status}): ${error.bodyExcerpt || 'no response body'}`, { exit: 1 }); | ||
| } | ||
| throw error; | ||
| } | ||
| const summary = normalizeUsage(raw); | ||
| if (!this.jsonEnabled()) { | ||
| if (summary.planName !== undefined) | ||
| this.log(`plan: ${chalk.bold(summary.planName)}`); | ||
| if (summary.callsUsed !== undefined && summary.callsLimit !== undefined) { | ||
| const percent = summary.percentUsed ?? 0; | ||
| this.log(`usage: ${formatBar(percent)} ${summary.callsUsed} / ${summary.callsLimit} (${percent.toFixed(1)}%)`); | ||
| } | ||
| else { | ||
| this.log(`usage: ${chalk.dim('not reported — see --json for the raw response')}`); | ||
| } | ||
| if (summary.callsRemaining !== undefined) | ||
| this.log(`remaining: ${summary.callsRemaining}`); | ||
| if (summary.periodStart !== undefined || summary.periodEnd !== undefined) { | ||
| const start = summary.periodStart ?? '—'; | ||
| this.log(`period: ${summary.periodEnd === undefined ? `started ${start}` : `${start} → ${summary.periodEnd}`}`); | ||
| } | ||
| const percent = summary.percentUsed; | ||
| if (percent !== undefined && percent >= CRITICAL_THRESHOLD) { | ||
| this.log(`${CROSS} ${chalk.red(`Plan limit reached (${percent.toFixed(1)}%) — governance calls may be rejected. Upgrade at https://tork.network.`)}`); | ||
| } | ||
| else if (percent !== undefined && percent >= WARN_THRESHOLD) { | ||
| this.log(`${ARROW} ${chalk.yellow(`Usage at ${percent.toFixed(1)}% of the plan limit.`)}`); | ||
| } | ||
| } | ||
| return { raw, summary }; | ||
| } | ||
| } |
| export declare class OpError extends Error { | ||
| name: string; | ||
| } | ||
| /** | ||
| * Resolves a 1Password secret reference (op://Vault/Item/field) via `op read`. | ||
| * The reference is passed as a single argv element — never through a shell — | ||
| * and the resolved value is returned to the caller without being logged. | ||
| */ | ||
| export declare function resolveOpReference(ref: string): string; |
| import { spawnSync } from 'node:child_process'; | ||
| export class OpError extends Error { | ||
| name = 'OpError'; | ||
| } | ||
| /** | ||
| * Resolves a 1Password secret reference (op://Vault/Item/field) via `op read`. | ||
| * The reference is passed as a single argv element — never through a shell — | ||
| * and the resolved value is returned to the caller without being logged. | ||
| */ | ||
| export function resolveOpReference(ref) { | ||
| if (!ref.startsWith('op://')) { | ||
| throw new OpError(`Invalid 1Password reference "${ref}" — it must start with "op://" (e.g. op://Vault/Item/field).`); | ||
| } | ||
| const result = spawnSync('op', ['read', ref], { encoding: 'utf8' }); | ||
| if (result.error !== undefined) { | ||
| if (result.error.code === 'ENOENT') { | ||
| throw new OpError('1Password CLI (`op`) is not installed. Install it with `brew install 1password-cli` and sign in with `op signin`.'); | ||
| } | ||
| throw new OpError(`Could not run \`op read\`: ${result.error.message}`); | ||
| } | ||
| if (result.status !== 0) { | ||
| const stderr = (result.stderr ?? '').trim(); | ||
| throw new OpError(`\`op read ${ref}\` failed (exit ${result.status})${stderr === '' ? '' : `: ${stderr}`}`); | ||
| } | ||
| const value = (result.stdout ?? '').trim(); | ||
| if (value === '') { | ||
| throw new OpError(`\`op read ${ref}\` returned an empty value.`); | ||
| } | ||
| return value; | ||
| } |
| import type { ReceiptSummary } from './api.js'; | ||
| /** Last underscore-separated segment of a receipt id (the unique hash), prefixed with "…". */ | ||
| export declare function shortReceiptId(id: string): string; | ||
| export declare function colorAction(action: string | undefined): string; | ||
| /** Uniform UTC "YYYY-MM-DD HH:MM:SS" regardless of the offset the API sends. */ | ||
| export declare function formatTimestamp(iso: string | undefined): string; | ||
| export declare function formatLogRow(receipt: ReceiptSummary): string; | ||
| export declare function logHeader(): string; | ||
| /** | ||
| * Returns receipts not yet in `seen` in chronological order (the API lists | ||
| * newest first) and records them as seen. Used by `tork logs --follow`. | ||
| */ | ||
| export declare function selectNew(receipts: ReceiptSummary[], seen: Set<string>): ReceiptSummary[]; | ||
| export interface StructureCheck { | ||
| ok: boolean; | ||
| problems: string[]; | ||
| /** Integrity fields the receipts API did not return (absence ≠ failure). */ | ||
| notExposed: string[]; | ||
| } | ||
| /** | ||
| * Local structural sanity check for a fetched receipt. This is NOT | ||
| * cryptographic verification — callers must label it accordingly. | ||
| */ | ||
| export declare function checkReceiptStructure(receipt: Record<string, unknown>): StructureCheck; |
| import chalk from 'chalk'; | ||
| const HEX_64 = /^[\da-f]{64}$/i; | ||
| const RECEIPT_ID_PATTERN = /^tork_rcpt_/; | ||
| const SHORT_ID_TAIL = 16; | ||
| /** Last underscore-separated segment of a receipt id (the unique hash), prefixed with "…". */ | ||
| export function shortReceiptId(id) { | ||
| if (id.length <= SHORT_ID_TAIL + 2) | ||
| return id; | ||
| const tail = id.includes('_') ? id.slice(id.lastIndexOf('_') + 1) : id.slice(-SHORT_ID_TAIL); | ||
| return `…${tail}`; | ||
| } | ||
| export function colorAction(action) { | ||
| if (action === undefined || action === '') | ||
| return chalk.dim('—'); | ||
| const lower = action.toLowerCase(); | ||
| if (lower === 'allow') | ||
| return chalk.green(action); | ||
| if (lower === 'redact') | ||
| return chalk.yellow(action); | ||
| if (lower === 'deny' || lower === 'block') | ||
| return chalk.red(action); | ||
| if (lower === 'escalate') | ||
| return chalk.magenta(action); | ||
| return action; | ||
| } | ||
| /** Uniform UTC "YYYY-MM-DD HH:MM:SS" regardless of the offset the API sends. */ | ||
| export function formatTimestamp(iso) { | ||
| if (iso === undefined || iso === '') | ||
| return '—'; | ||
| const date = new Date(iso); | ||
| if (Number.isNaN(date.getTime())) | ||
| return iso; | ||
| return date.toISOString().replace('T', ' ').slice(0, 19); | ||
| } | ||
| export function formatLogRow(receipt) { | ||
| const time = formatTimestamp(receipt.timestamp).padEnd(19); | ||
| const action = receipt.action ?? ''; | ||
| const actionCell = colorAction(receipt.action) + ' '.repeat(Math.max(0, 9 - (action === '' ? 1 : action.length))); | ||
| const pii = String(receipt.pii_count ?? 0).padStart(3); | ||
| const latency = `${String(receipt.latency_ms ?? '—').padStart(6)} ms`; | ||
| const id = receipt.receipt_id === undefined ? chalk.dim('—') : chalk.dim(shortReceiptId(receipt.receipt_id)); | ||
| return `${time} ${actionCell} ${pii} ${latency} ${id}`; | ||
| } | ||
| export function logHeader() { | ||
| return chalk.dim(`${'TIME'.padEnd(19)} ${'ACTION'.padEnd(9)} PII ${'LATENCY'.padStart(9)} RECEIPT`); | ||
| } | ||
| /** | ||
| * Returns receipts not yet in `seen` in chronological order (the API lists | ||
| * newest first) and records them as seen. Used by `tork logs --follow`. | ||
| */ | ||
| export function selectNew(receipts, seen) { | ||
| const fresh = []; | ||
| for (const receipt of receipts) { | ||
| const id = receipt.receipt_id; | ||
| if (id === undefined || seen.has(id)) | ||
| continue; | ||
| seen.add(id); | ||
| fresh.push(receipt); | ||
| } | ||
| return fresh.reverse(); | ||
| } | ||
| /** | ||
| * Local structural sanity check for a fetched receipt. This is NOT | ||
| * cryptographic verification — callers must label it accordingly. | ||
| */ | ||
| export function checkReceiptStructure(receipt) { | ||
| const problems = []; | ||
| const notExposed = []; | ||
| const id = receipt.receipt_id; | ||
| if (typeof id !== 'string' || id === '') | ||
| problems.push('receipt_id missing'); | ||
| else if (!RECEIPT_ID_PATTERN.test(id)) | ||
| problems.push(`receipt_id "${id}" does not match the tork_rcpt_ format`); | ||
| const timestamp = receipt.timestamp; | ||
| if (typeof timestamp !== 'string' || Number.isNaN(new Date(timestamp).getTime())) { | ||
| problems.push('timestamp missing or unparseable'); | ||
| } | ||
| for (const field of ['content_hash', 'hmac_signature']) { | ||
| const value = receipt[field]; | ||
| if (value === undefined) | ||
| notExposed.push(field); | ||
| else if (typeof value !== 'string' || !HEX_64.test(value)) | ||
| problems.push(`${field} is not a 64-char hex digest`); | ||
| } | ||
| const fingerprint = receipt.fingerprint; | ||
| if (fingerprint === undefined) | ||
| notExposed.push('fingerprint'); | ||
| else if (typeof fingerprint !== 'string' || fingerprint === '') | ||
| problems.push('fingerprint is not a string'); | ||
| return { notExposed, ok: problems.length === 0, problems }; | ||
| } |
| export declare const WARN_THRESHOLD = 80; | ||
| export declare const CRITICAL_THRESHOLD = 100; | ||
| export interface UsageSummary { | ||
| planName?: string; | ||
| callsUsed?: number; | ||
| callsLimit?: number; | ||
| callsRemaining?: number; | ||
| percentUsed?: number; | ||
| periodStart?: string; | ||
| periodEnd?: string; | ||
| } | ||
| /** Normalizes the /api/v1/usage response (top-level counters + plan + current_period). */ | ||
| export declare function normalizeUsage(raw: Record<string, unknown>): UsageSummary; | ||
| /** Proportional bar, colored by how close usage is to the limit. */ | ||
| export declare function formatBar(percentUsed: number, width?: number): string; |
| import chalk from 'chalk'; | ||
| const BAR_WIDTH = 24; | ||
| export const WARN_THRESHOLD = 80; | ||
| export const CRITICAL_THRESHOLD = 100; | ||
| function asNumber(value) { | ||
| return typeof value === 'number' && Number.isFinite(value) ? value : undefined; | ||
| } | ||
| function asString(value) { | ||
| return typeof value === 'string' && value !== '' ? value : undefined; | ||
| } | ||
| /** Normalizes the /api/v1/usage response (top-level counters + plan + current_period). */ | ||
| export function normalizeUsage(raw) { | ||
| const plan = typeof raw.plan === 'object' && raw.plan !== null ? raw.plan : {}; | ||
| const period = typeof raw.current_period === 'object' && raw.current_period !== null ? raw.current_period : {}; | ||
| const callsUsed = asNumber(raw.calls_used) ?? asNumber(period.calls_used); | ||
| const callsLimit = asNumber(raw.calls_limit) ?? asNumber(period.calls_limit); | ||
| const callsRemaining = asNumber(raw.calls_remaining) ?? asNumber(period.calls_remaining) ?? (callsUsed !== undefined && callsLimit !== undefined ? Math.max(0, callsLimit - callsUsed) : undefined); | ||
| let percentUsed = asNumber(period.percentage_used); | ||
| if (percentUsed === undefined && callsUsed !== undefined && callsLimit !== undefined && callsLimit > 0) { | ||
| percentUsed = (callsUsed / callsLimit) * 100; | ||
| } | ||
| return { | ||
| callsLimit, | ||
| callsRemaining, | ||
| callsUsed, | ||
| percentUsed, | ||
| periodEnd: asString(period.end) ?? asString(period.period_end) ?? asString(period.ends_at), | ||
| periodStart: asString(period.start), | ||
| planName: asString(plan.name), | ||
| }; | ||
| } | ||
| /** Proportional bar, colored by how close usage is to the limit. */ | ||
| export function formatBar(percentUsed, width = BAR_WIDTH) { | ||
| const clamped = Math.max(0, Math.min(100, percentUsed)); | ||
| const filled = Math.round((clamped / 100) * width); | ||
| const bar = '█'.repeat(filled) + '░'.repeat(width - filled); | ||
| if (percentUsed >= CRITICAL_THRESHOLD) | ||
| return chalk.red(bar); | ||
| if (percentUsed >= WARN_THRESHOLD) | ||
| return chalk.yellow(bar); | ||
| return chalk.green(bar); | ||
| } |
@@ -8,2 +8,3 @@ import { Command } from '@oclif/core'; | ||
| key: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>; | ||
| op: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>; | ||
| }; | ||
@@ -10,0 +11,0 @@ run(): Promise<{ |
@@ -6,2 +6,3 @@ import { password } from '@inquirer/prompts'; | ||
| import { getBaseUrl, isValidKeyFormat, maskKey, readConfig, writeConfig } from '../lib/config.js'; | ||
| import { OpError, resolveOpReference } from '../lib/op.js'; | ||
| import { CHECK } from '../lib/output.js'; | ||
@@ -11,9 +12,31 @@ export default class Login extends Command { | ||
| static enableJsonFlag = true; | ||
| static examples = ['<%= config.bin %> login', '<%= config.bin %> login --key tork_live_xxx']; | ||
| static examples = [ | ||
| '<%= config.bin %> login', | ||
| '<%= config.bin %> login --key tork_live_xxx', | ||
| "<%= config.bin %> login --op 'op://Tork/API key/credential'", | ||
| ]; | ||
| static flags = { | ||
| key: Flags.string({ description: 'API key (skips the interactive prompt, for non-interactive use)' }), | ||
| op: Flags.string({ description: 'read the key from 1Password via `op read` (e.g. op://Vault/Item/field)', exclusive: ['key'] }), | ||
| }; | ||
| async run() { | ||
| const { flags } = await this.parse(Login); | ||
| const key = flags.key ?? (await password({ mask: '*', message: 'Paste your Tork API key' })); | ||
| let key; | ||
| if (flags.op !== undefined) { | ||
| try { | ||
| key = resolveOpReference(flags.op); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof OpError) | ||
| this.error(error.message, { exit: 1 }); | ||
| throw error; | ||
| } | ||
| if (!isValidKeyFormat(key)) { | ||
| // the resolved secret must never be echoed — name only the reference | ||
| this.error(`The value at ${flags.op} does not look like a Tork API key (expected it to start with "tork_"). Check the field the reference points to.`, { exit: 1 }); | ||
| } | ||
| } | ||
| else { | ||
| key = flags.key ?? (await password({ mask: '*', message: 'Paste your Tork API key' })); | ||
| } | ||
| if (!isValidKeyFormat(key)) { | ||
@@ -20,0 +43,0 @@ this.error('Invalid key format: Tork API keys start with "tork_".', { exit: 1 }); |
@@ -8,2 +8,4 @@ import { Command } from '@oclif/core'; | ||
| configPath: string; | ||
| verified: boolean; | ||
| verifyError: string | null; | ||
| } | ||
@@ -10,0 +12,0 @@ export default class Whoami extends Command { |
| import { Command } from '@oclif/core'; | ||
| import chalk from 'chalk'; | ||
| import { TorkApi } from '../lib/api.js'; | ||
| import { ApiError, AuthError, NetworkError, TorkApi } from '../lib/api.js'; | ||
| import { configPath, getApiKey, getBaseUrl, maskKey, readConfig } from '../lib/config.js'; | ||
| export default class Whoami extends Command { | ||
| static description = 'Show the active key (masked), org, base URL and config path'; | ||
| static description = 'Show the active key (masked), org (live-verified), base URL and config path'; | ||
| static enableJsonFlag = true; | ||
@@ -13,15 +13,37 @@ static examples = ['<%= config.bin %> whoami', '<%= config.bin %> whoami --json']; | ||
| const source = process.env.TORK_API_KEY ? 'env TORK_API_KEY' : 'config file'; | ||
| // Verify against the live API — cached org names misled a real incident | ||
| // triage, so a value we could not verify is always labeled as cached. | ||
| let orgName = readConfig().orgName; | ||
| let verified = false; | ||
| let verifyError = null; | ||
| if (key !== undefined) { | ||
| try { | ||
| const { orgName: liveOrg } = await new TorkApi(baseUrl, key).validateKey(); | ||
| verified = true; | ||
| if (liveOrg !== undefined) | ||
| orgName = liveOrg; | ||
| } | ||
| catch { | ||
| // offline or invalid key — fall back to the stored org name | ||
| catch (error) { | ||
| if (error instanceof AuthError) { | ||
| verifyError = `server rejected the key (HTTP ${error.status})`; | ||
| process.exitCode = 1; | ||
| } | ||
| else if (error instanceof NetworkError) { | ||
| verifyError = 'network unreachable'; | ||
| } | ||
| else if (error instanceof ApiError) { | ||
| verifyError = `server error (HTTP ${error.status})`; | ||
| } | ||
| else { | ||
| verifyError = error instanceof Error ? error.message : String(error); | ||
| } | ||
| } | ||
| } | ||
| let orgSuffix = ''; | ||
| if (verified) | ||
| orgSuffix = ` ${chalk.dim('(verified just now)')}`; | ||
| else if (verifyError !== null) | ||
| orgSuffix = ` ${chalk.yellow(`(cached — could not verify: ${verifyError})`)}`; | ||
| this.log(`key: ${key === undefined ? chalk.dim('not set — run tork login') : `${chalk.bold(maskKey(key))} (${source})`}`); | ||
| this.log(`org: ${orgName ?? chalk.dim('—')}`); | ||
| this.log(`org: ${orgName ?? chalk.dim('—')}${orgSuffix}`); | ||
| this.log(`base URL: ${baseUrl}`); | ||
@@ -35,4 +57,6 @@ this.log(`config: ${configPath()}`); | ||
| source: key === undefined ? null : source, | ||
| verified, | ||
| verifyError, | ||
| }; | ||
| } | ||
| } |
+33
-0
@@ -16,2 +16,31 @@ export interface GovernPayload { | ||
| export type GovernResponse = Record<string, unknown>; | ||
| export interface ReceiptSummary { | ||
| receipt_id?: string; | ||
| timestamp?: string; | ||
| action?: string; | ||
| pii_types?: string[]; | ||
| pii_count?: number; | ||
| policy_version?: string; | ||
| latency_ms?: number; | ||
| [key: string]: unknown; | ||
| } | ||
| export interface ReceiptsPage { | ||
| receipts: ReceiptSummary[]; | ||
| total?: number; | ||
| limit?: number; | ||
| offset?: number; | ||
| [key: string]: unknown; | ||
| } | ||
| export interface VerifyRequest { | ||
| fingerprint: string; | ||
| date: string; | ||
| org_id: string; | ||
| } | ||
| export interface VerifyResponse { | ||
| verified?: boolean; | ||
| message?: string; | ||
| merkle_root?: string | null; | ||
| anchor?: unknown; | ||
| [key: string]: unknown; | ||
| } | ||
| export declare class NetworkError extends Error { | ||
@@ -48,3 +77,7 @@ name: string; | ||
| usage(): Promise<Record<string, unknown>>; | ||
| /** The receipts feed doubles as the decision log: /api/v1/logs does not exist on the server. */ | ||
| receipts(limit?: number, offset?: number): Promise<ReceiptsPage>; | ||
| receipt(id: string): Promise<Record<string, unknown>>; | ||
| verify(payload: VerifyRequest): Promise<VerifyResponse>; | ||
| private request; | ||
| } |
+18
-0
@@ -116,2 +116,20 @@ const TIMEOUT_MS = 10_000; | ||
| } | ||
| /** The receipts feed doubles as the decision log: /api/v1/logs does not exist on the server. */ | ||
| async receipts(limit, offset) { | ||
| const params = new URLSearchParams(); | ||
| if (limit !== undefined) | ||
| params.set('limit', String(limit)); | ||
| if (offset !== undefined) | ||
| params.set('offset', String(offset)); | ||
| const query = params.size > 0 ? `?${params.toString()}` : ''; | ||
| const raw = await this.request({ path: `/api/v1/receipts${query}` }); | ||
| const receipts = Array.isArray(raw.receipts) ? raw.receipts : []; | ||
| return { ...raw, receipts }; | ||
| } | ||
| async receipt(id) { | ||
| return this.request({ path: `/api/v1/receipts/${encodeURIComponent(id)}` }); | ||
| } | ||
| async verify(payload) { | ||
| return this.request({ method: 'POST', path: '/api/v1/verify', body: payload }); | ||
| } | ||
| async request(options) { | ||
@@ -118,0 +136,0 @@ const url = new URL(options.path, this.baseUrl).toString(); |
+1
-1
| { | ||
| "name": "@torknetwork/cli", | ||
| "version": "0.1.1", | ||
| "version": "0.2.0", | ||
| "description": "Tork Governance CLI — govern your AI agents from the terminal", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+39
-1
@@ -44,2 +44,3 @@ # Tork CLI | ||
| | `--key <key>` | Provide the key non-interactively (CI, scripts) | | ||
| | `--op <ref>` | Read the key from 1Password via `op read` (e.g. `'op://Vault/Item/field'`) — requires the [1Password CLI](https://developer.1password.com/docs/cli/); the resolved key is validated like `--key` and never printed | | ||
| | `--json` | Machine-readable result | | ||
@@ -78,2 +79,36 @@ | ||
| ### `tork logs` | ||
| Show recent governance decisions for your org — time, action (`allow` green, `redact` yellow, `deny` red, `escalate` magenta), PII count, latency and receipt id. Backed by the receipts feed (`GET /api/v1/receipts`); entries print oldest-first so the latest decision is at the bottom. | ||
| | Flag | Description | | ||
| | --- | --- | | ||
| | `--limit <n>` | Number of decisions to show (default 20) | | ||
| | `--follow` | Poll every 5s and print new decisions as they arrive (Ctrl+C to stop; not combinable with `--json`) | | ||
| | `--json` | Machine-readable entries | | ||
| ### `tork receipts` | ||
| List governance receipts, or fetch one by id and verify it: | ||
| ```sh | ||
| tork receipts # list recent receipts | ||
| tork receipts tork_rcpt_xxx # fetch one receipt and verify it | ||
| ``` | ||
| Single-receipt view shows the receipt id, timestamp, action, PII counts, policy version, content hash, fingerprint, governance DNA and whether a signature is present. Verification is honest about what it can do: when the receipt exposes a fingerprint it is checked against the server's merkle anchor via `POST /api/v1/verify` (`✔ verified` / `✖ failed`, exit 1 on failure); when it doesn't, you get a local **structure check only** — field presence and hash-format sanity, never claimed as cryptographic verification. | ||
| | Flag | Description | | ||
| | --- | --- | | ||
| | `--limit <n>` | Number of receipts to list (default 20) | | ||
| | `--json` | Full receipt / verification result as JSON | | ||
| ### `tork usage` | ||
| Show plan usage for the authenticated org from `GET /api/v1/usage` — plan name, a proportional usage bar, calls used / limit with percentage, calls remaining and the billing period. Warns in yellow at ≥80% of the limit and in red at ≥100%. | ||
| | Flag | Description | | ||
| | --- | --- | | ||
| | `--json` | Raw API response plus the normalized summary | | ||
| ### `tork doctor` | ||
@@ -100,3 +135,3 @@ | ||
| Print the active key (masked to `tork_****last4`), organisation (fetched live when possible), base URL, and config path. | ||
| Print the active key (masked to `tork_****last4`), organisation, base URL, and config path. The org is always checked live against the API: on success it shows `(verified just now)`; when the server can't be reached the stored value is printed suffixed `(cached — could not verify: <reason>)`; when the server rejects the key the command additionally exits 1, so scripts never mistake a cached identity for a live one. | ||
@@ -141,2 +176,5 @@ ## Environment variables | ||
| | Colored output breaks log parsing | ANSI codes in CI logs | Set `NO_COLOR=1` or use `--json` | | ||
| | `1Password CLI (op) is not installed` | `--op` used without the op CLI | `brew install 1password-cli`, then `op signin` | | ||
| | `op read … failed (exit 1)` | Bad reference or locked vault | Check the `op://Vault/Item/field` path; run `op signin` | | ||
| | `org: … (cached — could not verify)` | `tork whoami` couldn't reach the API or the key was rejected | Check connectivity; if HTTP 401, run `tork login` again | | ||
@@ -143,0 +181,0 @@ ## Development |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
76159
73.64%33
57.14%1547
70.56%188
25.33%7
16.67%