stack/apps/dashboard/src/lib/github-config-push.test.ts
BilalG1 7f99f15b42
fix(rde): graceful config load errors + lightweight /config import path (#1557)
## Problem

A user hit `Failed to register development environment session (500)`
when running the RDE (`hexclave dev` / `stack dev`). Removing
`defineStackConfig` from their `stack.config.ts` made it go away.

**Root cause:** the local dashboard evaluates the project's config file
in a plain Node context via `jiti`
([config-file.ts](apps/dashboard/src/lib/remote-development-environment/config-file.ts)).
When the config imports a *value* (e.g. `defineStackConfig`) from a
framework package like `@stackframe/stack` / `@hexclave/next`, jiti
executes the entire SDK — React, `server-only`, Next internals — which
throws in that context. The exception propagated as a bare 500. Dropping
`defineStackConfig` removed the value import, so jiti no longer loaded
the framework.

## Changes

**1. Graceful error (Fix 3)**
`readConfigFile` now wraps the `jiti.import` in try/catch and rethrows a
message pointing at the lightweight import path, instead of a raw 500.

**2. Lightweight `/config` subpath (Fix 1)**
Added a side-effect-free `./config` entrypoint to the framework packages
— `@hexclave/{js,next,react,tanstack-start}/config` — that re-exports
`defineHexclaveConfig` / `defineStackConfig` + the `HexclaveConfig` type
from `@hexclave/shared/config`, with **no framework runtime**. Source of
truth:
[`packages/template/src/config.ts`](packages/template/src/config.ts) +
the export in
[`package-template.json`](packages/template/package-template.json),
propagated to the generated packages via `generate-sdks`.

> Why per-package and not `@hexclave/shared/config`: `@hexclave/shared`
is only a *transitive* dependency from a user's perspective, so
importing from it fails under pnpm strict mode. Users depend on the
framework package directly, so `@hexclave/next/config` always resolves.
This was confirmed empirically — the previous tests that imported
`@hexclave/shared/config` were red.

**3. Docs / prompts / renderer aligned to the new path**
-
[`ai-setup-prompt.ts`](packages/shared/src/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.ts)
+ regenerated `docs-mintlify` (setup.mdx, llms-full.txt, snippets).
- Hand-written
[`hexclave-config.mdx`](docs-mintlify/guides/going-further/hexclave-config.mdx)
and
[`local-vs-cloud-dashboard.mdx`](docs-mintlify/guides/going-further/local-vs-cloud-dashboard.mdx).
(`docs/**` left untouched — legacy.)
- `renderConfigFileContent` (the config file the dashboard/CLI
auto-writes) now emits `import type { HexclaveConfig } from
"<pkg>/config"`. Legacy `@stackframe/*` packages predate the subpath, so
they keep their root import (guarded).

## Behavioral note

Existing config files that import from a package root get their import
line upgraded to `/config` on their next dashboard/CLI sync — a
one-time, harmless rewrite that migrates them onto the safe path. The
github-config-push idempotence test was updated to use the current
`/config` format so it still genuinely verifies "no spurious commit."

## Testing

- 43 unit tests pass across `config-file`, `github-config-push`,
`config-rendering`, `config-authoring`, `local-emulator`. The two
previously-red RDE `define*` tests now pass through jiti via
`@hexclave/next/config` (the real code path), and were made
resolution-stable by rooting their temp dir at the test file instead of
`process.cwd()`.
- Typecheck green on all source-changed packages (shared, cli, js, next,
react, tanstack-start). Lint clean.
- ⚠️ The two e2e suites (`cli.test.ts`, `config-local-emulator.test.ts`)
need backend+DB infra; their snapshot updates are mechanical and
**confirmable only in CI**.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Prevents 500s when loading `hexclave.config.ts` by adding a lightweight
`<pkg>/config` entrypoint and showing a clear, actionable error without
leaking framework stacks. Import detection, rendering, CLI, tests, and
docs now default to `/config` (including `@hexclave/tanstack-start`) so
configs load in plain Node contexts.

- **New Features**
- Added `/config` subpaths in `@hexclave/js`, `@hexclave/next`,
`@hexclave/react`, `@hexclave/tanstack-start` (and template)
re-exporting `defineHexclaveConfig`, `defineStackConfig`, and
`HexclaveConfig` with no framework runtime.
- Renderer, CLI, and docs import `HexclaveConfig` from `<pkg>/config`;
legacy `@stackframe/*` keep root imports. Existing config files
auto-upgrade on next dashboard/CLI sync.

- **Bug Fixes**
- Wrapped `jiti` config load with try/catch; capture raw error for
diagnostics and show a concise message pointing to `<pkg>/config` (no
nested framework stack traces).
- Import detection accepts optional `/config` suffix; renderer always
appends `/config` for Hexclave packages and recognizes
`@hexclave/tanstack-start`.
- Tests stabilized by scoping temp dirs to the test file; CLI error
example now references `HexclaveConfig` from `<pkg>/config` for Hexclave
packages.

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

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1557?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

* **New Features**
* Added lightweight "/config" subpath exports across SDK packages to
enable side-effect-free config authoring in plain Node contexts.

* **Documentation**
* Updated guides and snippets to recommend importing config types and
helpers from the "/config" entrypoint and added example usage.

* **Bug Fixes**
* Improved error messaging when dynamic config imports fail, with
guidance to use the "/config" entrypoint.

* **Tests**
* Adjusted tests and snapshots to expect normalized "/config" import
paths.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-10 11:40:19 -07:00

346 lines
10 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { isObject } from "./github-api";
import { buildUpdatedConfigFileContent, pushConfigUpdateToGitHub } from "./github-config-push";
function getStringField(value: Record<string, unknown>, key: string): string {
const field = value[key];
if (typeof field !== "string") {
throw new Error(`Expected request body field ${key} to be a string.`);
}
return field;
}
function snapshotGithubCall(call: { path: string, init?: RequestInit }) {
if (call.init == null) {
return { path: call.path };
}
const body = call.init.body;
if (body == null) {
return {
path: call.path,
init: call.init,
};
}
if (typeof body !== "string") {
throw new Error("Expected request body to be a JSON string.");
}
const parsedBody: unknown = JSON.parse(body);
if (!isObject(parsedBody)) {
throw new Error("Expected request body to parse as an object.");
}
const content = getStringField(parsedBody, "content");
return {
path: call.path,
method: call.init.method,
headers: call.init.headers,
body: {
...parsedBody,
content: Buffer.from(content, "base64").toString("utf-8"),
},
};
}
describe("buildUpdatedConfigFileContent", () => {
it("merges a flat dot-notation update into the existing config", () => {
const current = `import type { HexclaveConfig } from "@hexclave/next";
export const config: HexclaveConfig = {
teams: { allowClientTeamCreation: false },
};
`;
const result = buildUpdatedConfigFileContent(current, { "teams.allowClientTeamCreation": true });
expect(result).toMatchInlineSnapshot(`
"import type { HexclaveConfig } from "@hexclave/next/config";
export const config: HexclaveConfig = {
"teams": {
"allowClientTeamCreation": true
}
};
"
`);
});
it("preserves the existing @hexclave/* import package when re-rendering", () => {
const current = `import type { HexclaveConfig } from "@hexclave/react";
export const config: HexclaveConfig = {};
`;
const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true });
expect(result).toMatchInlineSnapshot(`
"import type { HexclaveConfig } from "@hexclave/react/config";
export const config: HexclaveConfig = {
"auth": {
"allowSignUp": true
}
};
"
`);
});
it("preserves a legacy @stackframe/* import package when re-rendering", () => {
// Projects pinned to the last @stackframe/* release (before the Hexclave
// rebrand) still have config files importing from the legacy scope. The
// dashboard must not silently rewrite their imports — keep what's there.
const current = `import type { StackConfig } from "@stackframe/react";
export const config: StackConfig = {};
`;
const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true });
expect(result).toMatchInlineSnapshot(`
"import type { HexclaveConfig } from "@stackframe/react";
export const config: HexclaveConfig = {
"auth": {
"allowSignUp": true
}
};
"
`);
});
it("defaults to @hexclave/js when no recognizable import is present", () => {
const current = `export const config = {};\n`;
const result = buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true });
expect(result).toMatchInlineSnapshot(`
"import type { HexclaveConfig } from "@hexclave/js/config";
export const config: HexclaveConfig = {
"auth": {
"allowSignUp": true
}
};
"
`);
});
it("adds new top-level keys to an empty config", () => {
const current = `import type { HexclaveConfig } from "@hexclave/js";
export const config: HexclaveConfig = {};
`;
const result = buildUpdatedConfigFileContent(current, {
"payments.items.todos.displayName": "Todos",
"payments.items.todos.customerType": "user",
});
expect(result).toMatchInlineSnapshot(`
"import type { HexclaveConfig } from "@hexclave/js/config";
export const config: HexclaveConfig = {
"payments": {
"items": {
"todos": {
"displayName": "Todos",
"customerType": "user"
}
}
}
};
"
`);
});
it("replaces an existing nested value via dot notation", () => {
const current = `import type { HexclaveConfig } from "@hexclave/js";
export const config: HexclaveConfig = {
payments: { items: { todos: { displayName: "Old" } } },
};
`;
const result = buildUpdatedConfigFileContent(current, {
"payments.items.todos.displayName": "New",
});
expect(result).toMatchInlineSnapshot(`
"import type { HexclaveConfig } from "@hexclave/js/config";
export const config: HexclaveConfig = {
"payments": {
"items": {
"todos": {
"displayName": "New"
}
}
}
};
"
`);
});
it("refuses to mutate a show-onboarding placeholder file", () => {
const current = `export const config = "show-onboarding";`;
expect(() => buildUpdatedConfigFileContent(current, { "auth.allowSignUp": true }))
.toThrowErrorMatchingInlineSnapshot(`[Error: The config file currently exports the onboarding placeholder. Finish setting up Hexclave in your repo before pushing dashboard changes.]`);
});
it("throws when the file does not export a `config` binding", () => {
expect(() => buildUpdatedConfigFileContent(`export const other = {};`, { "a": 1 }))
.toThrowErrorMatchingInlineSnapshot(`[Error: Invalid config in stack.config.ts. The file must export a plain \`config\` object or "show-onboarding".]`);
});
});
describe("pushConfigUpdateToGitHub", () => {
function buildFakeFetch(initialContent: string) {
const base64 = Buffer.from(initialContent, "utf-8").toString("base64");
const calls: { path: string, init?: RequestInit }[] = [];
const fn = async (path: string, init?: RequestInit) => {
calls.push({ path, init });
if (init?.method === "PUT") {
return { commit: { sha: "newsha" } };
}
return {
type: "file",
encoding: "base64",
content: base64,
sha: "oldsha",
};
};
return { fn, calls };
}
const baseSource = {
type: "pushed-from-github" as const,
owner: "myorg",
repo: "my-repo",
branch: "main",
commitHash: "abc",
configFilePath: "stack.config.ts",
};
it("fetches the existing file, merges the update, and PUTs the new content", async () => {
const { fn, calls } = buildFakeFetch(`import type { HexclaveConfig } from "@hexclave/js";
export const config: HexclaveConfig = { teams: { allowClientTeamCreation: false } };
`);
await pushConfigUpdateToGitHub({
source: baseSource,
configUpdate: { "teams.allowClientTeamCreation": true },
commitMessage: "feat: enable team creation",
githubFetch: fn,
});
expect(calls.map(snapshotGithubCall)).toMatchInlineSnapshot(`
[
{
"init": {
"cache": "no-store",
},
"path": "/repos/myorg/my-repo/contents/stack.config.ts?ref=main",
},
{
"body": {
"branch": "main",
"content": "import type { HexclaveConfig } from "@hexclave/js/config";
export const config: HexclaveConfig = {
"teams": {
"allowClientTeamCreation": true
}
};
",
"message": "feat: enable team creation",
"sha": "oldsha",
},
"headers": {
"content-type": "application/json",
},
"method": "PUT",
"path": "/repos/myorg/my-repo/contents/stack.config.ts",
},
]
`);
});
it("falls back to a default commit message when none is provided", async () => {
const { fn, calls } = buildFakeFetch(`export const config = {};\n`);
await pushConfigUpdateToGitHub({
source: baseSource,
configUpdate: { "auth.allowSignUp": true },
commitMessage: " ",
githubFetch: fn,
});
expect(calls.map(snapshotGithubCall)).toMatchInlineSnapshot(`
[
{
"init": {
"cache": "no-store",
},
"path": "/repos/myorg/my-repo/contents/stack.config.ts?ref=main",
},
{
"body": {
"branch": "main",
"content": "import type { HexclaveConfig } from "@hexclave/js/config";
export const config: HexclaveConfig = {
"auth": {
"allowSignUp": true
}
};
",
"message": "chore(stack-auth): update config from dashboard",
"sha": "oldsha",
},
"headers": {
"content-type": "application/json",
},
"method": "PUT",
"path": "/repos/myorg/my-repo/contents/stack.config.ts",
},
]
`);
});
it("skips the commit when the new rendered file is identical to the old one", async () => {
const same = `import type { HexclaveConfig } from "@hexclave/js/config";
export const config: HexclaveConfig = {
"teams": {
"allowClientTeamCreation": true
}
};
`;
const { fn, calls } = buildFakeFetch(same);
await pushConfigUpdateToGitHub({
source: baseSource,
configUpdate: { "teams.allowClientTeamCreation": true },
commitMessage: "no-op",
githubFetch: fn,
});
expect(calls.map(snapshotGithubCall)).toMatchInlineSnapshot(`
[
{
"init": {
"cache": "no-store",
},
"path": "/repos/myorg/my-repo/contents/stack.config.ts?ref=main",
},
]
`);
});
it("surfaces a clear error when the config file is missing on the branch", async () => {
const fn = async () => {
throw new Error("Not Found");
};
await expect(
pushConfigUpdateToGitHub({
source: baseSource,
configUpdate: { "auth.allowSignUp": true },
commitMessage: "x",
githubFetch: fn,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Could not find stack.config.ts on myorg/my-repo@main. Check that the config file still exists in the linked branch.]`);
});
it("propagates non-404 GitHub errors", async () => {
const fn = async () => {
throw new Error("Bad credentials");
};
await expect(
pushConfigUpdateToGitHub({
source: baseSource,
configUpdate: { "auth.allowSignUp": true },
commitMessage: "x",
githubFetch: fn,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Bad credentials]`);
});
});