diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe7dcb..7d5b471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ Versions before 0.8.0 are in the [git tags](https://github.com/leemour/cli-core/ Every entry says what changed as a caller sees it, why, and what to watch for — the rules are [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog). +## 0.15.0 — 01.10.2026 + +### Added + +- **`annotate(command, { mutates: true, local: true })`** marks a write that changes only this + machine — a file, the keyring — and never the service. `CommandInfo.local` carries it, and + `mutates` stays true, so a caller that tells reads from writes still sees a write. +- **`commandsPage` text takes `mutatesLocal`**, the line shown under such a command instead of + `mutates`. Without it a local write gets no line. Before this, `config set` was labelled as + changing Telegram or MAX. + ## 0.14.0 — 01.10.2026 ### Added diff --git a/package.json b/package.json index 43ffe8f..1ede394 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@leemour/cli-core", - "version": "0.14.0", + "version": "0.15.0", "description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks", "license": "MIT", "author": "Viacheslav Ptsarev", diff --git a/src/commands/commands.test.ts b/src/commands/commands.test.ts index 24ad724..d4e0106 100644 --- a/src/commands/commands.test.ts +++ b/src/commands/commands.test.ts @@ -132,6 +132,18 @@ describe("hidden commands", () => { }) }) +const COMMON_TEXT = { + banner: "", + title: "Commands", + intro: "", + globalHeading: "Global options", + globalIntro: "", + mutates: "", + exitHeading: "Exit codes", + exitIntro: "", + outro: "", +} + describe("commandsPage", () => { const page = () => commandsPage({ @@ -166,6 +178,25 @@ describe("commandsPage", () => { expect(text.endsWith("| `1` | anything else |\n")).toBe(true) }) + it("labels a write that changes only this machine with its own line, or none", () => { + const root = new Command("tool") + annotate(root.command("send").description("send"), { mutates: true }) + annotate(root.command("save").description("save"), { mutates: true, local: true }) + const text = (mutatesLocal?: string) => + commandsPage({ + cli: "tool", + commands: describeProgram(root), + options: [], + labels: COMMANDS_PAGE_LABELS.en, + text: { ...COMMON_TEXT, mutates: "**Changes the service.**", ...(mutatesLocal ? { mutatesLocal } : {}) }, + }) + + expect(text("**Changes this machine only.**")).toContain("## `tool send`\n\nsend\n\n**Changes the service.**") + expect(text("**Changes this machine only.**")).toContain("## `tool save`\n\nsave\n\n**Changes this machine only.**") + expect(text()).toContain("## `tool save`\n\nsave\n\n```sh") + expect(describeProgram(root)[1]).toMatchObject({ mutates: true, local: true }) + }) + it("keeps a pipe or a tilde from breaking its row, and lists the allowed values", () => { expect(page()).toContain("| `--sort ` | order. One of: `new`, `old`. |") const piped = commandsPage({ diff --git a/src/commands/index.ts b/src/commands/index.ts index 463fb29..ee99c73 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -18,8 +18,10 @@ export interface CommandMeta { origin?: Origin /** The catalog operation a generated command was made from. */ operationId?: string - /** True when running the command changes something outside this machine. */ + /** True when running the command changes something: a write, wherever it lands. */ mutates?: boolean + /** With `mutates`: what it changes is only on this machine — a file, the keyring — never the service. */ + local?: boolean state?: CommandState examples?: readonly string[] } @@ -62,6 +64,7 @@ export interface CommandInfo { origin: Origin operationId?: string mutates?: boolean + local?: boolean state?: CommandState examples?: readonly string[] arguments: readonly ArgumentInfo[] diff --git a/src/commands/page.ts b/src/commands/page.ts index d7b61e9..ec32203 100644 --- a/src/commands/page.ts +++ b/src/commands/page.ts @@ -61,6 +61,8 @@ export type CommandsPageText = { globalIntro: string /** Shown under a command that changes something outside this machine. */ mutates: string + /** Shown instead under a command marked `local`, which changes only this machine; without it, nothing is. */ + mutatesLocal?: string exitHeading: string exitIntro: string /** Markdown after the exit codes; may be empty. */ @@ -111,7 +113,8 @@ export const commandsPage = ({ cli, commands, options, labels, text }: CommandsP const body = (command: CommandInfo): string[] => { const parts = [cell(command.description), ""] - if (command.mutates) parts.push(text.mutates, "") + const label = command.mutates ? (command.local ? text.mutatesLocal : text.mutates) : undefined + if (label) parts.push(label, "") parts.push("```sh", command.usage, "```") if (command.arguments.length > 0) parts.push("", `| ${labels.argument} | | ${labels.isWhat} |`, "|---|---|---|", argumentRows(command.arguments))