From cef669b24cc3346819da4c2b94091be21608ff17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 02:26:07 +0000 Subject: [PATCH 1/7] Add generate command with provider-agnostic LLM support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `htmlbin generate --prompt ` which calls any OpenAI-compatible LLM endpoint, extracts the resulting HTML, and publishes it — returning the same URL output as `htmlbin publish`. Users supply the endpoint and model via env vars (HTMLBIN_LLM_BASE_URL, HTMLBIN_LLM_MODEL, HTMLBIN_LLM_API_KEY), keeping the CLI free of any vendor SDK dependency. - src/llm/provider.ts: resolves LLM config from env vars; no named providers, no hardcoded URLs or model defaults to go stale - src/llm/complete.ts: raw fetch against /chat/completions with timeout, structured error parsing, and markdown fence stripping - src/errors.ts: adds no_llm_provider (exit 9) and llm_error (exit 8) - src/bin.ts: registers the generate command; writes to a tmpfile, delegates to the active backend's publish method, then cleans up - test/llm.test.ts: unit tests for provider resolution and exit codes - test/e2e/smoke.test.ts: adds generate to the --help surface check --- src/bin.ts | 80 ++++++++++++++++++++++++++++++++++++++++++ src/errors.ts | 5 +++ src/llm/complete.ts | 74 ++++++++++++++++++++++++++++++++++++++ src/llm/provider.ts | 31 ++++++++++++++++ test/e2e/smoke.test.ts | 2 +- test/llm.test.ts | 73 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 src/llm/complete.ts create mode 100644 src/llm/provider.ts create mode 100644 test/llm.test.ts diff --git a/src/bin.ts b/src/bin.ts index ca61727..34c0f42 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -10,6 +10,9 @@ // Every command accepts `--to `. The active backend resolves via // the precedence in src/config.ts. +import { readFile, writeFile, unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Command, Option } from "commander"; import { createCloudBackend } from "./backends/cloud.js"; import { createGhPagesBackend } from "./backends/gh-pages.js"; @@ -40,6 +43,7 @@ import { listPatterns } from "./patterns/list.js"; import { initPatterns } from "./patterns/init.js"; import { ensureNoSilentSkip, installPattern } from "./patterns/install.js"; import { resolveSource } from "./patterns/sources.js"; +import { generateHtml } from "./llm/complete.js"; const VERSION = "0.2.0"; @@ -120,6 +124,20 @@ interface PublishCmdOpts extends GlobalOpts { upsert?: boolean; } +interface GenerateCmdOpts extends GlobalOpts { + prompt: string; + data?: string; + title?: string; + description?: string; + pr?: string; + slug?: string; + repo?: string; + branch?: string; + project?: string; + metadata?: string[]; + upsert?: boolean; +} + interface UpdateCmdOpts extends GlobalOpts { file?: string; title?: string; @@ -320,6 +338,68 @@ async function run(): Promise { } }); + // --- generate --- + program + .command("generate") + .description("Generate an HTML page from a prompt and publish it, returning a URL") + .requiredOption("--prompt ", "what to generate") + .option("--data ", "file whose contents are appended to the prompt (CSV, JSON, text)") + .option("--title ", "title (cloud backend)") + .option("--description ", "description (cloud backend)") + .option("--pr ", "PR number (gh-pages, cloudflare)") + .option("--slug ", "explicit slug") + .option("--repo ", "repo (gh-pages)") + .option("--branch ", "branch (gh-pages)") + .option("--project ", "Pages project (cloudflare)") + .option("--metadata ", "metadata key=value (cloud only; repeatable)") + .option("--upsert", "look up by --metadata first; PUT if found, POST if not (cloud only)") + .action(async (cmdOpts: GenerateCmdOpts) => { + try { + const { backend, config } = await resolveActiveBackend(program.opts()); + const be = await makeBackend(backend, config, cmdOpts); + + let data: string | undefined; + if (cmdOpts.data) { + data = await readFile(cmdOpts.data, "utf8").catch(() => { + throw new CliError("file_not_found", `Cannot read data file: ${cmdOpts.data}`); + }); + } + + const html = await generateHtml(cmdOpts.prompt, data); + + const tmp = join(tmpdir(), `htmlbin-generate-${Date.now()}-${Math.random().toString(36).slice(2)}.html`); + await writeFile(tmp, html, "utf8"); + + let r; + try { + const opts: PublishOpts = { file: tmp }; + if (cmdOpts.title) opts.title = cmdOpts.title; + if (cmdOpts.description) opts.description = cmdOpts.description; + if (cmdOpts.pr) opts.pr = Number(cmdOpts.pr); + if (cmdOpts.slug) opts.slug = cmdOpts.slug; + if (cmdOpts.metadata?.length) { + const parsed = parseMetadata(cmdOpts.metadata); + validateLocally(parsed); + opts.metadata = parsed; + } + if (cmdOpts.upsert) opts.upsert = true; + r = await be.publish(opts); + } finally { + await unlink(tmp).catch(() => {}); + } + + const payload: Record = { url: r.url, slug: r.slug, backend }; + if (r.matched !== undefined) payload.matched = r.matched; + if (r.note) payload.note = r.note; + emit(payload, () => { + if (r.note) process.stderr.write(`note: ${r.note}\n`); + return r.url + "\n"; + }); + } catch (e) { + die(e); + } + }); + // --- list --- program .command("list") diff --git a/src/errors.ts b/src/errors.ts index 68ec37a..8f7faa4 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -50,6 +50,8 @@ export type CliErrorCode = | "cloudflare_access_not_enabled" | "pr_required" | "network_error" + | "no_llm_provider" + | "llm_error" | "unknown"; export class CliError extends Error { @@ -119,7 +121,10 @@ export function exitCodeFor(code: CliErrorCode): number { return 7; case "network_error": case "server_misconfigured": + case "llm_error": return 8; + case "no_llm_provider": + return 9; default: return 1; } diff --git a/src/llm/complete.ts b/src/llm/complete.ts new file mode 100644 index 0000000..8315084 --- /dev/null +++ b/src/llm/complete.ts @@ -0,0 +1,74 @@ +import { CliError } from "../errors.js"; +import { resolveProvider } from "./provider.js"; + +const SYSTEM_PROMPT = + "You are an HTML generator. Return only a complete, valid HTML document. " + + "No markdown. No code fences. No explanation. " + + "Start immediately with ."; + +export async function generateHtml(prompt: string, data?: string): Promise { + const { baseURL, apiKey, model } = resolveProvider(); + + const userContent = data ? `${prompt}\n\n${data}` : prompt; + + let res: Response; + try { + res = await fetch(`${baseURL}/chat/completions`, { + method: "POST", + signal: AbortSignal.timeout(120_000), + headers: { + "Content-Type": "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userContent }, + ], + }), + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new CliError("llm_error", `LLM request failed: ${msg}`, { cause: err }); + } + + let body: unknown; + try { + body = await res.json(); + } catch { + throw new CliError("llm_error", `LLM returned non-JSON response (HTTP ${res.status})`); + } + + if (!res.ok) { + const msg = (body as any)?.error?.message ?? `HTTP ${res.status}`; + throw new CliError("llm_error", `LLM provider error: ${msg}`, { + details: { status: res.status, code: (body as any)?.error?.code }, + }); + } + + const content: unknown = (body as any)?.choices?.[0]?.message?.content; + if (typeof content !== "string" || !content.trim()) { + throw new CliError("llm_error", "LLM returned an empty or unexpected response shape"); + } + + return extractHtml(content); +} + +function extractHtml(raw: string): string { + // Strip markdown code fences — some models add them despite instructions. + const stripped = raw + .replace(/^```(?:html)?\r?\n?/i, "") + .replace(/\r?\n?```\s*$/, "") + .trim(); + + if (!stripped.toLowerCase().startsWith("", + } + ); + } + + return { + baseURL: baseURL.replace(/\/$/, ""), + apiKey: process.env.HTMLBIN_LLM_API_KEY ?? "", + model, + }; +} diff --git a/test/e2e/smoke.test.ts b/test/e2e/smoke.test.ts index 2efbba9..e3e25af 100644 --- a/test/e2e/smoke.test.ts +++ b/test/e2e/smoke.test.ts @@ -21,7 +21,7 @@ describe.skipIf(!distAvailable())("CLI smoke", () => { it("--help lists every top-level command", async () => { const r = await runCli(["--help"]); expect(r.exitCode).toBe(0); - for (const cmd of ["publish", "list", "delete", "url", "login", "setup", "patterns"]) { + for (const cmd of ["publish", "generate", "list", "delete", "url", "login", "setup", "patterns"]) { expect(r.stdout).toContain(cmd); } }); diff --git a/test/llm.test.ts b/test/llm.test.ts new file mode 100644 index 0000000..4d92e7a --- /dev/null +++ b/test/llm.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { resolveProvider } from "../src/llm/provider.js"; +import { CliError } from "../src/errors.js"; + +afterEach(() => { + delete process.env.HTMLBIN_LLM_BASE_URL; + delete process.env.HTMLBIN_LLM_MODEL; + delete process.env.HTMLBIN_LLM_API_KEY; +}); + +describe("resolveProvider", () => { + it("throws no_llm_provider when env vars are missing", () => { + expect(() => resolveProvider()).toThrow(CliError); + try { + resolveProvider(); + } catch (e) { + expect((e as CliError).code).toBe("no_llm_provider"); + } + }); + + it("throws when only base URL is set", () => { + process.env.HTMLBIN_LLM_BASE_URL = "https://api.openai.com/v1"; + expect(() => resolveProvider()).toThrow(CliError); + }); + + it("throws when only model is set", () => { + process.env.HTMLBIN_LLM_MODEL = "gpt-4o-mini"; + expect(() => resolveProvider()).toThrow(CliError); + }); + + it("resolves when both URL and model are set", () => { + process.env.HTMLBIN_LLM_BASE_URL = "https://api.openai.com/v1"; + process.env.HTMLBIN_LLM_MODEL = "gpt-4o-mini"; + const p = resolveProvider(); + expect(p.baseURL).toBe("https://api.openai.com/v1"); + expect(p.model).toBe("gpt-4o-mini"); + expect(p.apiKey).toBe(""); + }); + + it("includes api key when set", () => { + process.env.HTMLBIN_LLM_BASE_URL = "https://api.openai.com/v1"; + process.env.HTMLBIN_LLM_MODEL = "gpt-4o-mini"; + process.env.HTMLBIN_LLM_API_KEY = "sk-test"; + const p = resolveProvider(); + expect(p.apiKey).toBe("sk-test"); + }); + + it("strips trailing slash from base URL", () => { + process.env.HTMLBIN_LLM_BASE_URL = "http://localhost:11434/v1/"; + process.env.HTMLBIN_LLM_MODEL = "llama3.2"; + const p = resolveProvider(); + expect(p.baseURL).toBe("http://localhost:11434/v1"); + }); + + it("works with Ollama-style local endpoint and no key", () => { + process.env.HTMLBIN_LLM_BASE_URL = "http://localhost:11434/v1"; + process.env.HTMLBIN_LLM_MODEL = "llama3.2"; + const p = resolveProvider(); + expect(p.apiKey).toBe(""); + }); +}); + +describe("exitCodeFor llm codes", () => { + it("maps no_llm_provider to 9", async () => { + const { exitCodeFor } = await import("../src/errors.js"); + expect(exitCodeFor("no_llm_provider")).toBe(9); + }); + + it("maps llm_error to 8", async () => { + const { exitCodeFor } = await import("../src/errors.js"); + expect(exitCodeFor("llm_error")).toBe(8); + }); +}); From 5e1d9c4032e440bf4d4cdfb8a3568650ca9241cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 02:26:39 +0000 Subject: [PATCH 2/7] Sync package-lock.json version to 0.2.0 --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f527d6..6f96f4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@htmlbin/cli", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@htmlbin/cli", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@octokit/rest": "^21.0.2", From 020f082789f38a278cde283b9bfbb2f58c734517 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 02:31:57 +0000 Subject: [PATCH 3/7] Wire generate into HTMLBin's patterns and context infrastructure The generate command now builds on HTMLBin's existing quality surface instead of bypassing it: - Pattern auto-detection: prompt is matched against installed pattern triggers (project > global > bundled precedence). The matching pattern's body becomes the LLM system prompt, giving the model the same content checklist, layout directions, and quality constraints that the patterns system was designed to enforce. - Explicit override: --pattern pins a specific pattern. - context field: the generation prompt is passed as context to the cloud publish call so it travels with the drop. - backend.ts: adds context? to PublishOpts; cloud backend forwards it to api.publish(). - src/llm/pattern-resolve.ts: loads and merges patterns from all three sources, matches triggers, throws not_found on unknown --pattern. - Tests updated to cover pattern auto-detection and explicit lookup. --- src/backend.ts | 5 ++ src/backends/cloud.ts | 1 + src/bin.ts | 12 ++++- src/llm/complete.ts | 15 ++++-- src/llm/pattern-resolve.ts | 107 +++++++++++++++++++++++++++++++++++++ test/llm.test.ts | 44 ++++++++++++++- 6 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 src/llm/pattern-resolve.ts diff --git a/src/backend.ts b/src/backend.ts index b0ab8a7..4a44d0e 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -22,6 +22,11 @@ export interface PublishOpts { * Requires at least one metadata entry. Cloud only. */ upsert?: boolean; + /** + * Free-form context for the drop — e.g. the prompt used to generate it. + * Cloud only; other backends ignore it. + */ + context?: string; } export interface UpdateOpts { diff --git a/src/backends/cloud.ts b/src/backends/cloud.ts index 64abdd3..bbe0c37 100644 --- a/src/backends/cloud.ts +++ b/src/backends/cloud.ts @@ -75,6 +75,7 @@ export function createCloudBackend(opts: CloudBackendOpts = {}): Backend { const title = po.title?.trim() || defaultTitle(filePath); const body: Parameters[0] = { html, title }; if (po.description) body.description = po.description; + if (po.context) body.context = po.context; if (po.metadata && Object.keys(po.metadata).length > 0) { body.metadata = po.metadata; } diff --git a/src/bin.ts b/src/bin.ts index 34c0f42..8e5c914 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -44,6 +44,7 @@ import { initPatterns } from "./patterns/init.js"; import { ensureNoSilentSkip, installPattern } from "./patterns/install.js"; import { resolveSource } from "./patterns/sources.js"; import { generateHtml } from "./llm/complete.js"; +import { resolvePattern } from "./llm/pattern-resolve.js"; const VERSION = "0.2.0"; @@ -126,6 +127,7 @@ interface PublishCmdOpts extends GlobalOpts { interface GenerateCmdOpts extends GlobalOpts { prompt: string; + pattern?: string; data?: string; title?: string; description?: string; @@ -343,6 +345,7 @@ async function run(): Promise { .command("generate") .description("Generate an HTML page from a prompt and publish it, returning a URL") .requiredOption("--prompt ", "what to generate") + .option("--pattern ", "pattern to use as generation guide (default: auto-detected from prompt triggers)") .option("--data ", "file whose contents are appended to the prompt (CSV, JSON, text)") .option("--title ", "title (cloud backend)") .option("--description ", "description (cloud backend)") @@ -365,14 +368,19 @@ async function run(): Promise { }); } - const html = await generateHtml(cmdOpts.prompt, data); + const pattern = await resolvePattern({ name: cmdOpts.pattern, prompt: cmdOpts.prompt }); + if (pattern && OUTPUT_MODE === "text") { + process.stderr.write(`pattern: ${pattern.name} (${pattern.source})\n`); + } + + const html = await generateHtml(cmdOpts.prompt, data, pattern ?? undefined); const tmp = join(tmpdir(), `htmlbin-generate-${Date.now()}-${Math.random().toString(36).slice(2)}.html`); await writeFile(tmp, html, "utf8"); let r; try { - const opts: PublishOpts = { file: tmp }; + const opts: PublishOpts = { file: tmp, context: cmdOpts.prompt }; if (cmdOpts.title) opts.title = cmdOpts.title; if (cmdOpts.description) opts.description = cmdOpts.description; if (cmdOpts.pr) opts.pr = Number(cmdOpts.pr); diff --git a/src/llm/complete.ts b/src/llm/complete.ts index 8315084..70701cc 100644 --- a/src/llm/complete.ts +++ b/src/llm/complete.ts @@ -1,14 +1,23 @@ import { CliError } from "../errors.js"; import { resolveProvider } from "./provider.js"; +import type { ResolvedPattern } from "./pattern-resolve.js"; -const SYSTEM_PROMPT = +const BASE_SYSTEM = "You are an HTML generator. Return only a complete, valid HTML document. " + "No markdown. No code fences. No explanation. " + "Start immediately with ."; -export async function generateHtml(prompt: string, data?: string): Promise { +export async function generateHtml( + prompt: string, + data?: string, + pattern?: ResolvedPattern +): Promise { const { baseURL, apiKey, model } = resolveProvider(); + const systemContent = pattern + ? `${BASE_SYSTEM}\n\nUse the following pattern as your guide for structure, content, and quality:\n\n${pattern.body}` + : BASE_SYSTEM; + const userContent = data ? `${prompt}\n\n${data}` : prompt; let res: Response; @@ -23,7 +32,7 @@ export async function generateHtml(prompt: string, data?: string): Promise { + let files: string[]; + try { + files = await readdir(dir); + } catch { + return []; + } + const out: ResolvedPattern[] = []; + for (const f of files) { + if (!f.endsWith(".md")) continue; + const path = join(dir, f); + try { + if (!(await stat(path)).isFile()) continue; + const raw = await readFile(path, "utf8"); + const parsed = parseAndValidatePattern(raw); + out.push({ + name: parsed.frontmatter.name, + body: parsed.body, + triggers: parsed.frontmatter.triggers, + source, + }); + } catch { + // skip malformed files silently — listing tolerates them too + } + } + return out; +} + +async function allPatterns(cwd?: string, env?: NodeJS.ProcessEnv): Promise> { + const byName = new Map(); + + // Precedence: bundled < global < project — later writes win. + for (const p of BUNDLED_PATTERNS) { + try { + const parsed = parseAndValidatePattern(p.body); + byName.set(parsed.frontmatter.name, { + name: parsed.frontmatter.name, + body: parsed.body, + triggers: parsed.frontmatter.triggers, + source: "bundled", + }); + } catch { + // skip invalid bundled entry + } + } + for (const p of await patternsFromDir(globalPatternsDir(env ?? process.env), "global")) { + byName.set(p.name, p); + } + for (const p of await patternsFromDir(projectPatternsDir(cwd), "project")) { + byName.set(p.name, p); + } + + return byName; +} + +export async function resolvePattern(opts: { + name?: string; + prompt: string; + cwd?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const patterns = await allPatterns(opts.cwd, opts.env); + + if (opts.name) { + const found = patterns.get(opts.name); + if (!found) { + throw new CliError("not_found", `Pattern "${opts.name}" not found.`, { + hint: "Run 'htmlbin patterns list' to see installed patterns, or 'htmlbin patterns init' to install the defaults.", + }); + } + return found; + } + + // Auto-detect: find the trigger with the longest match against the prompt. + // Longer trigger = more specific = wins ties. + const promptLower = opts.prompt.toLowerCase(); + let best: ResolvedPattern | null = null; + let bestLen = 0; + + for (const pattern of patterns.values()) { + for (const trigger of pattern.triggers) { + if (promptLower.includes(trigger.toLowerCase()) && trigger.length > bestLen) { + bestLen = trigger.length; + best = pattern; + } + } + } + + return best; +} diff --git a/test/llm.test.ts b/test/llm.test.ts index 4d92e7a..b707217 100644 --- a/test/llm.test.ts +++ b/test/llm.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi, afterEach } from "vitest"; +import { describe, expect, it, afterEach } from "vitest"; import { resolveProvider } from "../src/llm/provider.js"; +import { resolvePattern } from "../src/llm/pattern-resolve.js"; import { CliError } from "../src/errors.js"; afterEach(() => { @@ -10,9 +11,9 @@ afterEach(() => { describe("resolveProvider", () => { it("throws no_llm_provider when env vars are missing", () => { - expect(() => resolveProvider()).toThrow(CliError); try { resolveProvider(); + expect.fail("should have thrown"); } catch (e) { expect((e as CliError).code).toBe("no_llm_provider"); } @@ -60,6 +61,45 @@ describe("resolveProvider", () => { }); }); +describe("resolvePattern", () => { + it("returns null when prompt matches no trigger", async () => { + const result = await resolvePattern({ prompt: "build me something completely unrelated xyzzy" }); + expect(result).toBeNull(); + }); + + it("auto-detects pr-explainer from a matching prompt", async () => { + const result = await resolvePattern({ prompt: "explain this pr to the team" }); + expect(result).not.toBeNull(); + expect(result!.name).toBe("pr-explainer"); + }); + + it("auto-detects plan-spec-explainer from a matching prompt", async () => { + const result = await resolvePattern({ prompt: "publish this plan for review" }); + expect(result).not.toBeNull(); + expect(result!.name).toBe("plan-spec-explainer"); + }); + + it("resolves explicit --pattern by name", async () => { + const result = await resolvePattern({ name: "pr-explainer", prompt: "anything" }); + expect(result).not.toBeNull(); + expect(result!.name).toBe("pr-explainer"); + }); + + it("throws not_found for an unknown explicit pattern name", async () => { + try { + await resolvePattern({ name: "no-such-pattern", prompt: "anything" }); + expect.fail("should have thrown"); + } catch (e) { + expect((e as CliError).code).toBe("not_found"); + } + }); + + it("resolved pattern has a non-empty body", async () => { + const result = await resolvePattern({ prompt: "explain this pr" }); + expect(result!.body.length).toBeGreaterThan(0); + }); +}); + describe("exitCodeFor llm codes", () => { it("maps no_llm_provider to 9", async () => { const { exitCodeFor } = await import("../src/errors.js"); From 3d895b0ec1d8bb9b36815d6b3646fe0c002a21a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 02:36:39 +0000 Subject: [PATCH 4/7] Eliminate publish flag duplication between publish and generate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts three shared helpers so the option list, opts mapping, and result emission live in one place: - addPublishOptions(cmd): registers all publish-related flags on any command — publish and generate both call this - buildPublishOpts(file, cmdOpts, extra): converts cmd opts into PublishOpts; accepts extra fields (context) for generate - emitDropResult(r, backend): shared emit for both commands GenerateCmdOpts now extends PublishCmdOpts with only prompt/pattern/data. Adding a new publish flag in future requires changing one line. --- src/bin.ts | 209 +++++++++++++++++++++++------------------------------ 1 file changed, 90 insertions(+), 119 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index d720976..e4d7bad 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -126,19 +126,10 @@ interface PublishCmdOpts extends GlobalOpts { upsert?: boolean; } -interface GenerateCmdOpts extends GlobalOpts { +interface GenerateCmdOpts extends PublishCmdOpts { prompt: string; pattern?: string; data?: string; - title?: string; - description?: string; - pr?: string; - slug?: string; - repo?: string; - branch?: string; - project?: string; - metadata?: string[]; - upsert?: boolean; } interface UpdateCmdOpts extends GlobalOpts { @@ -247,6 +238,44 @@ function emit(payload: unknown, renderText: () => string): void { } } +function addPublishOptions(cmd: Command): Command { + return cmd + .option("--title ", "title (cloud backend; defaults to filename)") + .option("--description ", "description (cloud backend)") + .option("--pr ", "PR number (gh-pages, cloudflare; default: $GITHUB_REF in CI)") + .option("--slug ", "explicit slug (e.g. feature/X; overrides --pr)") + .option("--repo ", "repo (gh-pages; default: git remote origin)") + .option("--branch ", "branch (gh-pages; default: gh-pages)") + .option("--project ", "Pages project (cloudflare; default: $CLOUDFLARE_PAGES_PROJECT)") + .option("--metadata ", "metadata key=value (cloud only; repeatable, up to 10)") + .option("--upsert", "look up by --metadata first; PUT if found, POST if not (cloud only)"); +} + +function buildPublishOpts(file: string, cmdOpts: PublishCmdOpts, extra: Partial = {}): PublishOpts { + const opts: PublishOpts = { file, ...extra }; + if (cmdOpts.title) opts.title = cmdOpts.title; + if (cmdOpts.description) opts.description = cmdOpts.description; + if (cmdOpts.pr) opts.pr = Number(cmdOpts.pr); + if (cmdOpts.slug) opts.slug = cmdOpts.slug; + if (cmdOpts.metadata?.length) { + const parsed = parseMetadata(cmdOpts.metadata); + validateLocally(parsed); + opts.metadata = parsed; + } + if (cmdOpts.upsert) opts.upsert = true; + return opts; +} + +function emitDropResult(r: { url: string; slug: string; matched?: boolean; note?: string }, backend: BackendName): void { + const payload: Record = { url: r.url, slug: r.slug, backend }; + if (r.matched !== undefined) payload.matched = r.matched; + if (r.note) payload.note = r.note; + emit(payload, () => { + if (r.note) process.stderr.write(`note: ${r.note}\n`); + return r.url + "\n"; + }); +} + async function run(): Promise { const program = new Command(); program @@ -292,122 +321,64 @@ async function run(): Promise { }); // --- publish --- - program - .command("publish") - .description("Publish an HTML file and print the resulting URL") - .argument("", "path to an HTML file") - .option("--title ", "title (cloud backend; defaults to filename)") - .option("--description ", "description (cloud backend)") - .option("--pr ", "PR number (gh-pages, cloudflare; default: $GITHUB_REF in CI)") - .option("--slug ", "explicit slug (e.g. feature/X; overrides --pr)") - .option("--repo ", "repo (gh-pages; default: git remote origin)") - .option("--branch ", "branch (gh-pages; default: gh-pages)") - .option("--project ", "Pages project (cloudflare; default: $CLOUDFLARE_PAGES_PROJECT)") - .option( - "--metadata ", - "metadata key=value (cloud only; repeatable, up to 10)" - ) - .option("--upsert", "look up by --metadata first; PUT if found, POST if not (cloud only)") - .action(async (file: string, cmdOpts: PublishCmdOpts) => { - try { - const { backend, config } = await resolveActiveBackend(program.opts()); - const be = await makeBackend(backend, config, cmdOpts); - const opts: PublishOpts = { file }; - if (cmdOpts.title) opts.title = cmdOpts.title; - if (cmdOpts.description) opts.description = cmdOpts.description; - if (cmdOpts.pr) opts.pr = Number(cmdOpts.pr); - if (cmdOpts.slug) opts.slug = cmdOpts.slug; - if (cmdOpts.metadata?.length) { - const parsed = parseMetadata(cmdOpts.metadata); - validateLocally(parsed); - opts.metadata = parsed; - } - if (cmdOpts.upsert) opts.upsert = true; - const r = await be.publish(opts); - const payload: Record = { - url: r.url, - slug: r.slug, - backend, - }; - if (r.matched !== undefined) payload.matched = r.matched; - if (r.note) payload.note = r.note; - emit(payload, () => { - let out = r.url + "\n"; - if (r.note) process.stderr.write(`note: ${r.note}\n`); - return out; - }); - } catch (e) { - die(e); - } - }); + addPublishOptions( + program + .command("publish") + .description("Publish an HTML file and print the resulting URL") + .argument("", "path to an HTML file") + ).action(async (file: string, cmdOpts: PublishCmdOpts) => { + try { + const { backend, config } = await resolveActiveBackend(program.opts()); + const be = await makeBackend(backend, config, cmdOpts); + const r = await be.publish(buildPublishOpts(file, cmdOpts)); + emitDropResult(r, backend); + } catch (e) { + die(e); + } + }); // --- generate --- - program - .command("generate") - .description("Generate an HTML page from a prompt and publish it, returning a URL") - .requiredOption("--prompt ", "what to generate") - .option("--pattern ", "pattern to use as generation guide (default: auto-detected from prompt triggers)") - .option("--data ", "file whose contents are appended to the prompt (CSV, JSON, text)") - .option("--title ", "title (cloud backend)") - .option("--description ", "description (cloud backend)") - .option("--pr ", "PR number (gh-pages, cloudflare)") - .option("--slug ", "explicit slug") - .option("--repo ", "repo (gh-pages)") - .option("--branch ", "branch (gh-pages)") - .option("--project ", "Pages project (cloudflare)") - .option("--metadata ", "metadata key=value (cloud only; repeatable)") - .option("--upsert", "look up by --metadata first; PUT if found, POST if not (cloud only)") - .action(async (cmdOpts: GenerateCmdOpts) => { - try { - const { backend, config } = await resolveActiveBackend(program.opts()); - const be = await makeBackend(backend, config, cmdOpts); - - let data: string | undefined; - if (cmdOpts.data) { - data = await readFile(cmdOpts.data, "utf8").catch(() => { - throw new CliError("file_not_found", `Cannot read data file: ${cmdOpts.data}`); - }); - } + addPublishOptions( + program + .command("generate") + .description("Generate an HTML page from a prompt and publish it, returning a URL") + .requiredOption("--prompt ", "what to generate") + .option("--pattern ", "pattern to use as generation guide (default: auto-detected from prompt triggers)") + .option("--data ", "file whose contents are appended to the prompt (CSV, JSON, text)") + ).action(async (cmdOpts: GenerateCmdOpts) => { + try { + const { backend, config } = await resolveActiveBackend(program.opts()); + const be = await makeBackend(backend, config, cmdOpts); + + let data: string | undefined; + if (cmdOpts.data) { + data = await readFile(cmdOpts.data, "utf8").catch(() => { + throw new CliError("file_not_found", `Cannot read data file: ${cmdOpts.data}`); + }); + } - const pattern = await resolvePattern({ name: cmdOpts.pattern, prompt: cmdOpts.prompt }); - if (pattern && OUTPUT_MODE === "text") { - process.stderr.write(`pattern: ${pattern.name} (${pattern.source})\n`); - } + const pattern = await resolvePattern({ name: cmdOpts.pattern, prompt: cmdOpts.prompt }); + if (pattern && OUTPUT_MODE === "text") { + process.stderr.write(`pattern: ${pattern.name} (${pattern.source})\n`); + } - const html = await generateHtml(cmdOpts.prompt, data, pattern ?? undefined); + const html = await generateHtml(cmdOpts.prompt, data, pattern ?? undefined); - const tmp = join(tmpdir(), `htmlbin-generate-${Date.now()}-${Math.random().toString(36).slice(2)}.html`); - await writeFile(tmp, html, "utf8"); + const tmp = join(tmpdir(), `htmlbin-generate-${Date.now()}-${Math.random().toString(36).slice(2)}.html`); + await writeFile(tmp, html, "utf8"); - let r; - try { - const opts: PublishOpts = { file: tmp, context: cmdOpts.prompt }; - if (cmdOpts.title) opts.title = cmdOpts.title; - if (cmdOpts.description) opts.description = cmdOpts.description; - if (cmdOpts.pr) opts.pr = Number(cmdOpts.pr); - if (cmdOpts.slug) opts.slug = cmdOpts.slug; - if (cmdOpts.metadata?.length) { - const parsed = parseMetadata(cmdOpts.metadata); - validateLocally(parsed); - opts.metadata = parsed; - } - if (cmdOpts.upsert) opts.upsert = true; - r = await be.publish(opts); - } finally { - await unlink(tmp).catch(() => {}); - } - - const payload: Record = { url: r.url, slug: r.slug, backend }; - if (r.matched !== undefined) payload.matched = r.matched; - if (r.note) payload.note = r.note; - emit(payload, () => { - if (r.note) process.stderr.write(`note: ${r.note}\n`); - return r.url + "\n"; - }); - } catch (e) { - die(e); + let r; + try { + r = await be.publish(buildPublishOpts(tmp, cmdOpts, { context: cmdOpts.prompt })); + } finally { + await unlink(tmp).catch(() => {}); } - }); + + emitDropResult(r, backend); + } catch (e) { + die(e); + } + }); // --- list --- program From 4080ec41d990b3bee3bfbba83cd28620212b0e6c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 02:40:04 +0000 Subject: [PATCH 5/7] Remove backend-specific flags from shared publish options addPublishOptions now only contains backend-agnostic flags: title, description, slug, metadata, upsert. The --pr, --repo, --branch, --project flags are backend configuration and stay directly on the publish command where they were before. generate does not inherit them. --- src/bin.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index e4d7bad..7899f03 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -117,13 +117,14 @@ interface GlobalOpts { interface PublishCmdOpts extends GlobalOpts { title?: string; description?: string; - pr?: string; slug?: string; + metadata?: string[]; + upsert?: boolean; + // backend-specific — only present when the flag is registered on the command + pr?: string; repo?: string; branch?: string; project?: string; - metadata?: string[]; - upsert?: boolean; } interface GenerateCmdOpts extends PublishCmdOpts { @@ -242,11 +243,7 @@ function addPublishOptions(cmd: Command): Command { return cmd .option("--title ", "title (cloud backend; defaults to filename)") .option("--description ", "description (cloud backend)") - .option("--pr ", "PR number (gh-pages, cloudflare; default: $GITHUB_REF in CI)") - .option("--slug ", "explicit slug (e.g. feature/X; overrides --pr)") - .option("--repo ", "repo (gh-pages; default: git remote origin)") - .option("--branch ", "branch (gh-pages; default: gh-pages)") - .option("--project ", "Pages project (cloudflare; default: $CLOUDFLARE_PAGES_PROJECT)") + .option("--slug ", "explicit slug") .option("--metadata ", "metadata key=value (cloud only; repeatable, up to 10)") .option("--upsert", "look up by --metadata first; PUT if found, POST if not (cloud only)"); } @@ -326,6 +323,10 @@ async function run(): Promise { .command("publish") .description("Publish an HTML file and print the resulting URL") .argument("", "path to an HTML file") + .option("--pr ", "PR number (gh-pages, cloudflare; default: $GITHUB_REF in CI)") + .option("--repo ", "repo (gh-pages; default: git remote origin)") + .option("--branch ", "branch (gh-pages; default: gh-pages)") + .option("--project ", "Pages project (cloudflare; default: $CLOUDFLARE_PAGES_PROJECT)") ).action(async (file: string, cmdOpts: PublishCmdOpts) => { try { const { backend, config } = await resolveActiveBackend(program.opts()); From 4e8793edeae9bbf890fdaf7d3d79ced920f5f708 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 02:44:37 +0000 Subject: [PATCH 6/7] Remove backend-specific flags as first-class CLI constructs --pr, --repo, --branch, --project were leaking backend implementation details into the public CLI surface across publish, list, delete, and url commands. Backend config belongs in the config file and env vars, not as per-command flags. - PublishOpts: drops pr field; backends auto-detect from $GITHUB_REF - makeBackend: now only accepts SetupCmdOpts overrides; all other commands use config values directly - publish, list, delete, url: no backend-specific flags - setup: keeps --repo, --branch, --project (one-time config, correct) - resolvePrNumber hint updated: directs to --slug or $GITHUB_REF --- src/backend.ts | 2 -- src/backends/cloudflare.ts | 2 +- src/backends/gh-pages.ts | 2 +- src/bin.ts | 58 ++++++++++++-------------------------- src/gh/repo.ts | 2 +- 5 files changed, 21 insertions(+), 45 deletions(-) diff --git a/src/backend.ts b/src/backend.ts index 4a44d0e..56aecd1 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -10,8 +10,6 @@ export interface PublishOpts { title?: string; /** Description (cloud backend only). */ description?: string; - /** PR number, used for gh-pages / cloudflare slug. */ - pr?: number; /** Explicit slug override. */ slug?: string; /** Owner-side tag bag (cloud only). gh-pages/cloudflare reject. */ diff --git a/src/backends/cloudflare.ts b/src/backends/cloudflare.ts index cf37f85..8c3103b 100644 --- a/src/backends/cloudflare.ts +++ b/src/backends/cloudflare.ts @@ -59,7 +59,7 @@ export function createCloudflareBackend(opts: CloudflareBackendOpts = {}): Backe function aliasFor(po: PublishOpts): string { if (po.slug) return po.slug.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase(); - const pr = resolvePrNumber({ explicit: po.pr }); + const pr = resolvePrNumber({}); return `pr-${pr}`; } diff --git a/src/backends/gh-pages.ts b/src/backends/gh-pages.ts index da0209d..5566737 100644 --- a/src/backends/gh-pages.ts +++ b/src/backends/gh-pages.ts @@ -52,7 +52,7 @@ export function createGhPagesBackend(opts: GhPagesBackendOpts = {}): Backend { function slugFor(po: PublishOpts): string { if (po.slug) return sanitizeSlug(po.slug); - const pr = resolvePrNumber({ explicit: po.pr }); + const pr = resolvePrNumber({}); return `pr-${pr}`; } diff --git a/src/bin.ts b/src/bin.ts index 7899f03..ff20bc5 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -120,11 +120,6 @@ interface PublishCmdOpts extends GlobalOpts { slug?: string; metadata?: string[]; upsert?: boolean; - // backend-specific — only present when the flag is registered on the command - pr?: string; - repo?: string; - branch?: string; - project?: string; } interface GenerateCmdOpts extends PublishCmdOpts { @@ -142,9 +137,6 @@ interface UpdateCmdOpts extends GlobalOpts { } interface ListCmdOpts extends GlobalOpts { - project?: string; - repo?: string; - branch?: string; limit?: string; metadata?: string[]; } @@ -161,23 +153,23 @@ interface SetupCmdOpts extends GlobalOpts { email?: string[]; } -async function makeBackend(name: BackendName, cfg: ConfigFile, extra: PublishCmdOpts | SetupCmdOpts = {}): Promise { +async function makeBackend(name: BackendName, cfg: ConfigFile, setup: SetupCmdOpts = {}): Promise { switch (name) { case "cloud": return createCloudBackend({ apiUrl: cfg.api_url }); case "gh-pages": return createGhPagesBackend({ - repo: extra.repo ?? cfg.repo, - branch: extra.branch ?? cfg.branch, + repo: setup.repo ?? cfg.repo, + branch: setup.branch ?? cfg.branch, }); case "cloudflare": return createCloudflareBackend({ accountId: cfg.account_id, - project: (extra as PublishCmdOpts).project ?? cfg.project, - setupIdp: (extra as SetupCmdOpts).idp, - setupEmailDomain: (extra as SetupCmdOpts).emailDomain, - setupEmail: (extra as SetupCmdOpts).email, - productionBranch: (extra as SetupCmdOpts).productionBranch, + project: setup.project ?? cfg.project, + setupIdp: setup.idp, + setupEmailDomain: setup.emailDomain, + setupEmail: setup.email, + productionBranch: setup.productionBranch, }); } } @@ -252,7 +244,6 @@ function buildPublishOpts(file: string, cmdOpts: PublishCmdOpts, extra: Partial< const opts: PublishOpts = { file, ...extra }; if (cmdOpts.title) opts.title = cmdOpts.title; if (cmdOpts.description) opts.description = cmdOpts.description; - if (cmdOpts.pr) opts.pr = Number(cmdOpts.pr); if (cmdOpts.slug) opts.slug = cmdOpts.slug; if (cmdOpts.metadata?.length) { const parsed = parseMetadata(cmdOpts.metadata); @@ -323,14 +314,10 @@ async function run(): Promise { .command("publish") .description("Publish an HTML file and print the resulting URL") .argument("", "path to an HTML file") - .option("--pr ", "PR number (gh-pages, cloudflare; default: $GITHUB_REF in CI)") - .option("--repo ", "repo (gh-pages; default: git remote origin)") - .option("--branch ", "branch (gh-pages; default: gh-pages)") - .option("--project ", "Pages project (cloudflare; default: $CLOUDFLARE_PAGES_PROJECT)") ).action(async (file: string, cmdOpts: PublishCmdOpts) => { try { const { backend, config } = await resolveActiveBackend(program.opts()); - const be = await makeBackend(backend, config, cmdOpts); + const be = await makeBackend(backend, config); const r = await be.publish(buildPublishOpts(file, cmdOpts)); emitDropResult(r, backend); } catch (e) { @@ -349,7 +336,7 @@ async function run(): Promise { ).action(async (cmdOpts: GenerateCmdOpts) => { try { const { backend, config } = await resolveActiveBackend(program.opts()); - const be = await makeBackend(backend, config, cmdOpts); + const be = await makeBackend(backend, config); let data: string | undefined; if (cmdOpts.data) { @@ -385,9 +372,6 @@ async function run(): Promise { program .command("list") .description("List published drops on the active backend") - .option("--project ", "Pages project (cloudflare)") - .option("--repo ", "repo (gh-pages)") - .option("--branch ", "branch (gh-pages)") .option("-n, --limit ", "max rows to return (default: all)") .option( "--metadata ", @@ -396,7 +380,7 @@ async function run(): Promise { .action(async (cmdOpts: ListCmdOpts) => { try { const { backend, config } = await resolveActiveBackend(program.opts()); - const be = await makeBackend(backend, config, cmdOpts); + const be = await makeBackend(backend, config); const listOpts: ListOpts = {}; if (cmdOpts.metadata?.length) { const parsed = parseMetadata(cmdOpts.metadata); @@ -474,15 +458,12 @@ async function run(): Promise { // --- delete --- program .command("delete") - .description("Delete a drop (slug or PR number)") - .argument("", "slug or PR number") - .option("--project ", "Pages project (cloudflare)") - .option("--repo ", "repo (gh-pages)") - .option("--branch ", "branch (gh-pages)") - .action(async (slug: string, cmdOpts: PublishCmdOpts) => { + .description("Delete a drop") + .argument("", "slug to delete") + .action(async (slug: string) => { try { const { backend, config } = await resolveActiveBackend(program.opts()); - const be = await makeBackend(backend, config, cmdOpts); + const be = await makeBackend(backend, config); await be.delete(slug); emit({ deleted: true, slug, backend }, () => `deleted ${slug}\n`); } catch (e) { @@ -494,14 +475,11 @@ async function run(): Promise { program .command("url") .description("Print the URL for a given slug (no publish)") - .argument("", "slug or PR number") - .option("--project ", "Pages project (cloudflare)") - .option("--repo ", "repo (gh-pages)") - .option("--branch ", "branch (gh-pages)") - .action(async (slug: string, cmdOpts: PublishCmdOpts) => { + .argument("", "slug") + .action(async (slug: string) => { try { const { backend, config } = await resolveActiveBackend(program.opts()); - const be = await makeBackend(backend, config, cmdOpts); + const be = await makeBackend(backend, config); const url = await be.url(slug); emit({ url, slug, backend }, () => url + "\n"); } catch (e) { diff --git a/src/gh/repo.ts b/src/gh/repo.ts index d6b8d23..d8afaf8 100644 --- a/src/gh/repo.ts +++ b/src/gh/repo.ts @@ -106,6 +106,6 @@ export function resolvePrNumber(opts: { explicit?: number; env?: NodeJS.ProcessE throw new CliError( "pr_required", "PR number required for this backend.", - { hint: "Pass --pr , or run under GitHub Actions where $GITHUB_REF is set on PR events." } + { hint: "Run under GitHub Actions (GITHUB_REF is set automatically on PR events), or pass --slug to set the slug explicitly." } ); } From a0fce7ebfc92a3b782fcba6b5dfebbd5be0e142c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 03:00:23 +0000 Subject: [PATCH 7/7] Remove dead code and simplify generate/pattern internals - Drop `explicit` param from resolvePrNumber; all call sites already passed `{}` - Drop `cwd` and `env` params from resolvePattern/allPatterns; they were never populated - Drop `source` field from ResolvedPattern; only existed for a single debug log line - Fix extractHtml to lowercase ` { ).action(async (cmdOpts: GenerateCmdOpts) => { try { const { backend, config } = await resolveActiveBackend(program.opts()); + if (backend !== "cloud") { + throw new CliError( + "invalid_arg", + "generate is only supported on the cloud backend." + ); + } const be = await makeBackend(backend, config); let data: string | undefined; @@ -347,7 +353,7 @@ async function run(): Promise { const pattern = await resolvePattern({ name: cmdOpts.pattern, prompt: cmdOpts.prompt }); if (pattern && OUTPUT_MODE === "text") { - process.stderr.write(`pattern: ${pattern.name} (${pattern.source})\n`); + process.stderr.write(`pattern: ${pattern.name}\n`); } const html = await generateHtml(cmdOpts.prompt, data, pattern ?? undefined); diff --git a/src/gh/repo.ts b/src/gh/repo.ts index d8afaf8..15fbaa4 100644 --- a/src/gh/repo.ts +++ b/src/gh/repo.ts @@ -6,9 +6,8 @@ // 3. error // // PR resolution: -// 1. --pr N CLI flag -// 2. $GITHUB_REF (refs/pull//merge|head) in GitHub Actions -// 3. error +// 1. $GITHUB_REF (refs/pull//merge|head) in GitHub Actions +// 2. error import { execa } from "execa"; import { CliError } from "../errors.js"; @@ -97,11 +96,8 @@ export function detectPrFromCiEnv(env: NodeJS.ProcessEnv = process.env): number return null; } -export function resolvePrNumber(opts: { explicit?: number; env?: NodeJS.ProcessEnv }): number { - if (typeof opts.explicit === "number" && Number.isFinite(opts.explicit) && opts.explicit > 0) { - return Math.floor(opts.explicit); - } - const fromCi = detectPrFromCiEnv(opts.env); +export function resolvePrNumber(env: NodeJS.ProcessEnv = process.env): number { + const fromCi = detectPrFromCiEnv(env); if (fromCi !== null) return fromCi; throw new CliError( "pr_required", diff --git a/src/llm/complete.ts b/src/llm/complete.ts index 70701cc..c093bd7 100644 --- a/src/llm/complete.ts +++ b/src/llm/complete.ts @@ -71,7 +71,7 @@ function extractHtml(raw: string): string { .replace(/\r?\n?```\s*$/, "") .trim(); - if (!stripped.toLowerCase().startsWith(" { +async function patternsFromDir(dir: string): Promise { let files: string[]; try { files = await readdir(dir); @@ -34,7 +30,6 @@ async function patternsFromDir( name: parsed.frontmatter.name, body: parsed.body, triggers: parsed.frontmatter.triggers, - source, }); } catch { // skip malformed files silently — listing tolerates them too @@ -43,7 +38,7 @@ async function patternsFromDir( return out; } -async function allPatterns(cwd?: string, env?: NodeJS.ProcessEnv): Promise> { +async function allPatterns(): Promise> { const byName = new Map(); // Precedence: bundled < global < project — later writes win. @@ -54,16 +49,15 @@ async function allPatterns(cwd?: string, env?: NodeJS.ProcessEnv): Promise { - const patterns = await allPatterns(opts.cwd, opts.env); + const patterns = await allPatterns(); if (opts.name) { const found = patterns.get(opts.name); diff --git a/test/repo.test.ts b/test/repo.test.ts index b85b29b..890b86d 100644 --- a/test/repo.test.ts +++ b/test/repo.test.ts @@ -107,19 +107,11 @@ describe("detectPrFromCiEnv", () => { }); describe("resolvePrNumber", () => { - it("prefers explicit", () => { - expect(resolvePrNumber({ explicit: 7, env: { GITHUB_REF: "refs/pull/99/merge" } as NodeJS.ProcessEnv })).toBe(7); + it("reads from GITHUB_REF", () => { + expect(resolvePrNumber({ GITHUB_REF: "refs/pull/99/merge" } as NodeJS.ProcessEnv)).toBe(99); }); - it("falls back to env", () => { - expect(resolvePrNumber({ env: { GITHUB_REF: "refs/pull/99/merge" } as NodeJS.ProcessEnv })).toBe(99); - }); - - it("throws when neither provided", () => { - expect(() => resolvePrNumber({ env: {} as NodeJS.ProcessEnv })).toThrow(CliError); - }); - - it("rejects non-positive explicit", () => { - expect(() => resolvePrNumber({ explicit: 0, env: {} as NodeJS.ProcessEnv })).toThrow(CliError); + it("throws when env has no PR ref", () => { + expect(() => resolvePrNumber({} as NodeJS.ProcessEnv)).toThrow(CliError); }); });