Fix config push failing when SDK isn't installed in target repo

Co-Authored-By: Konstantin Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
Devin AI 2026-07-14 01:58:55 +00:00
parent ce956a0fd2
commit 0b2d475975
3 changed files with 102 additions and 4 deletions

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, buildConfigPushSource, resolveConfigFilePathForPull } from "./config-file.js";
import { assertConfigPullTarget, buildConfigImportAlias, buildConfigPushSource, resolveConfigFilePathForPull } from "./config-file.js";
describe("resolveConfigFilePathForPull", () => {
let tmpDir: string;
@ -80,6 +80,60 @@ 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 CLI never depends on `@hexclave/next`, so its `/config` entrypoint is
// never resolvable from the config file's directory and must be aliased.
expect(alias["@hexclave/next/config"]).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,10 +1,12 @@
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";
@ -233,6 +235,35 @@ 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")
@ -288,7 +319,9 @@ export function registerConfigCommand(program: Command) {
}
const { createJiti } = await import("jiti");
const jiti = createJiti(import.meta.url);
const jiti = createJiti(import.meta.url, {
alias: buildConfigImportAlias(path.dirname(filePath)),
});
const configModule: { config?: unknown } = await jiti.import(filePath);
const config = parseConfigOverride(configModule.config);

View File

@ -9,7 +9,7 @@ const DEFAULT_CONFIG_IMPORT_PACKAGE = "@hexclave/js";
* so projects pinned to the last legacy release still render a config file
* that compiles against their installed SDK.
*/
const CONFIG_IMPORT_PACKAGES = [
export const CONFIG_IMPORT_PACKAGES = [
"@hexclave/next",
"@hexclave/react",
"@hexclave/tanstack-start",
@ -21,6 +21,17 @@ 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`
@ -53,7 +64,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 = pkg.startsWith("@hexclave/") ? `${pkg}/config` : pkg;
const importSpecifier = configImportSpecifierForPackage(pkg);
const importLine = `import type { HexclaveConfig } from "${importSpecifier}";`;
return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
}