diff --git a/src/code-mode.ts b/src/code-mode.ts index f3447c5e..0bd217dc 100644 --- a/src/code-mode.ts +++ b/src/code-mode.ts @@ -29,6 +29,20 @@ async function writeCodeModeWorkflow( "openwiki-update.yml", ); await mkdir(path.dirname(workflowPath), { recursive: true }); + + // Only create the workflow if it doesn't already exist — respect any + // customizations the user has made (cron schedule, env vars, fork guards, etc.). + try { + await readFile(workflowPath, "utf8"); + // File exists: leave it untouched so user edits survive. + return; + } catch (error) { + if (!isFileNotFoundError(error)) { + throw error; + } + // File not found: fall through to create it. + } + await writeFile(workflowPath, createCodeModeWorkflow(cronExpression), "utf8"); } diff --git a/src/constants.ts b/src/constants.ts index 1c1eb2bb..74b26bc3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -375,7 +375,7 @@ export function isValidModelId(value: string): boolean { return ( modelId.length > 0 && modelId.length <= 120 && - /^[A-Za-z0-9][A-Za-z0-9._:/+-]*$/u.test(modelId) && + /^[@A-Za-z0-9][A-Za-z0-9._:/+-]*$/u.test(modelId) && !modelId.includes("://") ); } diff --git a/test/code-mode.test.ts b/test/code-mode.test.ts index 11df0650..771b0c46 100644 --- a/test/code-mode.test.ts +++ b/test/code-mode.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -100,7 +100,7 @@ Trailing notes that must survive. }); describe("ensureCodeModeRepoSetup workflow", () => { - test("generated PR includes agent files and the workflow in add-paths", async () => { + test("creates workflow when it does not exist", async () => { const repo = await createTempRepo(); await ensureCodeModeRepoSetup(repo); @@ -119,4 +119,71 @@ describe("ensureCodeModeRepoSetup workflow", () => { expect(workflow).toContain(managedPath); } }); + + test("preserves existing customized workflow and does not overwrite", async () => { + const repo = await createTempRepo(); + const customWorkflow = `name: OpenWiki Update + +on: + workflow_dispatch: + schedule: + - cron: "0 2 * * 1" + +permissions: + contents: write + pull-requests: write + +jobs: + update: + if: github.repository == 'my-org/my-repo' + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + persist-credentials: true + + - name: Run OpenWiki + run: openwiki code --update --print + env: + OPENWIKI_PROVIDER: anthropic + ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }} + OPENWIKI_MODEL_ID: claude-sonnet-4-20250514 +`; + const workflowDir = path.join(repo, ".github", "workflows"); + await mkdir(workflowDir, { recursive: true }); + await writeFile( + path.join(workflowDir, "openwiki-update.yml"), + customWorkflow, + "utf8", + ); + + await ensureCodeModeRepoSetup(repo); + + const workflow = await readIfPresent( + path.join(workflowDir, "openwiki-update.yml"), + ); + // Must preserve the user's customizations, not overwrite with defaults. + expect(workflow).toBe(customWorkflow); + expect(workflow).toContain('cron: "0 2 * * 1"'); + expect(workflow).toContain("if: github.repository == 'my-org/my-repo'"); + expect(workflow).toContain("OPENWIKI_PROVIDER: anthropic"); + expect(workflow).toContain("claude-sonnet-4-20250514"); + expect(workflow).toContain("persist-credentials: true"); + }); + + test("is idempotent across repeated runs for workflow", async () => { + const repo = await createTempRepo(); + + await ensureCodeModeRepoSetup(repo); + const first = await readIfPresent( + path.join(repo, ".github", "workflows", "openwiki-update.yml"), + ); + await ensureCodeModeRepoSetup(repo); + const second = await readIfPresent( + path.join(repo, ".github", "workflows", "openwiki-update.yml"), + ); + + expect(second).toEqual(first); + }); }); diff --git a/test/constants.test.ts b/test/constants.test.ts index bbcae399..2fdf5698 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -24,6 +24,12 @@ describe("isValidModelId", () => { expect(isValidModelId("nvidia/nemotron-3-super-120b-a12b")).toBe(true); }); + test("accepts Cloudflare Workers AI model ids with leading '@'", () => { + expect(isValidModelId("@cf/meta/llama-3-8b-instruct")).toBe(true); + expect(isValidModelId("@cf/moonshotai/kimi-k2.7-code")).toBe(true); + expect(isValidModelId("@cf/qwen/qwen1.5-14b-chat-awq")).toBe(true); + }); + test("rejects empty, whitespace-only, and over-long ids", () => { expect(isValidModelId("")).toBe(false); expect(isValidModelId(" ")).toBe(false);