Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172import * 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<string, string | string[] | undefined>; 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<string, string | string[] | undefined>; 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:<uri>"), `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<T>(key: string, dateOf: (item: T) => string | undefined, fetch: (paging: PageOptions) => Promise<Page<T>>): Promise<Page<T>> { 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<string | undefined>((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<string> { 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<string> { if (value === '-') return await readStdin(); if (value.startsWith('@') && value.length > 1) return FS.readFileSync(value.slice(1), 'utf-8'); return value;}