mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
## 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>
174 lines
7.5 KiB
TypeScript
174 lines
7.5 KiB
TypeScript
import { existsSync, readFileSync } from "fs";
|
|
import path from "path";
|
|
import { hexclaveConfigFileExportsConfig, parseHexclaveConfigFileContent, renderConfigFileContent, tryParseHexclaveConfigFileContent } from "./hexclave-config-file";
|
|
export { hexclaveConfigFileExportsConfig, parseHexclaveConfigFileContent, renderConfigFileContent, tryParseHexclaveConfigFileContent };
|
|
|
|
/**
|
|
* Packages that export the `HexclaveConfig` type, in priority order.
|
|
* The first match found in a project's dependencies wins. Hexclave-branded
|
|
* packages come first (canonical); the legacy `@stackframe/*` names remain
|
|
* so projects pinned to the last legacy release still render a config file
|
|
* that compiles against their installed SDK.
|
|
*/
|
|
const CONFIG_IMPORT_PACKAGES = [
|
|
"@hexclave/next",
|
|
"@hexclave/react",
|
|
"@hexclave/tanstack-start",
|
|
"@hexclave/js",
|
|
"@hexclave/template",
|
|
"@stackframe/stack",
|
|
"@stackframe/react",
|
|
"@stackframe/js",
|
|
"@stackframe/template",
|
|
] as const;
|
|
|
|
/**
|
|
* Given a list of dependency names (from package.json), returns the SDK
|
|
* package that should be used for the `HexclaveConfig` import, or `undefined`
|
|
* if none of the known packages are installed.
|
|
*/
|
|
export function detectConfigImportPackage(dependencies: string[]): string | undefined {
|
|
for (const pkg of CONFIG_IMPORT_PACKAGES) {
|
|
if (dependencies.includes(pkg)) {
|
|
return pkg;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Walks up from `dir` to find the nearest `package.json` and returns the
|
|
* best SDK package to use for the `HexclaveConfig` type import.
|
|
*/
|
|
export function detectImportPackageFromDir(dir: string): string | undefined {
|
|
let current = dir;
|
|
while (true) {
|
|
const pkgPath = path.join(current, "package.json");
|
|
if (existsSync(pkgPath)) {
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
const deps = [
|
|
...Object.keys(pkg.dependencies ?? {}),
|
|
...Object.keys(pkg.devDependencies ?? {}),
|
|
];
|
|
return detectConfigImportPackage(deps);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
const parent = path.dirname(current);
|
|
if (parent === current) break;
|
|
current = parent;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({ expect }) => {
|
|
expect(renderConfigFileContent({
|
|
"payments.items.todos.displayName": "Todo Slots",
|
|
"payments.items.todos.customerType": "user",
|
|
})).toContain(`export const config: HexclaveConfig = {
|
|
"payments": {
|
|
"items": {
|
|
"todos": {
|
|
"displayName": "Todo Slots",
|
|
"customerType": "user"
|
|
}
|
|
}
|
|
}
|
|
};`);
|
|
});
|
|
|
|
import.meta.vitest?.test("parseHexclaveConfigFileContent parses static config exports", ({ expect }) => {
|
|
expect(parseHexclaveConfigFileContent(`
|
|
import type { StackConfig } from "@hexclave/js";
|
|
export const config: StackConfig = {
|
|
auth: { allowSignUp: true },
|
|
payments: { testMode: false },
|
|
};
|
|
`, "stack.config.ts")).toMatchInlineSnapshot(`
|
|
{
|
|
"auth": {
|
|
"allowSignUp": true,
|
|
},
|
|
"payments": {
|
|
"testMode": false,
|
|
},
|
|
}
|
|
`);
|
|
});
|
|
|
|
import.meta.vitest?.test("parseHexclaveConfigFileContent parses show-onboarding", ({ expect }) => {
|
|
expect(parseHexclaveConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding");
|
|
});
|
|
|
|
import.meta.vitest?.test("parseHexclaveConfigFileContent rejects dynamic config exports", ({ expect }) => {
|
|
expect(() => parseHexclaveConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toThrow(/Unsupported config expression/);
|
|
});
|
|
|
|
import.meta.vitest?.test("tryParseHexclaveConfigFileContent returns the config for static exports", ({ expect }) => {
|
|
expect(tryParseHexclaveConfigFileContent("export const config = { auth: { allowSignUp: true } };", "stack.config.ts")).toEqual({
|
|
auth: { allowSignUp: true },
|
|
});
|
|
});
|
|
|
|
import.meta.vitest?.test("tryParseHexclaveConfigFileContent returns null for non-static exports", ({ expect }) => {
|
|
// Wrapped in a helper call (e.g. defineStackConfig) -> not a plain literal.
|
|
expect(tryParseHexclaveConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toBeNull();
|
|
// References an imported value -> has structure to preserve.
|
|
expect(tryParseHexclaveConfigFileContent('import x from "./x.txt" with { type: "text" };\nexport const config = { a: x };', "stack.config.ts")).toBeNull();
|
|
// Syntax error.
|
|
expect(tryParseHexclaveConfigFileContent("export const config = {", "stack.config.ts")).toBeNull();
|
|
});
|
|
|
|
import.meta.vitest?.test("hexclaveConfigFileExportsConfig detects a config export", ({ expect }) => {
|
|
expect(hexclaveConfigFileExportsConfig("export const config = { a: 1 };", "stack.config.ts")).toBe(true);
|
|
expect(hexclaveConfigFileExportsConfig('import x from "./x.txt" with { type: "text" };\nexport const config = { a: x };', "stack.config.ts")).toBe(true);
|
|
expect(hexclaveConfigFileExportsConfig("export const notConfig = { a: 1 };", "stack.config.ts")).toBe(false);
|
|
expect(hexclaveConfigFileExportsConfig("export const config = {", "stack.config.ts")).toBe(false);
|
|
});
|
|
|
|
import.meta.vitest?.test("renderConfigFileContent rejects conflicting dotted keys", ({ expect }) => {
|
|
expect(() => renderConfigFileContent({
|
|
"a.b": 1,
|
|
"a.b.c": 2,
|
|
})).toThrowError(/conflicting keys.*"a\.b\.c"/);
|
|
});
|
|
|
|
import.meta.vitest?.test("renderConfigFileContent rejects invalid config exports", ({ expect }) => {
|
|
expect(() => renderConfigFileContent(null)).toThrowErrorMatchingInlineSnapshot(
|
|
`[Error: Invalid config: expected a plain object.]`,
|
|
);
|
|
});
|
|
|
|
import.meta.vitest?.test("renderConfigFileContent uses custom import package", ({ expect }) => {
|
|
const content = renderConfigFileContent({}, "@hexclave/next");
|
|
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/next/config";');
|
|
});
|
|
|
|
import.meta.vitest?.test("renderConfigFileContent defaults to @hexclave/js", ({ expect }) => {
|
|
const content = renderConfigFileContent({});
|
|
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js/config";');
|
|
});
|
|
|
|
import.meta.vitest?.test("renderConfigFileContent keeps legacy @stackframe packages on their root entrypoint", ({ expect }) => {
|
|
// The lightweight `/config` subpath only exists on Hexclave-branded packages;
|
|
// already-published @stackframe/* releases predate it.
|
|
const content = renderConfigFileContent({}, "@stackframe/next");
|
|
expect(content).toContain('import type { HexclaveConfig } from "@stackframe/next";');
|
|
});
|
|
|
|
import.meta.vitest?.test("detectConfigImportPackage picks first matching package by priority", ({ expect }) => {
|
|
expect(detectConfigImportPackage(["@hexclave/next", "@hexclave/js"])).toBe("@hexclave/next");
|
|
expect(detectConfigImportPackage(["@hexclave/react", "@hexclave/js"])).toBe("@hexclave/react");
|
|
expect(detectConfigImportPackage(["@hexclave/js"])).toBe("@hexclave/js");
|
|
expect(detectConfigImportPackage(["@hexclave/tanstack-start"])).toBe("@hexclave/tanstack-start");
|
|
// Hexclave names take priority over legacy stackframe names when both appear.
|
|
expect(detectConfigImportPackage(["@stackframe/stack", "@hexclave/next"])).toBe("@hexclave/next");
|
|
// Legacy fallback still works for projects pinned to the last @stackframe/* release.
|
|
expect(detectConfigImportPackage(["@stackframe/stack"])).toBe("@stackframe/stack");
|
|
expect(detectConfigImportPackage(["@stackframe/template"])).toBe("@stackframe/template");
|
|
expect(detectConfigImportPackage(["lodash", "express"])).toBeUndefined();
|
|
expect(detectConfigImportPackage([])).toBeUndefined();
|
|
});
|