Files
windmill/cli/test/guidance_writer_unit.test.ts
hugocasa 5bdc4f83ce feat(cli): improve agent prompts/skills and workspace fork workflow (#9531)
* feat(cli): improve agent prompts/skills and workspace fork workflow

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): refuse fork --from-branch rename of a base branch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): auto-detect fork branch workflow, drop rt.d.ts refresh and legacy-name warning

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): reconcile raw-app generate-metadata stance (agent offers+runs)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): agent runs all CLI commands, gated on intent not on user typing them

Extends #9467's safe-vs-destructive model: the agent runs consequential commands (sync push, generate-metadata) itself too, gated on explicit user intent rather than handed to the user to type. The explicit-intent rule is the safeguard; an approval prompt is treated as a possible backstop, not assumed (auto-approve/headless runs have none).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Revert "docs(skills): agent runs all CLI commands, gated on intent not on user typing them"

Reverts 9225e1759b. That commit over-reached: #9467 already established the safe-vs-destructive split, and the targeted item-6 fix already removed the passive "tell the user they can run <safe next step>" phrasing. The blanket "agent runs everything" principle pushed deploys to be more eager and carried a wrong "permission layer prompts for approval" claim (untrue in auto-approve/headless mode). Keep deploys conservative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): default fork workspace name/id to the current branch when renaming it

When 'wmill workspace fork' converts the current working branch into the fork branch, default the fork's name and id to that branch (sanitized to a slug, since branch names can contain '/'). Interactive: the prompt is pre-filled (enter to accept); non-interactive (--yes): used automatically. Adds a unit test for the slug derivation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): address fork review — guard fork-branch rename, cap+validate fork id

Two P2s from review:
- --from-branch refused when the current branch is already a fork branch (would detach the existing fork by renaming its branch).
- fork id slug capped to 42 chars (backend max 50 incl. wm-fork- prefix); auto-derived id is slugged; full id validated client-side before existsWorkspace/datatable cloning so an invalid id fails fast instead of leaving cloned Postgres databases behind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:16:16 +02:00

