Install deps in config-sync workflow + use root SDK imports; drop CLI /config alias
Some checks are pending
DB migration compat / Check if migrations changed (push) Waiting to run
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Blocked by required conditions
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Blocked by required conditions
DB migration compat / No migration changes (skipped) (push) Blocked by required conditions

Co-Authored-By: Konstantin Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
Devin AI 2026-07-18 01:30:18 +00:00
parent 4df9df5870
commit 36d982d1e5
12 changed files with 77 additions and 149 deletions

View File

@ -36,6 +36,19 @@ describe("buildWorkflowYaml", () => {
expect(workflowYaml).toContain("HEXCLAVE_SOURCE_REPO: ${{ github.repository }}");
expect(workflowYaml).not.toMatch(/HEXCLAVE_SOURCE_REPO:\s+"[^$]/);
});
it("installs the repo's dependencies (with lockfile detection) before pushing config", () => {
const workflowYaml = buildWorkflowYaml("main", "hexclave.config.ts");
expect(workflowYaml).toContain("- name: Install dependencies");
for (const marker of ["pnpm-lock.yaml", "yarn.lock", "package-lock.json", "npm ci"]) {
expect(workflowYaml).toContain(marker);
}
// The install must run before the push step, otherwise the SDK import would
// still be unresolvable when the CLI evaluates the config.
expect(workflowYaml.indexOf("- name: Install dependencies")).toBeLessThan(
workflowYaml.indexOf("- name: Push Hexclave config"),
);
});
});
describe("normalizeConfigPath", () => {

View File

@ -52,6 +52,27 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: "20"
# Install the repo's dependencies so the config file's SDK import (e.g.
# \`@hexclave/js\`) — and any other package it imports — resolves when the
# CLI evaluates it. The package manager is detected from the lockfile.
- name: Install dependencies
run: |
if [ -f pnpm-lock.yaml ]; then
corepack enable
pnpm install --frozen-lockfile
elif [ -f yarn.lock ]; then
corepack enable
yarn install --immutable
elif [ -f bun.lockb ] || [ -f bun.lock ]; then
npm install -g bun
bun install --frozen-lockfile
elif [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then
npm ci
elif [ -f package.json ]; then
npm install
else
echo "No package.json found at the repository root; skipping dependency install."
fi
- name: Push Hexclave config
env:
HEXCLAVE_PROJECT_ID: \${{ secrets.${GITHUB_PROJECT_ID_SECRET_NAME} }}

View File

@ -399,7 +399,7 @@ describe("Stack CLI", () => {
expect(exitCode).toBe(0);
expect(stdout).toContain("Config written to");
const content = fs.readFileSync(configTsPath, "utf-8");
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js/config";');
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js";');
expect(content).toContain("export const config: HexclaveConfig");
});
@ -514,7 +514,7 @@ describe("Stack CLI", () => {
expect(stdout).toContain("Config file written to");
const content = fs.readFileSync(path.join(initDir, "stack.config.ts"), "utf-8");
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js/config";');
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js";');
expect(content).toContain("export const config: HexclaveConfig");
expect(JSON.parse(extractConfigObjectString(content))).toMatchObject({
apps: {

File diff suppressed because one or more lines are too long

View File

@ -9,7 +9,7 @@ sidebarTitle: "hexclave.config.ts"
The file exports a static `config` object:
```ts title="hexclave.config.ts"
import type { HexclaveConfig } from "@hexclave/js/config";
import type { HexclaveConfig } from "@hexclave/js";
export const config: HexclaveConfig = {
auth: {
@ -28,14 +28,10 @@ export const config: HexclaveConfig = {
};
```
<Note>
Always import config helpers from the package's lightweight `/config` entrypoint (e.g. `@hexclave/js/config`, `@hexclave/next/config`) rather than the package root. The `/config` entrypoint contains no framework runtime code, so tooling such as the local dashboard can load your config file in a plain Node context. Importing `defineHexclaveConfig` (or the `HexclaveConfig` type) from the package root instead would pull in the entire SDK and fail to load.
</Note>
To get type-checking and editor autocomplete for your config object, wrap it with `defineHexclaveConfig`:
```ts title="hexclave.config.ts"
import { defineHexclaveConfig } from "@hexclave/js/config";
import { defineHexclaveConfig } from "@hexclave/js";
export const config = defineHexclaveConfig({
auth: {

View File

@ -25,7 +25,7 @@ Use a development environment when you want to:
The usual setup looks like this:
```ts title="hexclave.config.ts"
import type { HexclaveConfig } from "@hexclave/js/config";
import type { HexclaveConfig } from "@hexclave/js";
export const config: HexclaveConfig = "show-onboarding";
```

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,7 @@ import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { assertConfigPullTarget, buildConfigImportAlias, buildConfigPushSource, resolveConfigFilePathForPull } from "./config-file.js";
import { assertConfigPullTarget, buildConfigPushSource, resolveConfigFilePathForPull } from "./config-file.js";
describe("resolveConfigFilePathForPull", () => {
let tmpDir: string;
@ -80,62 +80,6 @@ describe("assertConfigPullTarget", () => {
});
});
describe("buildConfigImportAlias", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-config-alias-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("aliases unresolvable SDK config entrypoints to the CLI's own @hexclave/js/config", () => {
const alias = buildConfigImportAlias(tmpDir);
// The legacy `@stackframe/*` packages are not part of this monorepo, so
// they're never resolvable from the config file's directory and must be
// aliased. (`@hexclave/*` packages may or may not resolve depending on the
// environment's module resolution, so we don't assert on those here.)
expect(alias["@stackframe/stack"]).toBeDefined();
// Every aliased specifier points to the same single real file — the CLI's
// bundled `@hexclave/js/config`.
const targets = new Set(Object.values(alias));
expect(targets.size).toBe(1);
const target = [...targets][0];
expect(fs.existsSync(target)).toBe(true);
// Only known SDK config specifiers are aliased.
for (const specifier of Object.keys(alias)) {
expect(specifier === "@stackframe/stack" || specifier === "@stackframe/react" || specifier === "@stackframe/js" || specifier === "@stackframe/template" || specifier.endsWith("/config")).toBe(true);
}
});
it("lets jiti evaluate a config that imports defineHexclaveConfig without the SDK installed", async () => {
// Reproduces the CI sync workflow: the repo is checked out but its
// dependencies aren't installed, so `@hexclave/js/config` is unresolvable
// from the config file's directory.
const configPath = path.join(tmpDir, "hexclave.config.ts");
fs.writeFileSync(
configPath,
[
`import { defineHexclaveConfig } from "@hexclave/js/config";`,
``,
`export const config = defineHexclaveConfig({`,
` auth: { allowSignUp: true },`,
`});`,
``,
].join("\n"),
);
const { createJiti } = await import("jiti");
const jiti = createJiti(import.meta.url, {
alias: buildConfigImportAlias(path.dirname(configPath)),
});
const mod: { config?: unknown } = await jiti.import(configPath);
expect(mod.config).toEqual({ auth: { allowSignUp: true } });
});
});
describe("buildConfigPushSource", () => {
const ORIGINAL_ENV = { ...process.env };

View File

@ -1,12 +1,10 @@
import { replaceConfigObject } from "@hexclave/shared-backend";
import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-eval";
import { CONFIG_IMPORT_PACKAGES, configImportSpecifierForPackage } from "@hexclave/shared/dist/config-rendering";
import { isValidConfig } from "@hexclave/shared/dist/config/format";
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
import { throwErr } from "@hexclave/shared/dist/utils/errors";
import { Command } from "commander";
import * as fs from "fs";
import { createRequire } from "module";
import * as path from "path";
import { getAdminProject } from "../lib/app.js";
import { isProjectAuthWithRefreshToken, isProjectAuthWithSecretServerKey, resolveAuth, resolveProjectId, type ProjectAuthWithSecretServerKey } from "../lib/auth.js";
@ -235,35 +233,6 @@ export function assertConfigPullTarget(filePath: string, opts: { overwrite?: boo
}
}
// Typed config files import `defineHexclaveConfig` (a runtime helper) and the
// `HexclaveConfig` type from an SDK's lightweight `/config` entrypoint, e.g.
// `@hexclave/js/config`. `config push` runs from the generated GitHub sync
// workflow, which checks out the repo and runs the CLI via `npx` WITHOUT
// installing the repo's dependencies — so jiti cannot resolve that import and
// evaluating the config fails with MODULE_NOT_FOUND. The CLI bundles
// `@hexclave/js`, and every SDK `/config` entrypoint just re-exports the same
// identity helper + types from `@hexclave/shared/config`, so we resolve those
// imports to the CLI's own copy as a fallback. We only alias specifiers that
// don't already resolve from the config file's directory, so a project that
// does have its SDK installed keeps using its own copy.
export function buildConfigImportAlias(configFileDir: string): Record<string, string> {
const fallbackTarget = createRequire(import.meta.url).resolve("@hexclave/js/config");
// `createRequire` needs a file path to anchor resolution; the file need not
// exist, it only seeds the module search paths (node_modules lookup walks up
// from this directory).
const fromConfigDir = createRequire(path.join(configFileDir, "__hexclave_config_resolver__.cjs"));
const alias: Record<string, string> = {};
for (const pkg of CONFIG_IMPORT_PACKAGES) {
const specifier = configImportSpecifierForPackage(pkg);
try {
fromConfigDir.resolve(specifier);
} catch {
alias[specifier] = fallbackTarget;
}
}
return alias;
}
export function registerConfigCommand(program: Command) {
const config = program
.command("config")
@ -318,18 +287,16 @@ export function registerConfigCommand(program: Command) {
throw new CliError("Config file must have a .js or .ts extension.");
}
// The generated GitHub sync workflow installs the repo's dependencies
// before running the CLI, so jiti resolves the config's SDK import (e.g.
// `@hexclave/js`) from the project's own node_modules.
const { createJiti } = await import("jiti");
const jiti = createJiti(import.meta.url, {
alias: buildConfigImportAlias(path.dirname(filePath)),
});
const jiti = createJiti(import.meta.url);
const configModule: { config?: unknown } = await jiti.import(filePath);
const config = parseConfigOverride(configModule.config);
if (config == null) {
const examplePkg = detectImportPackageFromDir(path.dirname(filePath)) ?? "@hexclave/js";
// The lightweight `/config` entrypoint only exists on Hexclave-branded packages;
// legacy `@stackframe/*` releases predate it, so import from their root.
const exampleImport = examplePkg.startsWith("@hexclave/") ? `${examplePkg}/config` : examplePkg;
const exampleImport = detectImportPackageFromDir(path.dirname(filePath)) ?? "@hexclave/js";
throw new CliError(`Config file must export a plain \`config\` object or "show-onboarding". Example: import type { HexclaveConfig } from "${exampleImport}"; export const config: HexclaveConfig = { ... };`);
}

View File

@ -411,12 +411,12 @@ function getRestBackendSetupPrompt(kind: "python" | "rest-api") {
If this project already has a \`hexclave.config.ts\` file for another frontend or backend, reuse that same file so the whole project shares one Hexclave config. Otherwise, create a new \`hexclave.config.ts\` file in your workspace:
\`\`\`ts hexclave.config.ts
import type { HexclaveConfig } from "@hexclave/js/config";
import type { HexclaveConfig } from "@hexclave/js";
export const config: HexclaveConfig = "show-onboarding";
\`\`\`
The \`/config\` entrypoint is lightweight and free of framework runtime code, so it can be safely loaded by tooling such as the local dashboard. If you later switch to a config object and want type-checking, wrap it with \`defineHexclaveConfig\` imported from the same \`@hexclave/js/config\` path (never from \`@hexclave/js\` directly, which would pull in the whole SDK and fail to load).
If you later switch to a config object and want type-checking, wrap it with \`defineHexclaveConfig\`, imported from the same \`@hexclave/js\` package.
Run your backend through the Hexclave CLI so it starts the local dashboard and injects the Hexclave environment variables:
@ -975,13 +975,13 @@ export function getSdkSetupPrompt(mainType: "ai-prompt" | "nextjs" | "react" | "
First, create a \`hexclave.config.ts\` configuration file in the root directory of the workspace (or anywhere else):
\`\`\`ts hexclave.config.ts
import type { HexclaveConfig } from "${packageName}/config";
import type { HexclaveConfig } from "${packageName}";
// default: show-onboarding, which shows the onboarding flow for this project when Hexclave starts
export const config: HexclaveConfig = "show-onboarding";
\`\`\`
The \`/config\` entrypoint is lightweight and free of framework runtime code, so it can be safely loaded by tooling such as the local dashboard. If you later switch to a config object and want type-checking, wrap it with \`defineHexclaveConfig\` imported from the same \`${packageName}/config\` path (never from \`${packageName}\` directly, which would pull in the whole SDK and fail to load).
If you later switch to a config object and want type-checking, wrap it with \`defineHexclaveConfig\`, imported from the same \`${packageName}\` package.
${isAiPrompt ? deindent`
If you already know which apps you want to enable and how to configure them, you can also set the \`config\` object to the desired configuration directly. Refer to the per-app setup instructions for more information. However, in most cases, you would probably want to let the user onboard manually through the show-onboarding flow.

View File

@ -21,17 +21,6 @@ export const CONFIG_IMPORT_PACKAGES = [
"@stackframe/template",
] as const;
/**
* The module specifier from which a given SDK package exposes the
* `HexclaveConfig` type and the `defineHexclaveConfig` helper. Hexclave-branded
* packages expose them from a lightweight `/config` entrypoint (free of
* framework runtime code); legacy `@stackframe/*` releases predate `/config`
* and expose them from the package root.
*/
export function configImportSpecifierForPackage(pkg: string): string {
return pkg.startsWith("@hexclave/") ? `${pkg}/config` : pkg;
}
/**
* Given a list of dependency names (from package.json), returns the SDK
* package that should be used for the `HexclaveConfig` import, or `undefined`
@ -64,8 +53,7 @@ export function renderConfigFileContent(config: unknown, importPackage?: string)
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 importSpecifier = configImportSpecifierForPackage(pkg);
const importLine = `import type { HexclaveConfig } from "${importSpecifier}";`;
const importLine = `import type { HexclaveConfig } from "${pkg}";`;
return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
}
@ -102,15 +90,15 @@ import.meta.vitest?.test("renderConfigFileContent rejects invalid config exports
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";');
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/next";');
});
import.meta.vitest?.test("renderConfigFileContent defaults to @hexclave/js", ({ expect }) => {
const content = renderConfigFileContent({});
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js/config";');
expect(content).toContain('import type { HexclaveConfig } from "@hexclave/js";');
});
import.meta.vitest?.test("renderConfigFileContent keeps legacy @stackframe packages on their root entrypoint", ({ expect }) => {
import.meta.vitest?.test("renderConfigFileContent imports from the SDK package root", ({ expect }) => {
const content = renderConfigFileContent({}, "@stackframe/next");
expect(content).toContain('import type { HexclaveConfig } from "@stackframe/next";');
});

View File

@ -1,10 +1,9 @@
// Lightweight, side-effect-free entrypoint for authoring `hexclave.config.ts`
// files. Importing from here (e.g. `@hexclave/next/config`) gives you the
// `defineHexclaveConfig` helper and config types WITHOUT pulling in the
// framework runtime (React, server-only, Next.js internals). That matters
// because tooling such as the local dashboard evaluates your config file in a
// plain Node context — importing `defineHexclaveConfig` from the package root
// would drag in the whole SDK and fail to load.
// Retained only for backwards compatibility with config files that still import
// from `<pkg>/config` (e.g. `@hexclave/next/config`). New config files should
// import `defineHexclaveConfig` and the config types from the SDK package root
// (e.g. `@hexclave/next`) — the SDK roots are side-effect-free enough to load in
// a plain Node context (as the local dashboard and CLI do), so the separate
// `/config` entrypoint is no longer necessary and is not recommended anymore.
//
// Hexclave aliases and legacy Stack* names — @deprecated JSDoc lives on the
// original declarations in @hexclave/shared/config so it survives dts bundling