import * as FS from 'node:fs'; import type { CommandSpec } from './command.ts'; import type { OptionValues } from './args.ts'; import type { Client } from './api.ts'; import { UsageError } from './errors.ts'; import type { Doc } from './doc.ts'; import { parseTime } from './time.ts'; import { readState, writeState } from './state.ts'; import type { Page, PageOptions } from './api.ts'; // What a command's `run` is handed: its options and arguments, already // checked against its spec, and the client to talk to the network with. // // A command returns a Result: the data (what --json prints, as it is) and, // optionally, how to show it to a person. Commands never print results // themselves, which is what guarantees that every one of them has a JSON form. export type Format = 'text' | 'json' | 'markdown' | 'html'; export interface Result { /** What --json prints. */ data: unknown; /** What a person sees instead; without it, the data is rendered generically. */ text?: string | (() => string) | undefined; /** The document --markdown and --html render; without it, one is made from the data. */ doc?: (() => Doc) | undefined; /** The text is the output whatever the format but JSON: a shell script, a file's contents. */ raw?: boolean | undefined; /** The exit code, when it is not 0 for a command that still has something to say. */ exit?: number | undefined; } export function result(data: unknown, text?: string | (() => string)): Result { return { data, text }; } export class Context { command: CommandSpec; path: CommandSpec[]; options: OptionValues; args: Record; client: Client; format: Format; /** JSON output: progress that would only clutter a pipe can be left out. */ json: boolean; /** * Write to stdout as the command goes, for commands that stream (watch) * rather than return one result. */ out: (text: string) => void; constructor(init: { command: CommandSpec; path: CommandSpec[]; options: OptionValues; args: Record; client: Client; format: Format; out?: (text: string) => void }) { this.command = init.command; this.path = init.path; this.options = init.options; this.args = init.args; this.client = init.client; this.format = init.format; this.json = init.format === 'json'; this.out = init.out ?? ((text) => process.stdout.write(text)); } /** The command as typed, for messages: `atcli post`. */ get name(): string { return ['atcli', ...this.path.slice(1).map((command) => command.name)].join(' '); } str(name: string): string | undefined { const value = this.options[name]; return value === undefined ? undefined : String(value); } num(name: string): number | undefined { const value = this.options[name]; return typeof value === 'number' ? value : undefined; } flag(name: string): boolean { return this.options[name] === true; } /** Whether a boolean option was given at all, either way. */ given(name: string): boolean { return this.options[name] !== undefined; } list(name: string): string[] { const value = this.options[name]; return Array.isArray(value) ? value : value === undefined ? [] : [String(value)]; } arg(name: string): string | undefined { const value = this.args[name]; return Array.isArray(value) ? value[0] : value; } /** An argument that must be there (the parser has checked, so this is for types). */ need(name: string): string { const value = this.arg(name); if (value === undefined) throw new UsageError(`${this.name} needs <${name}>`, this.path); return value; } rest(name: string): string[] { const value = this.args[name]; return Array.isArray(value) ? value : value === undefined ? [] : [value]; } paging(): { limit?: number | undefined; all?: boolean | undefined; cursor?: string | undefined; since?: number | undefined } { const since = this.str('since'); let cutoff: number | undefined; try { cutoff = since ? parseTime(since) : undefined; } catch (error) { throw this.usage(`--since: ${(error as Error).message}`); } return { limit: this.num('limit'), all: this.flag('all'), cursor: this.str('cursor'), since: cutoff }; } /** * A listing of dated things, newest first, with --new honoured: `key` * names what is listed ("timeline", "feed:"), `dateOf` dates an item, * and `fetch` pages through it with the options it is given. * * With --new, what was shown last time is not shown again: the listing * starts after the newest item the last --new listing of it showed, and * takes everything since then, or with -n the oldest n of it (the queue * read from the front, so nothing new is skipped). Then the newest item * shown is remembered, per account. */ async listing(key: string, dateOf: (item: T) => string | undefined, fetch: (paging: PageOptions) => Promise>): Promise> { const paging = this.paging(); if (!this.flag('new')) return await fetch(paging); const account = (await this.client.maybeSession())?.did ?? 'signed-out'; const stateKey = `new:${account}:${key}`; const mark = readState(stateKey); const limit = this.num('limit'); if (mark) { const after = Date.parse(mark); paging.since = Math.max(paging.since ?? -Infinity, after); paging.all = true; paging.limit = undefined; } const page = await fetch(paging); // Newest first: with a mark and -n, the oldest n of what is new. const items = mark && limit !== undefined && page.items.length > limit ? page.items.slice(-limit) : page.items; // By time, not by text: a record's own date may carry any offset. const newest = items.map(dateOf).filter((date): date is string => Boolean(date) && !Number.isNaN(Date.parse(date!))) .reduce((best, date) => (best === undefined || Date.parse(date) > Date.parse(best) ? date : best), undefined); if (newest && (!mark || Date.parse(newest) > Date.parse(mark))) writeState(stateKey, newest); return { items, cursor: mark ? undefined : page.cursor }; } usage(message: string): UsageError { return new UsageError(message, this.path); } } /** All of stdin, as text. */ export async function readStdin(): Promise { const chunks: Buffer[] = []; for await (const chunk of process.stdin) chunks.push(chunk as Buffer); return Buffer.concat(chunks).toString('utf-8'); } /** A value that may be given inline, as `@file`, or as `-` for stdin. */ export async function valueOrFile(value: string): Promise { if (value === '-') return await readStdin(); if (value.startsWith('@') && value.length > 1) return FS.readFileSync(value.slice(1), 'utf-8'); return value; }