mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
chore: simplify config update agents
This commit is contained in:
parent
57188ed78b
commit
989f318a1a
@ -2,7 +2,7 @@ import {
|
||||
getBranchConfigOverrideSource,
|
||||
recordConfigAgentRunResult,
|
||||
} from "@/lib/config";
|
||||
import { commitConfigUpdate, stopConfigAgentSandbox, type GithubRepoRef } from "@/lib/config/repo-agent";
|
||||
import { CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR, ConfigRepoCommitConflictError, commitConfigUpdate, stopConfigAgentSandbox, type GithubRepoRef } from "@/lib/config/repo-agent";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
|
||||
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
@ -83,13 +83,18 @@ export const POST = createSmartRouteHandler({
|
||||
outcome: { status: "success", commitUrl: result.commitUrl, newCommitHash: result.commitSha },
|
||||
});
|
||||
} catch (error) {
|
||||
captureError("config-github-commit", error);
|
||||
if (!(error instanceof ConfigRepoCommitConflictError)) {
|
||||
captureError("config-github-commit", error);
|
||||
}
|
||||
await stopConfigAgentSandbox(sandboxId);
|
||||
await recordConfigAgentRunResult({
|
||||
projectId,
|
||||
branchId,
|
||||
nowMs: Date.now(),
|
||||
outcome: { status: "error", error: "Failed to commit and push the config changes." },
|
||||
outcome: {
|
||||
status: "error",
|
||||
error: error instanceof ConfigRepoCommitConflictError ? CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR : "Failed to commit and push the config changes.",
|
||||
},
|
||||
}).catch((e) => captureError("config-github-commit-record-error", e));
|
||||
}
|
||||
});
|
||||
|
||||
34
apps/backend/src/lib/config/repo-agent.test.ts
Normal file
34
apps/backend/src/lib/config/repo-agent.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR, ConfigRepoCommitConflictError, isGitBranchConflictOutput } from "./repo-agent";
|
||||
|
||||
describe("config repo agent commit conflict detection", () => {
|
||||
it("detects GitHub non-fast-forward push rejection output", () => {
|
||||
expect(isGitBranchConflictOutput(`
|
||||
To https://github.com/acme/app.git
|
||||
! [rejected] HEAD -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://github.com/acme/app.git'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. Integrate the remote changes before pushing again.
|
||||
`)).toMatchInlineSnapshot(`true`);
|
||||
});
|
||||
|
||||
it("detects stale-info push rejection output", () => {
|
||||
expect(isGitBranchConflictOutput(`
|
||||
! [rejected] HEAD -> feature/config (stale info)
|
||||
error: failed to push some refs to 'https://github.com/acme/app.git'
|
||||
`)).toMatchInlineSnapshot(`true`);
|
||||
});
|
||||
|
||||
it("does not treat unrelated git failures as branch conflicts", () => {
|
||||
expect(isGitBranchConflictOutput("fatal: Authentication failed for 'https://github.com/acme/app.git/'")).toMatchInlineSnapshot(`false`);
|
||||
});
|
||||
|
||||
it("uses a safe user-facing conflict message", () => {
|
||||
expect(new ConfigRepoCommitConflictError().message).toMatchInlineSnapshot(
|
||||
`"The GitHub branch changed before the config commit could be pushed. Retry the update to apply the same changes on the latest branch."`,
|
||||
);
|
||||
expect(CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR).toMatchInlineSnapshot(
|
||||
`"The GitHub branch changed before the config commit could be pushed. Retry the update to apply the same changes on the latest branch."`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -42,6 +42,7 @@
|
||||
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
||||
import { captureError } from "@hexclave/shared/dist/utils/errors";
|
||||
import { Sandbox } from "@vercel/sandbox";
|
||||
import { buildCompleteConfigAgentPrompt, CONFIG_AGENT_REPO_TOOLS } from "../../../../../packages/shared-backend/src/config-agent";
|
||||
import { PRODUCTION_AI_PROXY_BASE_URL } from "../ai/proxy-url";
|
||||
|
||||
const AGENT_SDK_VERSION = "0.2.73";
|
||||
@ -66,6 +67,7 @@ export type GithubRepoRef = { owner: string, repo: string, branch: string };
|
||||
export type GithubTokenProvider = () => Promise<string>;
|
||||
|
||||
export type ConfigUpdateCommitResult = { mode: "commit-to-branch", branch: string, commitUrl: string, commitSha: string };
|
||||
export const CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR = "The GitHub branch changed before the config commit could be pushed. Retry the update to apply the same changes on the latest branch.";
|
||||
|
||||
export class ConfigRepoAgentError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
@ -74,6 +76,21 @@ export class ConfigRepoAgentError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigRepoCommitConflictError extends ConfigRepoAgentError {
|
||||
constructor(options?: { cause?: unknown }) {
|
||||
super(CONFIG_REPO_COMMIT_CONFLICT_SAFE_ERROR, options);
|
||||
this.name = "ConfigRepoCommitConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isGitBranchConflictOutput(output: string): boolean {
|
||||
const normalized = output.toLowerCase();
|
||||
return normalized.includes("non-fast-forward")
|
||||
|| normalized.includes("fetch first")
|
||||
|| normalized.includes("stale info")
|
||||
|| (normalized.includes("failed to push some refs") && normalized.includes("updates were rejected"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sandbox credentials + low-level command helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -193,22 +210,6 @@ function tokenlessUrl(ref: Pick<GithubRepoRef, "owner" | "repo">): string {
|
||||
// Agent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildUpdatePrompt(completeConfig: Record<string, unknown>): string {
|
||||
const json = JSON.stringify(completeConfig, null, 2);
|
||||
return `You are writing the Hexclave / Stack Auth configuration for this repository (your current working directory is the repo root). The dashboard is the source of truth, and the repo's config file must reflect the COMPLETE configuration below — this is the full desired config, NOT a partial change.
|
||||
|
||||
Find where the config lives (a \`*.config.ts\` that exports \`config\`, typically wrapped in \`defineHexclaveConfig(...)\` imported from \`@hexclave/react/config\` or a similar path; it may pull values from helper modules/imported files). Rewrite the exported config so it is structurally equal to this object — add anything missing (e.g. the full content of every sign-up rule, every enabled app), update changed values, and REMOVE config that is not present here:
|
||||
|
||||
${json}
|
||||
|
||||
Rules:
|
||||
- The exported config must end up deep-equal to the object above. Do NOT drop nested content (e.g. a sign-up rule must keep its condition/action/displayName, not collapse to just an \`enabled\` flag).
|
||||
- Write it as idiomatic TypeScript inside the existing \`defineHexclaveConfig({ ... })\` wrapper, keeping that import. Use unquoted identifier keys where valid; keep keys that aren't valid identifiers (e.g. ids containing "-") quoted.
|
||||
- If the config currently exports the placeholder string "show-onboarding" (or is otherwise a stub), replace it with \`defineHexclaveConfig({ ... })\` containing this object.
|
||||
- If a value is conventionally sourced from an imported external file, you may keep that indirection as long as the resolved config matches. Preserve the file header comment and any genuinely-used helper imports. Do not touch unrelated files or application code.
|
||||
- Make the edits, then stop. Do NOT install dependencies, run builds, or run a type check — the repository's own CI validates the change after we push. Dependencies are intentionally NOT installed in this sandbox, so build/typecheck commands will fail; don't run them.`;
|
||||
}
|
||||
|
||||
/** Runner executed INSIDE the sandbox (no token in its env). Reads input from a
|
||||
* file and persists status to a file; process handlers catch the SDK's async errors. */
|
||||
function buildRunnerScript(): string {
|
||||
@ -253,7 +254,7 @@ for await (const m of query({
|
||||
prompt: input.prompt,
|
||||
options: {
|
||||
model: input.model,
|
||||
allowedTools: ["Read", "Edit", "Write", "Glob", "Grep", "Bash"],
|
||||
allowedTools: ${JSON.stringify([...CONFIG_AGENT_REPO_TOOLS])},
|
||||
permissionMode: "dontAsk",
|
||||
cwd: ${JSON.stringify(REPO_DIR)},
|
||||
env: { ...process.env, ANTHROPIC_BASE_URL: input.baseUrl, ANTHROPIC_API_KEY: input.apiKey, CLAUDECODE: "" },
|
||||
@ -364,6 +365,23 @@ async function gitHead(sandbox: Sandbox): Promise<string> {
|
||||
return (await run(sandbox, "git", ["-C", REPO_DIR, "rev-parse", "HEAD"])).stdout.trim();
|
||||
}
|
||||
|
||||
async function assertRemoteBranchStillAtClonedHead(sandbox: Sandbox, githubToken: string, ref: GithubRepoRef): Promise<void> {
|
||||
const clonedHead = await gitHead(sandbox);
|
||||
await run(sandbox, "git", [
|
||||
"-C",
|
||||
REPO_DIR,
|
||||
"fetch",
|
||||
"--depth",
|
||||
"1",
|
||||
tokenUrl(githubToken, ref),
|
||||
`+refs/heads/${ref.branch}:refs/remotes/origin/${ref.branch}`,
|
||||
]);
|
||||
const remoteHead = (await run(sandbox, "git", ["-C", REPO_DIR, "rev-parse", `refs/remotes/origin/${ref.branch}`])).stdout.trim();
|
||||
if (remoteHead !== clonedHead) {
|
||||
throw new ConfigRepoCommitConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots a config-agent sandbox with the agent SDK available. If a prebuilt base
|
||||
* snapshot is configured (`STACK_CONFIG_AGENT_BASE_SNAPSHOT_ID`) the SDK is already
|
||||
@ -490,10 +508,14 @@ export async function applyConfigUpdate(options: {
|
||||
await run(sandbox, "git", ["-C", REPO_DIR, "remote", "set-url", "origin", tokenlessUrl(ref)]);
|
||||
|
||||
// Agent writes the COMPLETE config to the file — no dependency install, no
|
||||
// typecheck (the linked repo's CI validates the committed change). See buildUpdatePrompt.
|
||||
// typecheck (the linked repo's CI validates the committed change).
|
||||
await reportConfigAgentStage(onStage, "agent_making_changes");
|
||||
await step("Agent editing config…");
|
||||
await runAgent(sandbox, buildUpdatePrompt(completeConfig), onProgress);
|
||||
await runAgent(sandbox, buildCompleteConfigAgentPrompt({
|
||||
scope: { mode: "repo" },
|
||||
completeConfig,
|
||||
commandPolicy: "Do NOT install dependencies, run builds, or run a type check. The repository's own CI validates the change after we push, and dependencies are intentionally not installed in this sandbox.",
|
||||
}), onProgress);
|
||||
|
||||
const dirty = (await runRaw(sandbox, "git", ["-C", REPO_DIR, "status", "--porcelain"])).stdout.trim();
|
||||
if (dirty === "") {
|
||||
@ -529,11 +551,19 @@ export async function commitConfigUpdate(options: {
|
||||
const githubToken = await options.getGithubToken();
|
||||
const sandbox = await getConfigAgentSandbox(sandboxId);
|
||||
try {
|
||||
await assertRemoteBranchStillAtClonedHead(sandbox, githubToken, ref);
|
||||
await run(sandbox, "git", ["-C", REPO_DIR, "add", "-A"]);
|
||||
await run(sandbox, "git", ["-C", REPO_DIR, "commit", "-m", commitMessage]);
|
||||
const commitSha = await gitHead(sandbox);
|
||||
// Re-inject the token for the push only.
|
||||
await run(sandbox, "git", ["-C", REPO_DIR, "push", tokenUrl(githubToken, ref), `HEAD:refs/heads/${ref.branch}`]);
|
||||
try {
|
||||
await run(sandbox, "git", ["-C", REPO_DIR, "push", tokenUrl(githubToken, ref), `HEAD:refs/heads/${ref.branch}`]);
|
||||
} catch (error) {
|
||||
if (isGitBranchConflictOutput(error instanceof Error ? error.message : String(error))) {
|
||||
throw new ConfigRepoCommitConflictError({ cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { mode: "commit-to-branch", branch: ref.branch, commitUrl: `https://github.com/${ref.owner}/${ref.repo}/commit/${commitSha}`, commitSha };
|
||||
} finally {
|
||||
await stopSandboxWithContext(sandboxId, "config-repo-agent-commit-stop");
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Link } from "@/components/link";
|
||||
import { DesignButton, DesignDialog, DesignDialogClose } from "@/components/design-components";
|
||||
import { DesignAlert, DesignButton, DesignDialog, DesignDialogClose } from "@/components/design-components";
|
||||
import { useDashboardInternalUser } from "@/lib/dashboard-user";
|
||||
import { ArrowsClockwise, GitBranch, GitCommit } from "@phosphor-icons/react";
|
||||
import type { OAuthConnection, StackAdminApp } from "@hexclave/next";
|
||||
@ -180,7 +180,7 @@ export function GithubPushDialog({ open, adminApp, source, configUpdate, project
|
||||
loading={scopeStatus === "checking"}
|
||||
>
|
||||
<ArrowsClockwise className="h-3.5 w-3.5 mr-1.5" />
|
||||
Start update
|
||||
{errorMessage != null && configUpdate != null ? "Retry update" : "Start update"}
|
||||
</DesignButton>
|
||||
)}
|
||||
</div>
|
||||
@ -345,6 +345,9 @@ function GithubPushBody({
|
||||
}
|
||||
|
||||
onErrorChange(null);
|
||||
onDiffChange(null);
|
||||
onActivityChange(null);
|
||||
onStageChange(null);
|
||||
try {
|
||||
const tokenResult = await scopeCheck.account.getAccessToken({ scopes: GITHUB_SCOPE_REQUIREMENTS });
|
||||
if (tokenResult.status !== "ok") {
|
||||
@ -404,7 +407,7 @@ function GithubPushBody({
|
||||
if (run.status === "error") {
|
||||
onPhaseChange("idle");
|
||||
onStageChange(null);
|
||||
onErrorChange("The config agent failed to apply your change.");
|
||||
onErrorChange(run.error ?? "The config agent failed to apply your change.");
|
||||
return;
|
||||
}
|
||||
if (run.status === "cancelled") {
|
||||
@ -488,11 +491,13 @@ function GithubPushBody({
|
||||
});
|
||||
if (result.status === "sandbox-expired") {
|
||||
onPhaseChange("idle");
|
||||
onStageChange(null);
|
||||
onErrorChange("The sandbox session expired. Please retry the update.");
|
||||
return;
|
||||
}
|
||||
if (result.status === "not-awaiting-review") {
|
||||
onPhaseChange("idle");
|
||||
onStageChange(null);
|
||||
onErrorChange("There is no config diff waiting to commit. Start the update again.");
|
||||
return;
|
||||
}
|
||||
@ -515,8 +520,9 @@ function GithubPushBody({
|
||||
return;
|
||||
}
|
||||
if (run.status === "error") {
|
||||
onPhaseChange("awaiting_review");
|
||||
onErrorChange("Failed to commit and push the changes. Please try again.");
|
||||
onPhaseChange("idle");
|
||||
onStageChange(null);
|
||||
onErrorChange(run.error ?? "Failed to commit and push the changes. Please try again.");
|
||||
return;
|
||||
}
|
||||
if (run.status === "cancelled") {
|
||||
@ -529,10 +535,11 @@ function GithubPushBody({
|
||||
onErrorChange("Timed out waiting for the commit. Check the repository for status.");
|
||||
} catch (error) {
|
||||
captureError("config-update-github-commit", error);
|
||||
onPhaseChange("awaiting_review");
|
||||
onPhaseChange("idle");
|
||||
onStageChange(null);
|
||||
onErrorChange("Unknown error committing to GitHub.");
|
||||
}
|
||||
}, [adminApp, commitMessage, onErrorChange, onPhaseChange, onSettle, scopeCheck]);
|
||||
}, [adminApp, commitMessage, onErrorChange, onPhaseChange, onSettle, onStageChange, scopeCheck]);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
try {
|
||||
@ -567,7 +574,7 @@ function GithubPushBody({
|
||||
|
||||
{/* Error */}
|
||||
{phase !== "running" && phase !== "cancelling" && errorMessage != null && (
|
||||
<p className="rounded-lg bg-destructive/8 px-3 py-2 text-sm text-destructive">{errorMessage}</p>
|
||||
<DesignAlert variant="error" description={errorMessage} />
|
||||
)}
|
||||
|
||||
{/* Unlink hint — shown in idle state */}
|
||||
|
||||
@ -457,7 +457,7 @@ describe("Stack CLI", () => {
|
||||
it("config pull writes a .ts file", async ({ expect }) => {
|
||||
configTsPath = path.join(tmpDir, "config.ts");
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["config", "pull", "--cloud-project-id", createdProjectId, "--config-file", configTsPath, "--overwrite"],
|
||||
["config", "pull", "--cloud-project-id", createdProjectId, "--config-file", configTsPath],
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Config written to");
|
||||
@ -494,27 +494,28 @@ describe("Stack CLI", () => {
|
||||
expect(stderr).toContain("plain `config` object");
|
||||
});
|
||||
|
||||
it("config pull rejects overwriting an existing file without --overwrite", async ({ expect }) => {
|
||||
it("config pull overwrites an existing file by default", async ({ expect }) => {
|
||||
const existingConfigPath = path.join(tmpDir, "existing-config.ts");
|
||||
fs.writeFileSync(existingConfigPath, "existing\n");
|
||||
|
||||
const { stderr, exitCode } = await runCli(
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["config", "pull", "--cloud-project-id", createdProjectId, "--config-file", existingConfigPath],
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("re-run with --overwrite");
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Config written to");
|
||||
const content = fs.readFileSync(existingConfigPath, "utf-8");
|
||||
expect(content).toContain("export const config: HexclaveConfig");
|
||||
});
|
||||
|
||||
it("config pull falls back to ./stack.config.ts in cwd when --config-file is omitted", async ({ expect }) => {
|
||||
it("config pull falls back to ./hexclave.config.ts in cwd when --config-file is omitted", async ({ expect }) => {
|
||||
// realpathSync normalizes macOS's /var/folders/... → /private/var/folders/...
|
||||
// (Node resolves the symlink when reporting the written path).
|
||||
const cwdDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-config-pull-cwd-")));
|
||||
const expected = path.join(cwdDir, "stack.config.ts");
|
||||
fs.writeFileSync(expected, "// placeholder so the file exists\n");
|
||||
const expected = path.join(cwdDir, "hexclave.config.ts");
|
||||
try {
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["config", "pull", "--cloud-project-id", createdProjectId, "--overwrite"],
|
||||
["config", "pull", "--cloud-project-id", createdProjectId],
|
||||
undefined,
|
||||
cwdDir,
|
||||
);
|
||||
@ -527,16 +528,20 @@ describe("Stack CLI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("config pull errors when --config-file is omitted and cwd has no stack.config.ts", async ({ expect }) => {
|
||||
it("config pull still prefers an existing ./stack.config.ts when --config-file is omitted", async ({ expect }) => {
|
||||
const cwdDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-config-pull-empty-")));
|
||||
const expected = path.join(cwdDir, "stack.config.ts");
|
||||
fs.writeFileSync(expected, "// placeholder so the file exists\n");
|
||||
try {
|
||||
const { stderr, exitCode } = await runCli(
|
||||
const { stdout, exitCode } = await runCli(
|
||||
["config", "pull", "--cloud-project-id", createdProjectId],
|
||||
undefined,
|
||||
cwdDir,
|
||||
);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Pass --config-file");
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain(`Config written to ${expected}`);
|
||||
const content = fs.readFileSync(expected, "utf-8");
|
||||
expect(content).toContain("export const config: HexclaveConfig");
|
||||
} finally {
|
||||
fs.rmSync(cwdDir, { recursive: true });
|
||||
}
|
||||
|
||||
@ -41,8 +41,8 @@ describe("resolveConfigFilePathForPull", () => {
|
||||
expect(resolveConfigFilePathForPull({ configFile: "" }, tmpDir)).toBe(expected);
|
||||
});
|
||||
|
||||
it("throws a CliError with help text when neither --config-file nor cwd stack.config.ts exists", () => {
|
||||
expect(() => resolveConfigFilePathForPull({}, tmpDir)).toThrow(/Pass --config-file/);
|
||||
it("defaults to ./hexclave.config.ts when neither --config-file nor cwd config file exists", () => {
|
||||
expect(resolveConfigFilePathForPull({}, tmpDir)).toBe(path.join(tmpDir, "hexclave.config.ts"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { replaceConfigObject, updateConfigObject } from "@hexclave/shared-backend";
|
||||
import { replaceConfigObject } from "@hexclave/shared-backend";
|
||||
import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-eval";
|
||||
import { isValidConfig } from "@hexclave/shared/dist/config/format";
|
||||
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
|
||||
@ -195,21 +195,21 @@ function sourceToSdkSource(source: BranchConfigSourceApi):
|
||||
return { type: "unlinked" };
|
||||
}
|
||||
|
||||
// Resolve the path for `config pull` when `--config-file` was omitted. Falls
|
||||
// back to a config file in cwd, and throws a CliError with a clear hint
|
||||
// if it isn't there. Exported for unit tests.
|
||||
// Resolve the path for `config pull` when `--config-file` was omitted. Prefer
|
||||
// an existing config file in cwd, otherwise use the Hexclave default path so a
|
||||
// prod-to-local pull can create the local config file without extra flags.
|
||||
export function resolveConfigFilePathForPull(opts: { configFile?: string }, cwd: string): string {
|
||||
if (opts.configFile != null && opts.configFile !== "") {
|
||||
return resolveConfigFilePathOption(opts.configFile);
|
||||
}
|
||||
// Hexclave rebrand: prefer the new `hexclave.config.ts` filename, fall back
|
||||
// to the legacy `stack.config.ts` so existing projects keep working. If
|
||||
// neither exists, default to the new filename for the error/directory hint.
|
||||
// neither exists, create the new filename.
|
||||
const hexclaveCandidate = path.join(cwd, "hexclave.config.ts");
|
||||
const legacyCandidate = path.join(cwd, "stack.config.ts");
|
||||
const candidate = fs.existsSync(hexclaveCandidate) ? hexclaveCandidate : legacyCandidate;
|
||||
if (!fs.existsSync(candidate)) {
|
||||
throw new CliError("No --config-file provided and no hexclave.config.ts (or stack.config.ts) found in the current directory. Pass --config-file <path> or run this command in a directory containing a config file.");
|
||||
return hexclaveCandidate;
|
||||
}
|
||||
if (fs.statSync(candidate).isDirectory()) {
|
||||
throw new CliError(`Default config path points to a directory instead of a file: ${candidate}`);
|
||||
@ -226,8 +226,7 @@ export function registerConfigCommand(program: Command) {
|
||||
.command("pull")
|
||||
.description("Pull branch config to a local file")
|
||||
.option("--cloud-project-id <id>", "Cloud project ID to pull config from (defaults to the STACK_PROJECT_ID env var)")
|
||||
.option("--config-file <path>", "Path to write config file (.ts); defaults to ./stack.config.ts in the current directory")
|
||||
.option("--overwrite", "Overwrite an existing config file instead of updating it in place")
|
||||
.option("--config-file <path>", "Path to write config file (.ts); defaults to ./hexclave.config.ts in the current directory")
|
||||
.action(async (opts) => {
|
||||
const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
|
||||
if (!isProjectAuthWithRefreshToken(auth)) {
|
||||
@ -246,11 +245,7 @@ export function registerConfigCommand(program: Command) {
|
||||
throw new CliError("Config file must have a .ts extension. Typed config files require TypeScript.");
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath) && !opts.overwrite) {
|
||||
await updateConfigObject(filePath, configOverride);
|
||||
} else {
|
||||
await replaceConfigObject(filePath, configOverride);
|
||||
}
|
||||
await replaceConfigObject(filePath, configOverride);
|
||||
console.log(`Config written to ${filePath}`);
|
||||
});
|
||||
|
||||
|
||||
45
packages/shared-backend/src/config-agent-prompt.test.ts
Normal file
45
packages/shared-backend/src/config-agent-prompt.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCompleteConfigAgentPrompt, buildPartialConfigAgentPrompt, CONFIG_AGENT_FILE_TOOLS, CONFIG_AGENT_REPO_TOOLS } from "./config-agent";
|
||||
|
||||
describe("config agent prompt", () => {
|
||||
it("uses the same core rules for complete repo edits", () => {
|
||||
expect(buildCompleteConfigAgentPrompt({
|
||||
scope: { mode: "repo" },
|
||||
completeConfig: { auth: { allowSignUp: false } },
|
||||
commandPolicy: "Do not run builds.",
|
||||
})).toMatchInlineSnapshot(`
|
||||
"You are updating a Hexclave / Stack Auth configuration file.
|
||||
|
||||
Current working directory: the repository root. Find the Hexclave / Stack Auth config file. It is usually a \`*.config.ts\` file exporting \`config\`, often wrapped in \`defineHexclaveConfig(...)\` or a similar helper.
|
||||
|
||||
The exported config must end up deep-equal to this JSON value:
|
||||
|
||||
{
|
||||
"auth": {
|
||||
"allowSignUp": false
|
||||
}
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Keep the file valid: it must still export \`config\`.
|
||||
- Preserve the existing authoring style where possible: imports, comments, helper wrappers, file header comments, and formatting.
|
||||
- If the config currently exports the placeholder string "show-onboarding" or is otherwise a stub, replace it with a real typed config object.
|
||||
- If a config value is conventionally sourced from an imported external file, you may keep that indirection as long as the evaluated config matches the requested value.
|
||||
- Do not touch unrelated files or application code.
|
||||
- Do not run builds.
|
||||
- Make the edits, then stop."
|
||||
`);
|
||||
});
|
||||
|
||||
it("supports partial known-file edits for unevaluable local configs", () => {
|
||||
expect(buildPartialConfigAgentPrompt({
|
||||
configFileName: "hexclave.config.ts",
|
||||
changes: [{ path: "auth.allowSignUp", value: false }],
|
||||
commandPolicy: "Do not run shell commands.",
|
||||
})).toContain(`- "auth.allowSignUp": set to false`);
|
||||
});
|
||||
|
||||
it("keeps repo tools as the file tools plus Bash", () => {
|
||||
expect(CONFIG_AGENT_REPO_TOOLS).toEqual([...CONFIG_AGENT_FILE_TOOLS, "Bash"]);
|
||||
});
|
||||
});
|
||||
@ -35,6 +35,89 @@ export type RunClaudeAgentResult = {
|
||||
resultText: string,
|
||||
};
|
||||
|
||||
export const CONFIG_AGENT_FILE_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep"] as const;
|
||||
export const CONFIG_AGENT_REPO_TOOLS = [...CONFIG_AGENT_FILE_TOOLS, "Bash"] as const;
|
||||
|
||||
type ConfigAgentPromptTarget =
|
||||
| {
|
||||
mode: "complete",
|
||||
completeConfig: Record<string, unknown>,
|
||||
}
|
||||
| {
|
||||
mode: "partial",
|
||||
changes: Array<{ path: string, value: unknown }>,
|
||||
};
|
||||
|
||||
export type ConfigAgentPromptScope =
|
||||
| {
|
||||
mode: "known-file",
|
||||
configFileName: string,
|
||||
}
|
||||
| {
|
||||
mode: "repo",
|
||||
};
|
||||
|
||||
function buildConfigAgentPrompt(options: {
|
||||
scope: ConfigAgentPromptScope,
|
||||
target: ConfigAgentPromptTarget,
|
||||
commandPolicy: string,
|
||||
}): string {
|
||||
const targetSection = options.target.mode === "complete"
|
||||
? `The exported config must end up deep-equal to this JSON value:\n\n${JSON.stringify(options.target.completeConfig, null, 2)}`
|
||||
: `Apply EXACTLY these config changes. Paths use dot notation, so \`a.b.c\` refers to \`config.a.b.c\`:\n\n${options.target.changes.map(({ path, value }) => `- ${JSON.stringify(path)}: set to ${JSON.stringify(value)}`).join("\n")}`;
|
||||
const scopeSection = options.scope.mode === "known-file"
|
||||
? `Config file: ${JSON.stringify(options.scope.configFileName)} in the current working directory.`
|
||||
: "Current working directory: the repository root. Find the Hexclave / Stack Auth config file. It is usually a `*.config.ts` file exporting `config`, often wrapped in `defineHexclaveConfig(...)` or a similar helper.";
|
||||
|
||||
return `You are updating a Hexclave / Stack Auth configuration file.
|
||||
|
||||
${scopeSection}
|
||||
|
||||
${targetSection}
|
||||
|
||||
Rules:
|
||||
- Keep the file valid: it must still export \`config\`.
|
||||
- Preserve the existing authoring style where possible: imports, comments, helper wrappers, file header comments, and formatting.
|
||||
- If the config currently exports the placeholder string "show-onboarding" or is otherwise a stub, replace it with a real typed config object.
|
||||
- If a config value is conventionally sourced from an imported external file, you may keep that indirection as long as the evaluated config matches the requested value.
|
||||
- Do not touch unrelated files or application code.
|
||||
- ${options.commandPolicy}
|
||||
- Make the edits, then stop.`;
|
||||
}
|
||||
|
||||
export function buildCompleteConfigAgentPrompt(options: {
|
||||
scope: ConfigAgentPromptScope,
|
||||
completeConfig: Record<string, unknown>,
|
||||
commandPolicy: string,
|
||||
}): string {
|
||||
return buildConfigAgentPrompt({
|
||||
scope: options.scope,
|
||||
target: {
|
||||
mode: "complete",
|
||||
completeConfig: options.completeConfig,
|
||||
},
|
||||
commandPolicy: options.commandPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildPartialConfigAgentPrompt(options: {
|
||||
configFileName: string,
|
||||
changes: Array<{ path: string, value: unknown }>,
|
||||
commandPolicy: string,
|
||||
}): string {
|
||||
return buildConfigAgentPrompt({
|
||||
scope: {
|
||||
mode: "known-file",
|
||||
configFileName: options.configFileName,
|
||||
},
|
||||
target: {
|
||||
mode: "partial",
|
||||
changes: options.changes,
|
||||
},
|
||||
commandPolicy: options.commandPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
export class ClaudeAgentTimeoutError extends Error {
|
||||
constructor(timeoutMs?: number) {
|
||||
super(`Claude agent timed out${timeoutMs == null ? "" : ` after ${timeoutMs}ms`}.`);
|
||||
|
||||
@ -3,7 +3,7 @@ import type { Config, ConfigValue, NormalizedConfig } from "@hexclave/shared/dis
|
||||
import { normalize, override } from "@hexclave/shared/dist/config/format";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { ClaudeAgentFailureError, ClaudeAgentTimeoutError, getToolWriteTargetPath, isPathInsideDir, runHeadlessClaudeAgent } from "./config-agent";
|
||||
import { buildCompleteConfigAgentPrompt, buildPartialConfigAgentPrompt, ClaudeAgentFailureError, ClaudeAgentTimeoutError, CONFIG_AGENT_FILE_TOOLS, getToolWriteTargetPath, isPathInsideDir, runHeadlessClaudeAgent } from "./config-agent";
|
||||
import { ensureConfigFileExists, readConfigFile } from "./config-file";
|
||||
|
||||
const LOG_PREFIX = "[Stack config updater]";
|
||||
@ -57,7 +57,7 @@ async function runConfigUpdateAgent(options: {
|
||||
await runHeadlessClaudeAgent({
|
||||
prompt: options.prompt,
|
||||
cwd: options.cwd,
|
||||
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep"],
|
||||
allowedTools: [...CONFIG_AGENT_FILE_TOOLS],
|
||||
strictIsolation: true,
|
||||
timeoutMs,
|
||||
stderr: (data) => { console.warn(`${LOG_PREFIX} [agent] ${data}`); },
|
||||
@ -232,36 +232,19 @@ function flattenConfigUpdate(update: Config): ConfigChange[] {
|
||||
|
||||
function buildConfigUpdatePrompt(configFileName: string, configUpdate: Config, baselineConfig: Config | null): string {
|
||||
const changes = flattenConfigUpdate(configUpdate);
|
||||
const changeLines = changes.map(({ path: configPath, value }) => {
|
||||
return `- ${JSON.stringify(configPath)}: set to ${JSON.stringify(value)}`;
|
||||
}).join("\n");
|
||||
const expectedConfig = baselineConfig == null ? null : canonicalizeConfig(override(baselineConfig, configUpdate));
|
||||
const expectedConfigSection = expectedConfig == null ? "" : `
|
||||
After the edit, evaluating the exported \`config\` must produce this exact JSON value:
|
||||
|
||||
${JSON.stringify(expectedConfig, null, 2)}
|
||||
`;
|
||||
|
||||
return `You are editing a Hexclave / Stack Auth configuration file in place. Apply a set of configuration changes WITHOUT changing how the file is written.
|
||||
|
||||
Config file: ${JSON.stringify(configFileName)} (in the current working directory).
|
||||
|
||||
The file exports a \`config\` object (it may be wrapped in a helper such as \`defineStackConfig(...)\`). Some config values may be sourced from other files via imports, for example:
|
||||
|
||||
import welcomeEmail from "./welcome-email.tsx" with { type: "text" };
|
||||
export const config = { emails: { templates: { welcome: welcomeEmail } } };
|
||||
|
||||
Apply EXACTLY these changes. Paths use dot notation, so \`a.b.c\` refers to \`config.a.b.c\`:
|
||||
|
||||
${changeLines}
|
||||
${expectedConfigSection}
|
||||
|
||||
Rules:
|
||||
- Change ONLY the config paths listed above. Leave every other part of the file byte-for-byte unchanged: imports, comments, formatting, helper wrappers, and any config fields not listed.
|
||||
- If a listed path's value is currently provided by an imported external file (like the \`import ... with { type: "text" }\` example above), DO NOT inline the new value into the config file. Instead, overwrite that external file with the new value and keep the import statement intact.
|
||||
- If a listed path's value is a plain inline literal, edit it inline.
|
||||
- Keep the file valid: it must still export a \`config\` that, once evaluated, reflects the new values exactly.
|
||||
- Do not run any shell commands and do not create files other than what is required to apply these changes.`;
|
||||
const commandPolicy = "Do not run shell commands and do not create files other than what is required to apply the config changes.";
|
||||
if (baselineConfig != null) {
|
||||
return buildCompleteConfigAgentPrompt({
|
||||
scope: { mode: "known-file", configFileName },
|
||||
completeConfig: canonicalizeConfig(override(baselineConfig, configUpdate)),
|
||||
commandPolicy,
|
||||
});
|
||||
}
|
||||
return buildPartialConfigAgentPrompt({
|
||||
configFileName,
|
||||
changes,
|
||||
commandPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
function canonicalizeConfig(config: Config): NormalizedConfig {
|
||||
|
||||
@ -888,6 +888,7 @@ export const configAgentSafeErrorMessages = [
|
||||
"The config agent failed to apply the change.",
|
||||
"Sandbox session expired. Please retry the update.",
|
||||
"Failed to commit and push the config changes.",
|
||||
"The GitHub branch changed before the config commit could be pushed. Retry the update to apply the same changes on the latest branch.",
|
||||
] as const;
|
||||
export type ConfigAgentSafeErrorMessage = typeof configAgentSafeErrorMessages[number];
|
||||
export const configAgentSafeErrorMessageSchema = yupString().oneOf(configAgentSafeErrorMessages);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user