678 lines
25 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { writeAiGuidanceFiles } from "../src/guidance/writer.ts";
import {
currentPromptsHash,
extractPromptsHash,
injectPromptsHashMarker,
shouldRunFreshnessCheck,
warnIfPromptsStale,
} from "../src/guidance/freshness.ts";
const SKILL_TARGET_ROOTS = [".agents", ".claude"] as const;
async function withTempDir(fn: (tempDir: string) => Promise<void>): Promise<void> {
const tempDir = await mkdtemp(join(tmpdir(), "wmill_guidance_writer_"));
try {
await fn(tempDir);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
async function writeSkill(
rootDir: string,
skillName: string,
content: string
): Promise<string> {
const skillPath = join(rootDir, skillName, "SKILL.md");
await mkdir(join(rootDir, skillName), { recursive: true });
await writeFile(skillPath, content, "utf8");
return skillPath;
}
describe("writeAiGuidanceFiles — skills", () => {
test("preserves custom skills when refreshing generated guidance", async () => {
await withTempDir(async (tempDir) => {
const skillsDirs = SKILL_TARGET_ROOTS.map((root) =>
join(tempDir, root, "skills")
);
const customSkillContent = `---
name: custom-skill
description: Custom skill
---
Preserve me.
`;
const staleGeneratedContent = "stale generated skill";
const customSkillPaths = await Promise.all(
skillsDirs.map((skillsDir) =>
writeSkill(skillsDir, "custom-skill", customSkillContent)
)
);
const generatedSkillPaths = await Promise.all(
skillsDirs.map((skillsDir) =>
writeSkill(skillsDir, "write-flow", staleGeneratedContent)
)
);
await writeAiGuidanceFiles({ targetDir: tempDir });
// Custom skills survive on every side, untouched.
for (const customSkillPath of customSkillPaths) {
expect(await readFile(customSkillPath, "utf8")).toBe(customSkillContent);
}
// Both `.agents/skills/` and `.claude/skills/` hold the same full
// canonical content. (Claude's skill loader doesn't expand `@`
// references inside SKILL.md, so we can't dedupe via `@`-include.)
for (const generatedSkillPath of generatedSkillPaths) {
const generatedSkillContent = await readFile(generatedSkillPath, "utf8");
expect(generatedSkillContent).not.toBe(staleGeneratedContent);
expect(generatedSkillContent).toContain("name: write-flow");
expect(generatedSkillContent).not.toContain("@../../../");
}
});
});
test("preserves custom skills when copying a skill bundle from source", async () => {
await withTempDir(async (tempDir) => {
const skillsDirs = SKILL_TARGET_ROOTS.map((root) =>
join(tempDir, root, "skills")
);
const customSkillContent = `---
name: custom-skill
description: Custom skill
---
Keep this local skill.
`;
const sourceSkillContent = `---
name: write-flow
description: Replacement flow skill
---
Copied from source.
`;
const bundleOnlySkillContent = `---
name: bundle-only
description: Bundle only skill
---
Copied from source bundle.
`;
const customSkillPaths = await Promise.all(
skillsDirs.map((skillsDir) =>
writeSkill(skillsDir, "custom-skill", customSkillContent)
)
);
await Promise.all(
skillsDirs.map((skillsDir) =>
writeSkill(skillsDir, "write-flow", "old content")
)
);
const sourceSkillsDir = join(tempDir, "source-skills");
await writeSkill(sourceSkillsDir, "write-flow", sourceSkillContent);
await writeSkill(sourceSkillsDir, "bundle-only", bundleOnlySkillContent);
await writeAiGuidanceFiles({
targetDir: tempDir,
skillsSourcePath: sourceSkillsDir,
});
// Custom skills survive untouched on every side.
for (const customSkillPath of customSkillPaths) {
expect(await readFile(customSkillPath, "utf8")).toBe(customSkillContent);
}
// Source bundle is copied verbatim into both `.agents/skills/` and
// `.claude/skills/`. No `@`-include wrapping.
for (const root of SKILL_TARGET_ROOTS) {
expect(
await readFile(join(tempDir, root, "skills/write-flow/SKILL.md"), "utf8")
).toBe(sourceSkillContent);
expect(
await readFile(join(tempDir, root, "skills/bundle-only/SKILL.md"), "utf8")
).toBe(bundleOnlySkillContent);
}
});
});
test("AGENTS.wmill.md gets the skills reference from copied directory names", async () => {
await withTempDir(async (tempDir) => {
const sourceSkillsDir = join(tempDir, "source-skills");
await writeSkill(
sourceSkillsDir,
"custom-folder",
`---
name: write-flow
description: Custom bundle skill
---
Copied from source bundle.
`
);
await writeAiGuidanceFiles({
targetDir: tempDir,
skillsSourcePath: sourceSkillsDir,
});
const agentsCli = await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8");
expect(agentsCli).toContain(".agents/skills/custom-folder/SKILL.md");
expect(agentsCli).not.toContain(".agents/skills/write-flow/SKILL.md");
// The skill reference points at the .agents/ tree — not .claude/ —
// so the path is meaningful to Codex/Pi as well as Claude.
expect(agentsCli).not.toContain(".claude/skills/custom-folder/SKILL.md");
});
});
test("AGENTS.wmill.md and CLAUDE.md are written even if skills creation fails", async () => {
await withTempDir(async (tempDir) => {
// Create a file at .claude so mkdir of .claude/skills throws.
await writeFile(join(tempDir, ".claude"), "not a directory\n", "utf8");
await expect(
writeAiGuidanceFiles({ targetDir: tempDir })
).rejects.toThrow();
expect(await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8")).toContain(
".agents/skills/"
);
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain(
"@AGENTS.wmill.md"
);
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain(
"@AGENTS.md"
);
});
});
});
describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => {
test("creates a skeleton AGENTS.md (with @AGENTS.wmill.md include) when none exists", async () => {
await withTempDir(async (tempDir) => {
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.agentsCreated).toBe(true);
expect(result.agentsMigration).toBe("not-applicable");
const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(agentsMd).toContain("@AGENTS.wmill.md");
});
});
test("leaves an existing AGENTS.md alone when it already references @AGENTS.wmill.md", async () => {
await withTempDir(async (tempDir) => {
const original = "# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.wmill.md\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.agentsCreated).toBe(false);
expect(result.agentsMigration).toBe("already-linked");
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(original);
});
});
test("appends @AGENTS.wmill.md when the resolver returns 'append'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Existing custom AGENTS.md\n\nproject rules here.\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "append",
});
expect(result.agentsCreated).toBe(false);
expect(result.agentsMigration).toBe("append");
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(updated).toStartWith(original);
expect(updated).toContain("@AGENTS.wmill.md");
});
});
test("overwrites AGENTS.md with the managed skeleton when the resolver returns 'overwrite'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Some old AGENTS.md to be replaced\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "overwrite",
});
expect(result.agentsCreated).toBe(false);
expect(result.agentsMigration).toBe("overwrite");
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(updated).not.toBe(original);
expect(updated).toContain("@AGENTS.wmill.md");
});
});
test("leaves AGENTS.md untouched when the resolver returns 'skip'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Hand-managed AGENTS.md\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "skip",
});
expect(result.agentsCreated).toBe(false);
expect(result.agentsMigration).toBe("skip");
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(original);
});
});
test("defaults to 'append' when the resolver is not provided", async () => {
await withTempDir(async (tempDir) => {
const original = "# AGENTS.md\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.agentsMigration).toBe("append");
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(updated).toStartWith(original);
expect(updated).toContain("@AGENTS.wmill.md");
});
});
});
describe("writeAiGuidanceFiles — CLAUDE.md reconciliation", () => {
test("creates a skeleton CLAUDE.md (with @AGENTS.md include) when none exists", async () => {
await withTempDir(async (tempDir) => {
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.claudeCreated).toBe(true);
expect(result.claudeMigration).toBe("not-applicable");
const claudeMd = await readFile(join(tempDir, "CLAUDE.md"), "utf8");
expect(claudeMd).toContain("@AGENTS.md");
});
});
test("leaves an existing CLAUDE.md alone when it already references @AGENTS.md", async () => {
await withTempDir(async (tempDir) => {
const original = "# My CLAUDE.md\n\nlocal stuff\n\n@AGENTS.md\n";
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.claudeCreated).toBe(false);
expect(result.claudeMigration).toBe("already-linked");
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toBe(original);
});
});
test("appends @AGENTS.md when the resolver returns 'append'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Existing custom CLAUDE.md\n\nBe helpful.\n";
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "append",
});
expect(result.claudeCreated).toBe(false);
expect(result.claudeMigration).toBe("append");
const updated = await readFile(join(tempDir, "CLAUDE.md"), "utf8");
expect(updated).toStartWith(original);
expect(updated).toContain("@AGENTS.md");
});
});
test("overwrites CLAUDE.md with the managed skeleton when the resolver returns 'overwrite'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Some old CLAUDE.md\n";
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "overwrite",
});
expect(result.claudeCreated).toBe(false);
expect(result.claudeMigration).toBe("overwrite");
expect(
await readFile(join(tempDir, "CLAUDE.md"), "utf8")
).not.toBe(original);
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain(
"@AGENTS.md"
);
});
});
test("leaves CLAUDE.md untouched when the resolver returns 'skip'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Hand-managed CLAUDE.md\n";
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "skip",
});
expect(result.claudeCreated).toBe(false);
expect(result.claudeMigration).toBe("skip");
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toBe(original);
});
});
test("resolver is invoked at most once even if both files need it", async () => {
await withTempDir(async (tempDir) => {
const original = "# old\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
let resolverCalls = 0;
await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => {
resolverCalls += 1;
return "append";
},
});
expect(resolverCalls).toBe(1);
});
});
});
describe("writeAiGuidanceFiles — legacy AGENTS.cli.md migration", () => {
test("removes a legacy AGENTS.cli.md and rewrites the @AGENTS.cli.md include", async () => {
await withTempDir(async (tempDir) => {
// Simulate a project initialized by an older CLI.
await writeFile(
join(tempDir, "AGENTS.cli.md"),
"# old managed file\n",
"utf8"
);
await writeFile(
join(tempDir, "AGENTS.md"),
"# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.cli.md\n",
"utf8"
);
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
// Legacy file is gone; the new managed file is present.
expect(result.legacyManagedRemoved).toBe(true);
await expect(
readFile(join(tempDir, "AGENTS.cli.md"), "utf8")
).rejects.toThrow();
expect(
await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8")
).toContain(".agents/skills/");
// The include was rewritten in place (so it reads as already-linked,
// not a duplicate append).
const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(agentsMd).toContain("@AGENTS.wmill.md");
expect(agentsMd).not.toContain("@AGENTS.cli.md");
expect(result.agentsMigration).toBe("already-linked");
});
});
test("legacyManagedRemoved is false when there is no legacy file", async () => {
await withTempDir(async (tempDir) => {
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.legacyManagedRemoved).toBe(false);
});
});
test("does not rewrite a @AGENTS.cli.md.backup lookalike token", async () => {
await withTempDir(async (tempDir) => {
await writeFile(
join(tempDir, "AGENTS.md"),
"see @AGENTS.cli.md.backup\n\n@AGENTS.wmill.md\n",
"utf8"
);
await writeAiGuidanceFiles({ targetDir: tempDir });
const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8");
// The standalone backup reference (a different file) is preserved.
expect(agentsMd).toContain("@AGENTS.cli.md.backup");
});
});
});
describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () => {
test.each([
["bare line", "@AGENTS.wmill.md"],
["between blank lines", "before\n\n@AGENTS.wmill.md\n\nafter"],
["leading whitespace then include", " @AGENTS.wmill.md\n"],
["CRLF line endings", "line one\r\n@AGENTS.wmill.md\r\nline three"],
// Mid-sentence include: this is how our own CLAUDE.md default looks
// ("Instructions are in @AGENTS.md"). A strict line-equality check made
// `wmill refresh prompts` re-prompt every run on files wmill wrote.
["mid-sentence include", "Instructions are in @AGENTS.wmill.md\n"],
// `>` blockquote prefix doesn't disable Claude's `@`-import expansion,
// so we treat it as a reference too.
["blockquoted include", "> @AGENTS.wmill.md"],
])("treats %s as a reference (no append)", async (_label, content) => {
await withTempDir(async (tempDir) => {
await writeFile(join(tempDir, "AGENTS.md"), content, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.agentsMigration).toBe("already-linked");
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(content);
});
});
test.each([
["@AGENTS.wmill.md.backup", "@AGENTS.wmill.md.backup"],
["@AGENTS.wmill.mdx", "@AGENTS.wmill.mdx"],
["@AGENTS-cli-md (lookalike)", "@AGENTS-cli-md"],
["@AGENTS.wmill.md without surrounding whitespace", "foo@AGENTS.wmill.md"],
["commented-out include", "<!-- @AGENTS.wmill.md -->"],
])("does not treat %s as a reference (append happens)", async (_label, content) => {
await withTempDir(async (tempDir) => {
await writeFile(join(tempDir, "AGENTS.md"), content, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "append",
});
expect(result.agentsMigration).toBe("append");
});
});
});
describe("prompts freshness — hash marker", () => {
test("AGENTS.wmill.md written by writeAiGuidanceFiles carries a hash marker", async () => {
await withTempDir(async (tempDir) => {
await writeAiGuidanceFiles({ targetDir: tempDir });
const agentsCli = await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8");
const hash = extractPromptsHash(agentsCli);
expect(hash).not.toBeNull();
expect(hash).toMatch(/^[0-9a-f]{12}$/);
});
});
test("the stored hash matches currentPromptsHash for the same nonDottedPaths", async () => {
await withTempDir(async (tempDir) => {
// writeAiGuidanceFiles defaults nonDottedPaths to `false` (matching
// core/conf.ts's missing-key default).
await writeAiGuidanceFiles({ targetDir: tempDir });
const agentsCli = await readFile(join(tempDir, "AGENTS.wmill.md"), "utf8");
expect(extractPromptsHash(agentsCli)).toBe(currentPromptsHash(false));
});
});
test("nonDottedPaths setting changes the hash", () => {
expect(currentPromptsHash(true)).not.toBe(currentPromptsHash(false));
});
test("injectPromptsHashMarker places the marker after the H1 title", () => {
const input = "# Title\n\nbody line\n";
const out = injectPromptsHashMarker(input, "abc123def456");
const lines = out.split("\n");
expect(lines[0]).toBe("# Title");
expect(lines[1]).toBe("<!-- wmill-prompts-hash: abc123def456 -->");
expect(lines[2]).toBe("");
expect(lines[3]).toBe("body line");
});
test("injectPromptsHashMarker prepends when there's no H1", () => {
const input = "no heading\nrest\n";
const out = injectPromptsHashMarker(input, "abc123def456");
expect(out).toStartWith("<!-- wmill-prompts-hash: abc123def456 -->");
});
test("extractPromptsHash returns null when no marker is present", () => {
expect(extractPromptsHash("# Title\n\nno marker here\n")).toBeNull();
expect(extractPromptsHash("<!-- wmill-prompts-hash: tooshort -->")).toBeNull();
});
});
describe("prompts freshness — shouldRunFreshnessCheck", () => {
// Each input matches process.argv shape: [node, script, ...args].
test.each<[string, string[], boolean]>([
["empty argv", ["node", "wmill"], false],
["wmill --help", ["node", "wmill", "--help"], false],
["wmill -h on a subcommand", ["node", "wmill", "init", "-h"], false],
["wmill --version", ["node", "wmill", "--version"], false],
["wmill init", ["node", "wmill", "init"], false],
["wmill init prompts", ["node", "wmill", "init", "prompts"], false],
["wmill refresh prompts", ["node", "wmill", "refresh", "prompts"], false],
["wmill completions zsh", ["node", "wmill", "completions", "zsh"], false],
["wmill upgrade", ["node", "wmill", "upgrade"], false],
["wmill sync push", ["node", "wmill", "sync", "push"], true],
["wmill flow run", ["node", "wmill", "flow", "run"], true],
["wmill --verbose sync push", ["node", "wmill", "--verbose", "sync", "push"], true],
// Value-taking global options must skip their value when locating the
// first subcommand. Otherwise `wmill --workspace prod refresh prompts`
// would misread `"prod"` as the subcommand and trip the warning during
// the very command that's meant to fix it.
["wmill --workspace prod refresh prompts",
["node", "wmill", "--workspace", "prod", "refresh", "prompts"], false],
["wmill --token tok sync push",
["node", "wmill", "--token", "tok", "sync", "push"], true],
["wmill --base-url u --workspace w init",
["node", "wmill", "--base-url", "u", "--workspace", "w", "init"], false],
["wmill --config-dir /etc/wmill init prompts",
["node", "wmill", "--config-dir", "/etc/wmill", "init", "prompts"], false],
])("%s → %s", (_label, argv, expected) => {
expect(shouldRunFreshnessCheck(argv)).toBe(expected);
});
});
describe("prompts freshness — additional invariants", () => {
test("currentPromptsHash is deterministic across invocations in the same process", () => {
const h1 = currentPromptsHash(true);
const h2 = currentPromptsHash(true);
const h3 = currentPromptsHash(false);
const h4 = currentPromptsHash(false);
expect(h1).toBe(h2);
expect(h3).toBe(h4);
});
test("warnIfPromptsStale writes to stderr (never stdout)", async () => {
await withTempDir(async (tempDir) => {
// Write a tampered AGENTS.wmill.md so the freshness check trips.
await writeFile(
join(tempDir, "AGENTS.wmill.md"),
"# Windmill CLI Agent Instructions\n<!-- wmill-prompts-hash: 000000000000 -->\nbody\n",
"utf8"
);
const stdoutWrites: string[] = [];
const stderrWrites: string[] = [];
const originalStdout = process.stdout.write.bind(process.stdout);
const originalStderr = process.stderr.write.bind(process.stderr);
// @ts-expect-error — overriding write for the test
process.stdout.write = (chunk: any) => {
stdoutWrites.push(String(chunk));
return true;
};
// @ts-expect-error — overriding write for the test
process.stderr.write = (chunk: any) => {
stderrWrites.push(String(chunk));
return true;
};
try {
await warnIfPromptsStale({
cwd: tempDir,
nonDottedPaths: false,
argv: ["node", "wmill", "sync", "push"],
});
} finally {
process.stdout.write = originalStdout;
process.stderr.write = originalStderr;
}
const stderrJoined = stderrWrites.join("");
const stdoutJoined = stdoutWrites.join("");
expect(stderrJoined).toContain("out of date");
expect(stdoutJoined).not.toContain("out of date");
});
});
// Back-compat: a legacy AGENTS.cli.md is still hash-checked, and we do NOT
// warn merely because of the old filename — only when it's actually stale.
test("warnIfPromptsStale stays silent for an up-to-date legacy AGENTS.cli.md", async () => {
await withTempDir(async (tempDir) => {
const content = injectPromptsHashMarker(
"# Windmill CLI Agent Instructions\nbody\n",
currentPromptsHash(false)
);
await writeFile(join(tempDir, "AGENTS.cli.md"), content, "utf8");
const stderrWrites: string[] = [];
const originalStderr = process.stderr.write.bind(process.stderr);
// @ts-expect-error — overriding write for the test
process.stderr.write = (chunk: any) => {
stderrWrites.push(String(chunk));
return true;
};
try {
await warnIfPromptsStale({
cwd: tempDir,
nonDottedPaths: false,
argv: ["node", "wmill", "sync", "push"],
});
} finally {
process.stderr.write = originalStderr;
}
expect(stderrWrites.join("")).toBe("");
});
});
test("warnIfPromptsStale warns (naming the legacy file) for a stale AGENTS.cli.md", async () => {
await withTempDir(async (tempDir) => {
await writeFile(
join(tempDir, "AGENTS.cli.md"),
"# Windmill CLI Agent Instructions\n<!-- wmill-prompts-hash: 000000000000 -->\nbody\n",
"utf8"
);
const stderrWrites: string[] = [];
const originalStderr = process.stderr.write.bind(process.stderr);
// @ts-expect-error — overriding write for the test
process.stderr.write = (chunk: any) => {
stderrWrites.push(String(chunk));
return true;
};
try {
await warnIfPromptsStale({
cwd: tempDir,
nonDottedPaths: false,
argv: ["node", "wmill", "sync", "push"],
});
} finally {
process.stderr.write = originalStderr;
}
const out = stderrWrites.join("");
expect(out).toContain("out of date");
expect(out).toContain("AGENTS.cli.md");
});
});
});