From 0b2d4759750f2c8d5ed057c6c1f763b5bf31ddf2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:58:55 +0000 Subject: [PATCH] Fix config push failing when SDK isn't installed in target repo Co-Authored-By: Konstantin Wohlwend --- packages/cli/src/commands/config-file.test.ts | 56 ++++++++++++++++++- packages/cli/src/commands/config-file.ts | 35 +++++++++++- packages/shared/src/config-rendering.ts | 15 ++++- 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/config-file.test.ts b/packages/cli/src/commands/config-file.test.ts index 69c2673b2..4f2ebc405 100644 --- a/packages/cli/src/commands/config-file.test.ts +++ b/packages/cli/src/commands/config-file.test.ts @@ -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 }; diff --git a/packages/cli/src/commands/config-file.ts b/packages/cli/src/commands/config-file.ts index 5a57a7c72..4cad9b5f4 100644 --- a/packages/cli/src/commands/config-file.ts +++ b/packages/cli/src/commands/config-file.ts @@ -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 { + 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 = {}; + 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); diff --git a/packages/shared/src/config-rendering.ts b/packages/shared/src/config-rendering.ts index 023fb0386..f6be0addf 100644 --- a/packages/shared/src/config-rendering.ts +++ b/packages/shared/src/config-rendering.ts @@ -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`; }