stack/packages/stack-shared/src/hexclave-config-file.ts
BilalG1 501ae9fe61
PR 4: Rename Stack -> Hexclave: examples config module, app-internal symbols, crypto docs (#1534)
## What

Continues the **Stack Auth → Hexclave** rename for a set of safe,
internal-only surfaces. This intentionally avoids public-contract names.

### Changes
- **Examples** — renamed the user-facing config module
`stack.ts`/`stack.tsx` (and the `convex` / `lovable` `stack/`
directories) to `hexclave`, and updated every importer across
`.ts`/`.tsx`/`.jsx`. The public `app/handler/[...stack]/` route segment
is left unchanged.
- **apps/{dashboard,backend,internal-tool}** — renamed app-local
SDK-init symbols `stackClientApp → hexclaveClientApp` and
`getStackServerApp → getHexclaveServerApp`, and the dashboard
`StackCompanion` component → `HexclaveCompanion` (incl.
`useStackCompanion`, context types). The public
`StackClientApp`/`StackServerApp` SDK classes are **unchanged**.
- **packages/stack-shared** — added comments to the crypto / JWT / vault
`stack-*` literals documenting that they must **not** be renamed (key
derivation / JWKS / KMS-alias stability). The literals are
byte-identical.

### Deliberately excluded
- **`STACK_*` → `HEXCLAVE_*` env-var rename** — `HEXCLAVE_*` already
resolves via the dual-read layers (SDK env, dashboard `_inlineEnvVars`,
`getEnvVariable`). The remaining holdout is the docker post-build
sentinel path, which the codebase authors explicitly deferred and which
is tightly coupled to `entrypoint.sh` + untestable here. A blind rename
there risks silently breaking self-host/emulator bootstrap for ~zero
functional gain.
- **All public-contract names** — SDK class names, env vars, HTTP
headers (`x-stack-*`), and the `/handler` route convention.

## Verification
- `pnpm lint` — **29/29 passing**.
- `pnpm typecheck` — **28/29 passing**; the only failure is
`@hexclave/docs` (pre-existing missing fumadocs `.source` codegen,
untouched by this PR).
- Two rounds of adversarial multi-agent review; findings fixed:
string-literal collateral from the symbol sweep (CLI test fixtures + an
AI-prompt template) reverted, and a missed `.jsx` importer in
`examples/cjs-test` corrected.

## Notes
- Based on a `dev` snapshot from when the branch was cut (a couple
commits behind tip); the diff contains only the changes above.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Complete the internal “Stack” → “Hexclave” rename across examples,
app-local code, config tooling, and setup docs, and standardize env
output to HEXCLAVE_* with correct default API URL handling. Public SDK
classes, handler routes, and legacy env names keep working.

- **Refactors**
- Examples/config: `stack.*` files and `stack/` dirs →
`hexclave.*`/`hexclave/`; imports updated; keep `app/handler/[...stack]`
route.
- Apps: backend/dashboard/internal-tool now use `getHexclaveServerApp`
and `hexclaveClientApp`; dashboard `StackCompanion` →
`HexclaveCompanion`. Public `StackClientApp`/`StackServerApp` unchanged.
- Env/setup: Next.js and CLI generators write HEXCLAVE_* and omit API
URL when using https://api.stack-auth.com; CLI `doctor` and auth
resolution prefer HEXCLAVE_* (e.g. `HEXCLAVE_SECRET_SERVER_KEY`,
`HEXCLAVE_PROJECT_ID`) with `STACK_*` fallback.
- Config tooling: `stack-config-file` → `hexclave-config-file`, emitting
`HexclaveConfig`; imports updated across backend/dashboard/tooling.
- Shared/docs: added “do not rename” notes for crypto/JWT/vault
`stack-*` literals; regenerated setup prompt/docs to use
`hexclave.config.ts`, `hexclave dev`, and `src/hexclave/`.
- Tests: updated snapshots/assertions to expect `HexclaveConfig` and
HEXCLAVE_* env names.

- **Migration**
  - No action required. SDK and CLI read both HEXCLAVE_* and STACK_*.

<sup>Written for commit 8a891b4f6c.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1534?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

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

* **Refactor**
* Renamed internal app/client/server instances and companion/provider
components to the new product name across backend, dashboard, examples,
and tooling; imports updated accordingly.
* Updated generated environment variable names and CLI init/doctor
outputs to prefer the new product prefix.

* **Documentation**
* Added clarifying notes about vault/encryption and JWT/key labels to
avoid breaking existing encrypted data.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-03 12:09:20 -07:00

121 lines
4.6 KiB
TypeScript

import * as parser from "@babel/parser";
import * as t from "@babel/types";
import { isValidConfig, normalize } from "./config/format";
export const showOnboardingStackConfigValue = "show-onboarding";
const DEFAULT_CONFIG_IMPORT_PACKAGE = "@hexclave/js";
/**
* Renders a config object into the source text of a `stack.config.ts` file.
*
* Browser-safe: kept here (next to `parseStackConfigFileContent`) instead of in
* `config-rendering.ts` so dashboard client code can render config files
* without pulling in `fs` / `path`.
*/
export function renderConfigFileContent(config: unknown, importPackage?: string): string {
if (!isValidConfig(config)) {
throw new Error("Invalid config: expected a plain object.");
}
const droppedKeys: string[] = [];
const normalizedConfig = normalize(config, {
onDotIntoNonObject: "ignore",
onDotIntoNull: "empty-object",
droppedKeys,
});
if (droppedKeys.length > 0) {
throw new Error(`Config has conflicting keys that would be dropped during normalization: ${droppedKeys.map(k => JSON.stringify(k)).join(", ")}`);
}
const pkg = importPackage ?? DEFAULT_CONFIG_IMPORT_PACKAGE;
const importLine = `import type { HexclaveConfig } from "${pkg}";`;
return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
}
type ParsedStackConfig = Record<string, unknown> | typeof showOnboardingStackConfigValue;
function unwrapStaticConfigExpression(expression: t.Expression): t.Expression {
if (
t.isTSAsExpression(expression)
|| t.isTSSatisfiesExpression(expression)
|| t.isTSTypeAssertion(expression)
|| t.isTSNonNullExpression(expression)
) {
return unwrapStaticConfigExpression(expression.expression);
}
return expression;
}
function evaluateStaticConfigExpression(expression: t.Expression): unknown {
const unwrapped = unwrapStaticConfigExpression(expression);
if (t.isStringLiteral(unwrapped)) return unwrapped.value;
if (t.isBooleanLiteral(unwrapped)) return unwrapped.value;
if (t.isNumericLiteral(unwrapped)) return unwrapped.value;
if (t.isNullLiteral(unwrapped)) return null;
if (t.isIdentifier(unwrapped) && unwrapped.name === "undefined") return undefined;
if (t.isUnaryExpression(unwrapped) && unwrapped.operator === "-" && t.isNumericLiteral(unwrapped.argument)) {
return -unwrapped.argument.value;
}
if (t.isArrayExpression(unwrapped)) {
return unwrapped.elements.map((element) => {
if (element == null || t.isSpreadElement(element)) {
throw new Error("Config arrays cannot contain holes or spreads.");
}
return evaluateStaticConfigExpression(element);
});
}
if (t.isObjectExpression(unwrapped)) {
const result: Record<string, unknown> = {};
for (const property of unwrapped.properties) {
if (t.isSpreadElement(property)) {
throw new Error("Config objects cannot contain spreads.");
}
if (property.computed) {
throw new Error("Config object keys cannot be computed.");
}
const key = t.isIdentifier(property.key)
? property.key.name
: t.isStringLiteral(property.key) || t.isNumericLiteral(property.key)
? String(property.key.value)
: null;
if (key == null) {
throw new Error("Unsupported config object key.");
}
if (t.isObjectMethod(property)) {
throw new Error("Config objects cannot contain methods.");
}
if (!t.isExpression(property.value)) {
throw new Error("Unsupported config object value.");
}
result[key] = evaluateStaticConfigExpression(property.value);
}
return result;
}
throw new Error(`Unsupported config expression: ${unwrapped.type}`);
}
export function parseStackConfigFileContent(content: string, filePath: string): ParsedStackConfig {
if (content.trim() === "") return {};
const ast = parser.parse(content, {
sourceType: "module",
plugins: ["typescript"],
});
for (const statement of ast.program.body) {
if (!t.isExportNamedDeclaration(statement) || !t.isVariableDeclaration(statement.declaration)) {
continue;
}
for (const declaration of statement.declaration.declarations) {
if (!t.isIdentifier(declaration.id) || declaration.id.name !== "config") {
continue;
}
if (declaration.init == null || !t.isExpression(declaration.init)) {
throw new Error(`Config export in ${filePath} must have an initializer.`);
}
return evaluateStaticConfigExpression(declaration.init) as ParsedStackConfig;
}
}
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
}