diff --git a/.changeset/cli-retry-exit-code.md b/.changeset/cli-retry-exit-code.md new file mode 100644 index 00000000..87c43609 --- /dev/null +++ b/.changeset/cli-retry-exit-code.md @@ -0,0 +1,5 @@ +--- +'@stats-forge/github-stats-forge-cli': minor +--- + +feat: exit 75 rather than 1 when a card failed in a way a later run may not repeat, such as a rate limit diff --git a/apps/docs/src/content/docs/docs/usage/cli.md b/apps/docs/src/content/docs/docs/usage/cli.md index 849fa0b0..7045cd12 100644 --- a/apps/docs/src/content/docs/docs/usage/cli.md +++ b/apps/docs/src/content/docs/docs/usage/cli.md @@ -89,6 +89,13 @@ npx @stats-forge/github-stats-forge-cli --config card.json --generate --out stat `--check` in its place renders the same card and compares it with `stats.svg`, writing nothing: it exits 1 when the file is missing or out of date, so a job can tell whether a commit is due. +A card that could not be drawn exits 1, or 75 when a later run may draw it, as after a rate limit +or an outage. A scheduled job can keep the SVG it has and try again rather than fail: + +```sh +npx @stats-forge/github-stats-forge-cli --config card.json --generate --out stats.svg || [ $? -eq 75 ] +``` + ## The query string Every other way of drawing a card takes a query string: [the action](https://github.com/stats-forge/github-stats-forge-action)'s diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 0cece668..2f0e79b6 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -28,6 +28,8 @@ builds and runs it against the root `.env`. - **`--check` is `--generate` that compares** with `--out` and writes nothing, exiting 1 on a difference, a missing file or a failed render; every guard on `generate` covers it. - **The menu stays open after a render, its cursor in place.** +- **A failed render exits `75` when core says it is `retryable`, else `1`**: `EX_TEMPFAIL`, so a + scheduled job can tell a rate limit from a broken config. `--check` keeps `1` for a stale file. ## Prompts and the menu diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 14e76137..d8e7aeae 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -19,6 +19,18 @@ import { DEFAULT_ENV_FILE, loadEnvFile, resolveTokens } from './tokens.ts'; /** Room for a slow connection, well short of undici's five minutes on a stalled one. */ const REQUEST_TIMEOUT_MS = 30_000; +/** `EX_TEMPFAIL` from `sysexits.h`: a failure a later run may not repeat, such as a rate limit. */ +const EXIT_RETRYABLE = 75; + +/** Why a card was not drawn. */ +interface Failed { + failed: string; + retryable: boolean; +} + +/** @returns The exit code a failure leaves: `75` when a retry may work, else `1`. */ +const exitCodeFor = (failure: Failed): number => (failure.retryable ? EXIT_RETRYABLE : 1); + /** Writes one of core's diagnostics to stderr, where everything but the result goes. */ const toStderr = (...args: Array): void => { process.stderr.write(`${formatWithOptions({}, ...args)}\n`); @@ -45,6 +57,7 @@ Options -v, --version Show the version The token can also come from PAT_1 in the environment, or be typed when asked. +A card that could not be drawn exits 1, or 75 when a later run may draw it. `; /** What the command line can carry. */ @@ -92,7 +105,7 @@ const renderCard = async ( card: CardKind, query: Record, config: CardConfig, -): Promise<{ content: string } | { failed: string }> => { +): Promise<{ content: string } | Failed> => { const result = await withSpinner(`Rendering the ${card.id} card`, async () => card.render(query, config), ); @@ -112,7 +125,7 @@ const renderCard = async ( .filter(Boolean) .join('\n')}\n`, ); - return { failed: code }; + return { failed: code, retryable: result.retryable }; }; /** @returns Where a card is written, absolute, named after the card unless `out` says. */ @@ -129,7 +142,7 @@ const renderAndWrite = async ( query: Record, config: CardConfig, out: string | undefined, -): Promise<{ written: string } | { failed: string }> => { +): Promise<{ written: string } | Failed> => { const rendered = await renderCard(card, query, config); if ('failed' in rendered) { return rendered; @@ -145,17 +158,17 @@ const renderAndWrite = async ( /** * Renders a card and compares it with the file it would be written to, writing nothing. * - * @returns Whether the file holds exactly this render. + * @returns Whether the file holds exactly this render, or why there was no render to compare. */ const renderAndCompare = async ( card: CardKind, query: Record, config: CardConfig, out: string | undefined, -): Promise => { +): Promise => { const rendered = await renderCard(card, query, config); if ('failed' in rendered) { - return false; + return rendered; } const file = outFile(card, query, out); @@ -280,15 +293,23 @@ const main = async (): Promise => { // `--generate` renders what the file holds and stops: no menu, nothing to answer. if (saved && flags.check) { - if (!(await renderAndCompare(card, { ...saved.options, ...pasted }, config, flags.out))) { + const compared = await renderAndCompare( + card, + { ...saved.options, ...pasted }, + config, + flags.out, + ); + if (compared === false) { process.exitCode = 1; + } else if (compared !== true) { + process.exitCode = exitCodeFor(compared); } return; } if (saved && generate) { const outcome = await renderAndWrite(card, { ...saved.options, ...pasted }, config, flags.out); if ('failed' in outcome) { - process.exitCode = 1; + process.exitCode = exitCodeFor(outcome); } return; } @@ -304,7 +325,7 @@ const main = async (): Promise => { * list is to change one thing and look again. */ let status: string | undefined; - let lastFailed = false; + let lastFailure: Failed | undefined; for (;;) { const action = await navigateOptions(card, menu, status); @@ -335,17 +356,17 @@ const main = async (): Promise => { if ('failed' in outcome) { // Left on the menu, since a rejected param is one edit away from working. status = `${outcome.failed} — fix it and generate again`; - lastFailed = true; + lastFailure = outcome; continue; } status = `wrote ${outcome.written} — edit an option and generate again`; - lastFailed = false; + lastFailure = undefined; } // Leaving straight after a failure still reports one. - if (lastFailed) { - process.exitCode = 1; + if (lastFailure) { + process.exitCode = exitCodeFor(lastFailure); } }; diff --git a/packages/cli/tests/index.spec.ts b/packages/cli/tests/index.spec.ts index 94b7729f..41f67a68 100644 --- a/packages/cli/tests/index.spec.ts +++ b/packages/cli/tests/index.spec.ts @@ -46,7 +46,7 @@ vi.mock(import('../src/prompts.ts'), () => prompts); const DRAWN: ApiResult = { status: 'success', content: 'the card' }; const REFUSED: ApiResult = { status: 'error', - retryable: true, + retryable: false, error: { code: 'not_found', reason: 'gist_not_found', @@ -57,6 +57,19 @@ const REFUSED: ApiResult = { }, content: 'the error', }; +const RATE_LIMITED: ApiResult = { + status: 'error', + retryable: true, + error: { + code: 'rate_limited', + reason: 'rate_limited', + message: 'Every token has hit the rate limit', + secondaryMessage: undefined, + text: 'Every token has hit the rate limit.', + param: undefined, + }, + content: 'the error', +}; let dir: string; /** A gist card saved with `id=abc&theme=ember_dark`. */ @@ -223,11 +236,22 @@ describe('the command line', () => { expect(result.err).toContain('Could not render the gist card.'); expect(result.err).toContain('\n Gist not found. Check the id.\n'); expect(result.err).toContain('code: not_found'); - expect(result.err).toContain('This one may work on a retry.'); + expect(result.err).not.toContain('retry'); expect(result.code).toBe(1); expect(existsSync(out)).toBe(false); }); + it('exits 75 when the failure may pass on a retry, as a rate limit may', async () => { + render.mockResolvedValue(RATE_LIMITED); + const out = join(dir, 'card.svg'); + + const result = await run('--config', savedGist, '--generate', '--pat', 'ghp_x', '--out', out); + + expect(result.err).toContain('This one may work on a retry.'); + expect(result.code).toBe(75); + expect(existsSync(out)).toBe(false); + }); + describe('--check', () => { it('passes when the file holds exactly this render, and leaves it alone', async () => { render.mockResolvedValue(DRAWN); @@ -268,6 +292,16 @@ describe('the command line', () => { expect(result.err).toContain('Could not render the gist card.'); expect(result.code).toBe(1); }); + + it('exits 75 when the render may pass on a retry', async () => { + render.mockResolvedValue(RATE_LIMITED); + const out = join(dir, 'card.svg'); + await writeFile(out, DRAWN.content); + + const result = await run('--config', savedGist, '--check', '--pat', 'ghp_x', '--out', out); + + expect(result.code).toBe(75); + }); }); it('leaves with 130, and says nothing, when a prompt is cancelled', async () => { @@ -366,6 +400,15 @@ describe('the menu', () => { expect(code).toBe(1); }); + it('leaves with 75 when the last render may pass on a retry', async () => { + render.mockResolvedValue(RATE_LIMITED); + choose('generate'); + + const { code } = await run('--card', 'gist', '--pat', 'ghp_x', '--out', join(dir, 'card.svg')); + + expect(code).toBe(75); + }); + it('leaves with 0 when a later render worked', async () => { render.mockResolvedValueOnce(REFUSED).mockResolvedValueOnce(DRAWN); choose('generate', 'generate');