stack/packages/shared-backend/src/index.test.ts
Mantra f38c9d85e7
Replace writeConfigObject with AI-aware updateConfigObject (#1537)
## Summary

Replaces `writeConfigObject` (destructive overwrite) with
`updateConfigObject` — an async, AI-aware updater that preserves
user-authored config structure (imports, external file references,
helpers).

**Dual-path approach:**
- **Fast path** (deterministic, no AI): plain static literal configs →
`override()` + in-memory validation + atomic write
- **Agent path** (custom structure): configs with `import x from
"./file.txt" with { type: "text" }` etc. → Claude agent edits the
external files in place, then validates

**Safety guarantees:**
- Snapshot/restore: config + all relative imports are captured before
the agent runs; rolled back on any failure
- In-memory validation on fast path (never write unvalidated bytes)
- Semantic check when config is evaluable; no-op detection + structural
check when it isn't
- Path traversal guard on imports (rejects `../` escapes)
- Agent isolation: `settingSources: []`, `strictMcpConfig: true`,
`CLAUDE_CODE_DISABLE_AUTO_MEMORY`, no Bash tool
- `scheduleSync` only fires after a successful update
- Bounded 120s timeout on agent runs (configurable via env var)

CI failures are preexisting on `dev`
(`ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` from overrides move without
lockfile regen); this branch has zero lockfile changes vs dev.

Link to Devin session:
https://app.devin.ai/sessions/cc7409a357bc472ea19fbed065f1229f
Requested by: @mantrakp04

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced partial configuration update functionality with validation
and automatic rollback on failures.
* Enhanced configuration management with support for more complex file
structures and external references.

* **Chores**
* Added Claude Agent SDK dependency for configuration update operations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

## Documentation

Docs for this feature were added in this branch:

- **New page**
`docs-mintlify/guides/going-further/local-development.mdx` — covers
`stack dev`, the development-environment flow, and how dashboard edits
are written back to the local config file (structure-preserving fast
path vs. assistant path, external `import … with { type: "text" }`
templates, validation + rollback). Added to `docs.json` nav; also fixes
the previously-broken `/guides/going-further/local-development` links
from `index.mdx` and `self-host.mdx`.
- **`docs-mintlify/guides/going-further/cli.mdx`** — added a `stack dev`
("Run a development environment") section.
- **Skill-site AI prompts** — filled in the `config-docs` and
`dashboard-instructions` placeholders under
`packages/stack-shared/src/ai/unified-prompts/skill-site-prompt-parts/`,
and added a structure-preserving note to the setup prompt.
- **`CHANGELOG.md`** — user-facing entry.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mantra <mantra@stack-auth.com>
2026-06-15 12:00:24 -07:00

124 lines
4.6 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
import path from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type MockToolWrite = { tool_name: string, file_path: string };
let mockScriptedWrites: MockToolWrite[] = [];
let mockHookDecisions: unknown[] = [];
let mockAfterWrites: (() => void) | null = null;
let tempDir: string | undefined;
vi.mock("./config-agent", async (importOriginal) => {
const actual = await importOriginal<typeof import("./config-agent")>();
return {
...actual,
runHeadlessClaudeAgent: async (options: {
cwd: string,
onPreToolUse?: (input: { hook_event_name: "PreToolUse", tool_name: string, tool_input: unknown }) => Promise<unknown> | unknown,
}) => {
for (const write of mockScriptedWrites) {
const decision = await options.onPreToolUse?.({
hook_event_name: "PreToolUse",
tool_name: write.tool_name,
tool_input: { file_path: write.file_path },
});
mockHookDecisions.push(decision);
}
mockAfterWrites?.();
return { resultText: "done" };
},
};
});
function getTempDir(): string {
if (tempDir == null) {
tempDir = mkdtempSync(path.join(process.cwd(), ".shared-backend-test-"));
writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ name: "shared-backend-test" }), "utf-8");
}
return tempDir;
}
function writeTempConfig(content: string): string {
const configPath = path.join(getTempDir(), "stack.config.ts");
writeFileSync(configPath, content, "utf-8");
return configPath;
}
beforeEach(() => {
mockScriptedWrites = [];
mockHookDecisions = [];
mockAfterWrites = null;
});
afterEach(() => {
if (tempDir != null) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = undefined;
}
});
// Config with an import triggers the agent path (tryParseHexclaveConfigFileContent returns null)
const CUSTOM_CONFIG = `import emailHtml from "./emails/welcome.html" with { type: "text" };
export const config = { auth: { allowSignUp: true }, emails: { welcomeHtml: emailHtml } };
`;
describe("local config updater fast path", () => {
it("uses the fast path for plain static configs (no agent invoked)", async () => {
const configPath = writeTempConfig("export const config = { auth: { allowSignUp: true } };\n");
mockScriptedWrites = [{ tool_name: "Write", file_path: path.join(getTempDir(), "x.ts") }];
const { updateConfigObject } = await import("./index");
await expect(updateConfigObject(configPath, { "auth.allowSignUp": false })).resolves.toBeUndefined();
// Agent was never called, so no hook decisions were recorded
expect(mockHookDecisions).toEqual([]);
// The config file was updated deterministically
expect(readFileSync(configPath, "utf-8")).toContain('"allowSignUp": false');
});
});
describe("local config updater agent write boundary", () => {
it("allows writes inside the config directory and captures them for rollback", async () => {
const configPath = writeTempConfig(CUSTOM_CONFIG);
const inside = path.join(getTempDir(), "emails", "welcome-email.tsx");
mockScriptedWrites = [{ tool_name: "Write", file_path: inside }];
mockAfterWrites = () => {
writeFileSync(configPath, "export const config = { auth: { allowSignUp: false } };\n", "utf-8");
};
const { updateConfigObject } = await import("./index");
await expect(updateConfigObject(configPath, { "auth.allowSignUp": false })).resolves.toBeUndefined();
expect(mockHookDecisions).toEqual([{ continue: true }]);
});
it("denies a `../` escape and fails the run", async () => {
const configPath = writeTempConfig(CUSTOM_CONFIG);
const outside = path.resolve(getTempDir(), "../../.env");
mockScriptedWrites = [{ tool_name: "Write", file_path: outside }];
const { updateConfigObject } = await import("./index");
await expect(updateConfigObject(configPath, { "auth.allowSignUp": false }))
.rejects.toThrow(/outside the config directory/);
expect(mockHookDecisions).toHaveLength(1);
expect(mockHookDecisions[0]).toMatchObject({
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny" },
});
expect(readFileSync(configPath, "utf-8")).toBe(CUSTOM_CONFIG);
});
it("denies an absolute path outside the config directory", async () => {
const configPath = writeTempConfig(CUSTOM_CONFIG);
mockScriptedWrites = [{ tool_name: "Edit", file_path: "/etc/passwd" }];
const { updateConfigObject } = await import("./index");
await expect(updateConfigObject(configPath, { "auth.allowSignUp": false }))
.rejects.toThrow("/etc/passwd");
});
});