From 7eb4e5048e3a46113059392554183c3e5ec9b43f Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Thu, 17 Sep 2026 20:35:55 -0500 Subject: [PATCH 1/4] feat(cli): resolve providers from the registry the project targets Addresses the two code-level readings of #208 using the mechanism added for #393: the registry comes from the project's declared targetVersions, so OpenTofu only when it is the sole declared product. 1. The "could not find a version" error pointed everyone at registry.terraform.io/browse/providers. An OpenTofu project now gets search.opentofu.org/providers - registry.opentofu.org/browse/providers is a 404, the listing lives on the search host. 2. `cdktn provider add` resolved available versions from registry.terraform.io regardless of target. Both registries expose the same /v1/providers///versions shape and their version lists differ - 42 versions vs 41 for hashicorp/random at time of writing - so an OpenTofu project could be told to pin a version its own registry does not carry. Deliberately out of scope: DEFAULT_HOSTNAME, which normalizes a bare "hashicorp/aws" into a fully qualified source. That is provider *identity*, not resolution, and it is consumed by more than this path - cdktf-config-manager and prebuilt-providers both hardcode stripping the "registry.terraform.io/" prefix, so making normalization registry-aware would break prebuilt matching and cdktf.json round-tripping for OpenTofu projects. It needs its own change with those consumers updated together. The registry parameter defaults to Terraform, so every caller that does not yet thread targetVersions keeps its current behaviour. Co-Authored-By: Claude Opus 5 --- .../lib/dependencies/dependency-manager.ts | 7 +- .../src/lib/dependencies/registry-api.ts | 11 ++- .../@cdktn/cli-core/src/lib/provider-add.ts | 14 +++- .../lib/dependencies/registry-api.test.ts | 73 +++++++++++++++++++ 4 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 packages/@cdktn/cli-core/src/test/lib/dependencies/registry-api.test.ts diff --git a/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts b/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts index e7dc9f79a..b52d1c124 100644 --- a/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts +++ b/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts @@ -6,6 +6,8 @@ import { IsErrorType, logger, TerraformDependencyConstraint, + Registry, + TERRAFORM_REGISTRY, } from "@cdktn/commons"; import { toPascalCase, toSnakeCase } from "codemaker"; import { CdktfConfig } from "../cdktf-config"; @@ -140,6 +142,7 @@ export class DependencyManager { private readonly targetLanguage: Language, private cdktfVersion: string, private readonly projectDirectory: string, + private readonly registry: Registry = TERRAFORM_REGISTRY, ) { this.packageManager = PackageManager.forLanguage( targetLanguage, @@ -363,7 +366,7 @@ export class DependencyManager { ); if (!constraint.version && constraint.isFromTerraformRegistry()) { - const v = await getLatestVersion(constraint); + const v = await getLatestVersion(constraint, this.registry); if (v) { constraint = new ProviderConstraint( constraint.source, @@ -372,7 +375,7 @@ export class DependencyManager { ); } else { throw Errors.Usage( - `Could not find a version for the provider '${constraint}' in the public registry. This could be due to a typo, please take a look at https://registry.terraform.io/browse/providers to find all supported providers.`, + `Could not find a version for the provider '${constraint}' in the public registry. This could be due to a typo, please take a look at ${this.registry.browseUrl} to find all supported providers.`, ); } } diff --git a/packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts b/packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts index a5164d118..e75fff2da 100644 --- a/packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts +++ b/packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts @@ -3,7 +3,7 @@ import { fetch, ProxyAgent } from "undici"; import { ProviderConstraint } from "./dependency-manager"; import * as semver from "semver"; -import { Errors } from "@cdktn/commons"; +import { Errors, Registry, TERRAFORM_REGISTRY } from "@cdktn/commons"; type VersionsReturnType = { id: string; // e.g. hashicorp/aws @@ -16,10 +16,11 @@ type VersionsReturnType = { async function fetchVersions( constraint: ProviderConstraint, + registry: Registry, ): Promise { const proxy = process.env.http_proxy || process.env.HTTP_PROXY; const dispatcher = proxy ? new ProxyAgent(proxy) : undefined; - const url = `https://registry.terraform.io/v1/providers/${constraint.namespace}/${constraint.name}/versions`; + const url = `https://${registry.hostname}/v1/providers/${constraint.namespace}/${constraint.name}/versions`; const result = await fetch(url, { dispatcher, @@ -41,11 +42,15 @@ async function fetchVersions( * returns the latest available version for the provider in the constraint * the version of the constraint is ignored * returns null, if the provider does not exist + * + * Both registries expose the same /v1/providers///versions shape, + * and their version lists differ, so the project's target decides which to ask. */ export async function getLatestVersion( constraint: ProviderConstraint, + registry: Registry = TERRAFORM_REGISTRY, ): Promise { - const versions = await fetchVersions(constraint); + const versions = await fetchVersions(constraint, registry); if (!versions) { return null; } diff --git a/packages/@cdktn/cli-core/src/lib/provider-add.ts b/packages/@cdktn/cli-core/src/lib/provider-add.ts index 065d2a4bb..003a6f792 100644 --- a/packages/@cdktn/cli-core/src/lib/provider-add.ts +++ b/packages/@cdktn/cli-core/src/lib/provider-add.ts @@ -3,7 +3,11 @@ * SPDX-License-Identifier: MPL-2.0 */ -import { Language } from "@cdktn/commons"; +import { + Language, + readConfigSync, + registryForTargetVersions, +} from "@cdktn/commons"; import { DependencyManager, ProviderConstraint, @@ -30,7 +34,13 @@ export async function providerAdd({ const version = cdktfVersion || (await determineDeps(cdktfVersion, dist)).cdktf_version; - const manager = new DependencyManager(language, version, projectDirectory); + const registry = registryForTargetVersions(readConfigSync().targetVersions); + const manager = new DependencyManager( + language, + version, + projectDirectory, + registry, + ); let needsGet = false; diff --git a/packages/@cdktn/cli-core/src/test/lib/dependencies/registry-api.test.ts b/packages/@cdktn/cli-core/src/test/lib/dependencies/registry-api.test.ts new file mode 100644 index 000000000..750235063 --- /dev/null +++ b/packages/@cdktn/cli-core/src/test/lib/dependencies/registry-api.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import { + MockAgent, + setGlobalDispatcher, + getGlobalDispatcher, + Dispatcher, +} from "undici"; +import { OPENTOFU_REGISTRY, TERRAFORM_REGISTRY } from "@cdktn/commons"; +import { ProviderConstraint } from "../../../lib/dependencies/dependency-manager"; +import { getLatestVersion } from "../../../lib/dependencies/registry-api"; + +describe("getLatestVersion", () => { + let mockAgent: MockAgent; + let originalDispatcher: Dispatcher; + + beforeEach(() => { + originalDispatcher = getGlobalDispatcher(); + mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + setGlobalDispatcher(mockAgent); + }); + + afterEach(async () => { + setGlobalDispatcher(originalDispatcher); + await mockAgent.close(); + }); + + const versionsPath = "/v1/providers/hashicorp/random/versions"; + const body = { id: "hashicorp/random", versions: [{ version: "3.7.2" }] }; + + it("asks the Terraform registry by default", async () => { + mockAgent + .get("https://registry.terraform.io") + .intercept({ path: versionsPath }) + .reply(200, body); + + const constraint = ProviderConstraint.fromConfigEntry("hashicorp/random"); + expect(await getLatestVersion(constraint)).toBe("3.7.2"); + }); + + it("asks the OpenTofu registry when that is the project's target", async () => { + mockAgent + .get("https://registry.opentofu.org") + .intercept({ path: versionsPath }) + .reply(200, { ...body, versions: [{ version: "3.9.1" }] }); + + const constraint = ProviderConstraint.fromConfigEntry("hashicorp/random"); + expect(await getLatestVersion(constraint, OPENTOFU_REGISTRY)).toBe("3.9.1"); + }); + + it("returns null for a provider the registry does not have", async () => { + mockAgent + .get("https://registry.opentofu.org") + .intercept({ path: versionsPath }) + .reply(404, ""); + + const constraint = ProviderConstraint.fromConfigEntry("hashicorp/random"); + expect(await getLatestVersion(constraint, OPENTOFU_REGISTRY)).toBeNull(); + }); +}); + +describe("registry browse URLs", () => { + it("point at each registry's own provider listing", () => { + expect(TERRAFORM_REGISTRY.browseUrl).toBe( + "https://registry.terraform.io/browse/providers", + ); + // registry.opentofu.org/browse/providers is a 404; the listing lives here. + expect(OPENTOFU_REGISTRY.browseUrl).toBe( + "https://search.opentofu.org/providers", + ); + }); +}); From 63f1aeac8a1dfa5468913cb79d208e9ef5be1c15 Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Thu, 17 Sep 2026 21:36:40 -0500 Subject: [PATCH 2/4] feat(cli): expand bare provider sources against the target registry Folds DEFAULT_HOSTNAME into the registry work, per review. OpenTofu users have been told to fully qualify their providers because normalization hardcoded registry.terraform.io. Two changes so that advice becomes optional rather than required, without breaking anyone following it: - A bare or namespace-only source now expands against the registry the project targets, so `cdktn provider add aws` in an OpenTofu project yields registry.opentofu.org/hashicorp/aws. - A source that already names a host is still left alone, so an explicit registry.opentofu.org/... always wins over the project's target - and over the default, for a project that declares no targetVersions. `isFromTerraformRegistry` becomes `isFromPublicRegistry`. That predicate gated automatic version resolution on the host being Terraform's, so an explicitly qualified OpenTofu provider - exactly what tofu users were told to write - silently skipped resolution and got no version. It now asks whether the host is a registry we can query at all, so private and self-hosted registries are still correctly excluded. Resolution and the browse URL in the error text both follow the constraint's own hostname rather than a registry passed down the call chain, which is what makes explicit qualification win and let the DependencyManager parameter go away again. simplifiedName drops whichever public host it carries, not just Terraform's. Co-Authored-By: Claude Opus 5 --- .../lib/dependencies/dependency-manager.ts | 46 +++++++++++++------ .../src/lib/dependencies/registry-api.ts | 24 ++++++++-- .../@cdktn/cli-core/src/lib/provider-add.ts | 9 +--- .../dependencies/dependency-manager.test.ts | 8 ++-- .../lib/dependencies/registry-api.test.ts | 40 ++++++++++++++-- packages/@cdktn/commons/src/registry.ts | 14 ++++++ 6 files changed, 107 insertions(+), 34 deletions(-) diff --git a/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts b/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts index b52d1c124..a76554e0d 100644 --- a/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts +++ b/packages/@cdktn/cli-core/src/lib/dependencies/dependency-manager.ts @@ -8,6 +8,7 @@ import { TerraformDependencyConstraint, Registry, TERRAFORM_REGISTRY, + registryForHostname, } from "@cdktn/commons"; import { toPascalCase, toSnakeCase } from "codemaker"; import { CdktfConfig } from "../cdktf-config"; @@ -19,23 +20,29 @@ import { getPrebuiltProviderVersionInformation, getPrebuiltProviderVersions, } from "./prebuilt-providers"; -import { getLatestVersion } from "./registry-api"; +import { getLatestVersion, registryForConstraint } from "./registry-api"; import { versionMatchesConstraint } from "./version-constraints"; import * as semver from "semver"; import { LocalProviderVersions } from "../local-provider-versions"; import { LocalProviderConstraints } from "../local-provider-constraints"; // ref: https://www.terraform.io/language/providers/requirements#source-addresses -export const DEFAULT_HOSTNAME = "registry.terraform.io"; +export const DEFAULT_HOSTNAME = TERRAFORM_REGISTRY.hostname; export const DEFAULT_NAMESPACE = "hashicorp"; -function normalizeProviderSource(source: string) { - // returns // + +/** + * Expands a source to //. A source that already + * names a hostname is left alone, so an explicitly qualified provider - which + * is what OpenTofu users have been told to write - always wins over the + * project's target. + */ +function normalizeProviderSource(source: string, registry: Registry) { const slashes = source.split("/").length - 1; switch (slashes) { case 0: - return `${DEFAULT_HOSTNAME}/${DEFAULT_NAMESPACE}/${source}`; + return `${registry.hostname}/${DEFAULT_NAMESPACE}/${source}`; case 1: - return `${DEFAULT_HOSTNAME}/${source}`; + return `${registry.hostname}/${source}`; default: return source; } @@ -54,18 +61,21 @@ export class ProviderConstraint { constructor( source: string, public readonly version: string | undefined, + registry: Registry = TERRAFORM_REGISTRY, ) { - this.source = normalizeProviderSource(source); + this.source = normalizeProviderSource(source, registry); } static fromConfigEntry( provider: string | TerraformDependencyConstraint, + registry: Registry = TERRAFORM_REGISTRY, ): ProviderConstraint { if (typeof provider === "string") { const [src, version] = provider.split("@"); return new ProviderConstraint( src.trim(), version ? version.trim() : undefined, + registry, ); } @@ -73,11 +83,15 @@ export class ProviderConstraint { (provider.namespace ? `${provider.namespace}/` : "") + (provider.source || provider.name); - return new ProviderConstraint(src, provider.version); + return new ProviderConstraint(src, provider.version, registry); } - public isFromTerraformRegistry(): boolean { - return this.hostname === DEFAULT_HOSTNAME; + /** + * Whether this provider lives on a registry cdktn can query for versions. + * Private and self-hosted registries expose no such API. + */ + public isFromPublicRegistry(): boolean { + return registryForHostname(this.hostname) !== undefined; } /** @@ -111,7 +125,10 @@ export class ProviderConstraint { public get simplifiedName(): string { return this.source .split("/") - .filter((part) => part !== DEFAULT_HOSTNAME && part !== DEFAULT_NAMESPACE) + .filter( + (part) => + registryForHostname(part) === undefined && part !== DEFAULT_NAMESPACE, + ) .join("/"); } @@ -142,7 +159,6 @@ export class DependencyManager { private readonly targetLanguage: Language, private cdktfVersion: string, private readonly projectDirectory: string, - private readonly registry: Registry = TERRAFORM_REGISTRY, ) { this.packageManager = PackageManager.forLanguage( targetLanguage, @@ -365,8 +381,8 @@ export class DependencyManager { `Adding local provider ${constraint.source} with version constraint ${constraint.version} to cdktf.json`, ); - if (!constraint.version && constraint.isFromTerraformRegistry()) { - const v = await getLatestVersion(constraint, this.registry); + if (!constraint.version && constraint.isFromPublicRegistry()) { + const v = await getLatestVersion(constraint); if (v) { constraint = new ProviderConstraint( constraint.source, @@ -375,7 +391,7 @@ export class DependencyManager { ); } else { throw Errors.Usage( - `Could not find a version for the provider '${constraint}' in the public registry. This could be due to a typo, please take a look at ${this.registry.browseUrl} to find all supported providers.`, + `Could not find a version for the provider '${constraint}' in the public registry. This could be due to a typo, please take a look at ${registryForConstraint(constraint).browseUrl} to find all supported providers.`, ); } } diff --git a/packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts b/packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts index e75fff2da..5b020b927 100644 --- a/packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts +++ b/packages/@cdktn/cli-core/src/lib/dependencies/registry-api.ts @@ -3,7 +3,12 @@ import { fetch, ProxyAgent } from "undici"; import { ProviderConstraint } from "./dependency-manager"; import * as semver from "semver"; -import { Errors, Registry, TERRAFORM_REGISTRY } from "@cdktn/commons"; +import { + Errors, + Registry, + TERRAFORM_REGISTRY, + registryForHostname, +} from "@cdktn/commons"; type VersionsReturnType = { id: string; // e.g. hashicorp/aws @@ -14,6 +19,17 @@ type VersionsReturnType = { }[]; }; +/** + * The registry a constraint resolves against: the one its own hostname names. + * A constraint is normalized before it gets here, so this is either the host + * the author wrote explicitly or the project's target registry. + */ +export function registryForConstraint( + constraint: ProviderConstraint, +): Registry { + return registryForHostname(constraint.hostname) ?? TERRAFORM_REGISTRY; +} + async function fetchVersions( constraint: ProviderConstraint, registry: Registry, @@ -48,9 +64,11 @@ async function fetchVersions( */ export async function getLatestVersion( constraint: ProviderConstraint, - registry: Registry = TERRAFORM_REGISTRY, ): Promise { - const versions = await fetchVersions(constraint, registry); + const versions = await fetchVersions( + constraint, + registryForConstraint(constraint), + ); if (!versions) { return null; } diff --git a/packages/@cdktn/cli-core/src/lib/provider-add.ts b/packages/@cdktn/cli-core/src/lib/provider-add.ts index 003a6f792..6d220cd03 100644 --- a/packages/@cdktn/cli-core/src/lib/provider-add.ts +++ b/packages/@cdktn/cli-core/src/lib/provider-add.ts @@ -35,17 +35,12 @@ export async function providerAdd({ cdktfVersion || (await determineDeps(cdktfVersion, dist)).cdktf_version; const registry = registryForTargetVersions(readConfigSync().targetVersions); - const manager = new DependencyManager( - language, - version, - projectDirectory, - registry, - ); + const manager = new DependencyManager(language, version, projectDirectory); let needsGet = false; for (const provider of providers) { - const constraint = ProviderConstraint.fromConfigEntry(provider); + const constraint = ProviderConstraint.fromConfigEntry(provider, registry); if (forceLocal) { needsGet = true; await manager.addLocalProvider(constraint); diff --git a/packages/@cdktn/cli-core/src/test/lib/dependencies/dependency-manager.test.ts b/packages/@cdktn/cli-core/src/test/lib/dependencies/dependency-manager.test.ts index 466ef7c63..40b6c60bf 100644 --- a/packages/@cdktn/cli-core/src/test/lib/dependencies/dependency-manager.test.ts +++ b/packages/@cdktn/cli-core/src/test/lib/dependencies/dependency-manager.test.ts @@ -16,7 +16,7 @@ describe("dependency manager", () => { "registry.terraform.io/hashicorp/aws", ); expect(constraint.hostname).toEqual("registry.terraform.io"); - expect(constraint.isFromTerraformRegistry()).toBe(true); + expect(constraint.isFromPublicRegistry()).toBe(true); expect(constraint.namespace).toEqual("hashicorp"); expect(constraint.name).toEqual("aws"); expect(constraint.simplifiedName).toEqual("aws"); @@ -40,7 +40,7 @@ describe("dependency manager", () => { "registry.terraform.io/hashicorp/aws", ); expect(constraint.hostname).toEqual("registry.terraform.io"); - expect(constraint.isFromTerraformRegistry()).toBe(true); + expect(constraint.isFromPublicRegistry()).toBe(true); expect(constraint.namespace).toEqual("hashicorp"); expect(constraint.name).toEqual("aws"); expect(constraint.version).toBeDefined(); @@ -52,7 +52,7 @@ describe("dependency manager", () => { const constraint = ProviderConstraint.fromConfigEntry("kreuzwerker/docker"); expect(constraint.hostname).toEqual("registry.terraform.io"); - expect(constraint.isFromTerraformRegistry()).toBe(true); + expect(constraint.isFromPublicRegistry()).toBe(true); expect(constraint.namespace).toEqual("kreuzwerker"); expect(constraint.name).toEqual("docker"); expect(constraint.simplifiedName).toEqual("kreuzwerker/docker"); @@ -63,7 +63,7 @@ describe("dependency manager", () => { "registry.example.com/acme/customprovider", ); expect(constraint.hostname).toEqual("registry.example.com"); - expect(constraint.isFromTerraformRegistry()).toBe(false); + expect(constraint.isFromPublicRegistry()).toBe(false); expect(constraint.namespace).toEqual("acme"); expect(constraint.name).toEqual("customprovider"); expect(constraint.simplifiedName).toEqual( diff --git a/packages/@cdktn/cli-core/src/test/lib/dependencies/registry-api.test.ts b/packages/@cdktn/cli-core/src/test/lib/dependencies/registry-api.test.ts index 750235063..14c4375d9 100644 --- a/packages/@cdktn/cli-core/src/test/lib/dependencies/registry-api.test.ts +++ b/packages/@cdktn/cli-core/src/test/lib/dependencies/registry-api.test.ts @@ -39,14 +39,41 @@ describe("getLatestVersion", () => { expect(await getLatestVersion(constraint)).toBe("3.7.2"); }); - it("asks the OpenTofu registry when that is the project's target", async () => { + it("asks the OpenTofu registry when the project targets it", async () => { mockAgent .get("https://registry.opentofu.org") .intercept({ path: versionsPath }) .reply(200, { ...body, versions: [{ version: "3.9.1" }] }); - const constraint = ProviderConstraint.fromConfigEntry("hashicorp/random"); - expect(await getLatestVersion(constraint, OPENTOFU_REGISTRY)).toBe("3.9.1"); + // A bare source expands against the project's registry. + const constraint = ProviderConstraint.fromConfigEntry( + "hashicorp/random", + OPENTOFU_REGISTRY, + ); + expect(constraint.source).toBe("registry.opentofu.org/hashicorp/random"); + expect(await getLatestVersion(constraint)).toBe("3.9.1"); + }); + + it("honours an explicitly qualified source over the project's target", async () => { + mockAgent + .get("https://registry.opentofu.org") + .intercept({ path: versionsPath }) + .reply(200, { ...body, versions: [{ version: "3.9.1" }] }); + + // OpenTofu users have been told to fully qualify; that must keep working + // even when the project declares no targetVersions. + const constraint = ProviderConstraint.fromConfigEntry( + "registry.opentofu.org/hashicorp/random", + ); + expect(constraint.isFromPublicRegistry()).toBe(true); + expect(await getLatestVersion(constraint)).toBe("3.9.1"); + }); + + it("treats a private registry as unqueryable", () => { + const constraint = ProviderConstraint.fromConfigEntry( + "registry.example.com/acme/thing", + ); + expect(constraint.isFromPublicRegistry()).toBe(false); }); it("returns null for a provider the registry does not have", async () => { @@ -55,8 +82,11 @@ describe("getLatestVersion", () => { .intercept({ path: versionsPath }) .reply(404, ""); - const constraint = ProviderConstraint.fromConfigEntry("hashicorp/random"); - expect(await getLatestVersion(constraint, OPENTOFU_REGISTRY)).toBeNull(); + const constraint = ProviderConstraint.fromConfigEntry( + "hashicorp/random", + OPENTOFU_REGISTRY, + ); + expect(await getLatestVersion(constraint)).toBeNull(); }); }); diff --git a/packages/@cdktn/commons/src/registry.ts b/packages/@cdktn/commons/src/registry.ts index e5052bb07..d5bde5d87 100644 --- a/packages/@cdktn/commons/src/registry.ts +++ b/packages/@cdktn/commons/src/registry.ts @@ -76,3 +76,17 @@ export function registryForTargetVersions( targetVersions?.terraform === undefined; return opentofuOnly ? OPENTOFU_REGISTRY : TERRAFORM_REGISTRY; } + +/** The registries cdktn knows how to query for available versions. */ +export const PUBLIC_REGISTRIES: readonly Registry[] = [ + TERRAFORM_REGISTRY, + OPENTOFU_REGISTRY, +]; + +/** + * The public registry serving `hostname`, or undefined for a private or + * self-hosted one - which cdktn cannot query for versions. + */ +export function registryForHostname(hostname: string): Registry | undefined { + return PUBLIC_REGISTRIES.find((r) => r.hostname === hostname); +} From b17afc6a813d1ed2481ea133ab62309f1c54daac Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Wed, 23 Sep 2026 08:15:42 -0500 Subject: [PATCH 3/4] fix(cli): read targetVersions from the project directory, not the cwd Review finding on #445. `providerAdd` picked the registry from `readConfigSync()`, which with no argument reads `process.cwd()/cdktf.json`, while passing `projectDirectory` to DependencyManager. Those are not the same directory: init() scaffolds into `destination` and calls providerAdd with `projectDirectory: destination` without changing cwd (cli-core/src/lib/init.ts:93). So adding a bare provider to a freshly scaffolded OpenTofu-only project would normalize and resolve it against registry.terraform.io, chosen from whatever project the CLI happened to be run from. Regression test covers the boundary directly: cwd is a Terraform-targeting project, the destination targets OpenTofu only, and the selection must follow the destination. Co-Authored-By: Claude Opus 5 --- .../@cdktn/cli-core/src/lib/provider-add.ts | 7 +- .../test/lib/provider-add-registry.test.ts | 64 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 packages/@cdktn/cli-core/src/test/lib/provider-add-registry.test.ts diff --git a/packages/@cdktn/cli-core/src/lib/provider-add.ts b/packages/@cdktn/cli-core/src/lib/provider-add.ts index 6d220cd03..9fbe6c8a0 100644 --- a/packages/@cdktn/cli-core/src/lib/provider-add.ts +++ b/packages/@cdktn/cli-core/src/lib/provider-add.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: MPL-2.0 */ +import * as path from "path"; import { Language, readConfigSync, @@ -34,7 +35,11 @@ export async function providerAdd({ const version = cdktfVersion || (await determineDeps(cdktfVersion, dist)).cdktf_version; - const registry = registryForTargetVersions(readConfigSync().targetVersions); + // Read the target project's config, not the caller's cwd - init() scaffolds + // into `destination` and calls this without changing directory. + const registry = registryForTargetVersions( + readConfigSync(path.join(projectDirectory, "cdktf.json")).targetVersions, + ); const manager = new DependencyManager(language, version, projectDirectory); let needsGet = false; diff --git a/packages/@cdktn/cli-core/src/test/lib/provider-add-registry.test.ts b/packages/@cdktn/cli-core/src/test/lib/provider-add-registry.test.ts new file mode 100644 index 000000000..7349a26c9 --- /dev/null +++ b/packages/@cdktn/cli-core/src/test/lib/provider-add-registry.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +import * as fs from "fs-extra"; +import * as os from "os"; +import * as path from "path"; +import { + OPENTOFU_REGISTRY, + TERRAFORM_REGISTRY, + readConfigSync, + registryForTargetVersions, +} from "@cdktn/commons"; + +// init() scaffolds into a destination and calls providerAdd with that +// directory without changing cwd, so the registry must come from the project +// being written to, not from wherever the CLI happens to be running. +describe("registry selection for a project directory", () => { + let tmpRoot: string; + let cwdProject: string; + let targetProject: string; + let previousCwd: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "provider-add-registry")); + cwdProject = path.join(tmpRoot, "cwd-project"); + targetProject = path.join(tmpRoot, "target-project"); + fs.mkdirpSync(cwdProject); + fs.mkdirpSync(targetProject); + + // The directory the CLI runs from targets Terraform... + fs.writeFileSync( + path.join(cwdProject, "cdktf.json"), + JSON.stringify({ language: "typescript", app: "npx tsx main.ts" }), + ); + // ...while the project being written to targets OpenTofu only. + fs.writeFileSync( + path.join(targetProject, "cdktf.json"), + JSON.stringify({ + language: "typescript", + app: "npx tsx main.ts", + targetVersions: { opentofu: ">=1.6.0" }, + }), + ); + + previousCwd = process.cwd(); + process.chdir(cwdProject); + }); + + afterEach(() => { + process.chdir(previousCwd); + fs.removeSync(tmpRoot); + }); + + it("uses the target project's registry, not the cwd's", () => { + const fromTarget = registryForTargetVersions( + readConfigSync(path.join(targetProject, "cdktf.json")).targetVersions, + ); + expect(fromTarget).toBe(OPENTOFU_REGISTRY); + }); + + it("would pick the wrong registry if it read the cwd", () => { + const fromCwd = registryForTargetVersions(readConfigSync().targetVersions); + expect(fromCwd).toBe(TERRAFORM_REGISTRY); + }); +}); From 21a44591ba45a2878e5423bb51bb987d62609bdb Mon Sep 17 00:00:00 2001 From: Jon Steinich Date: Sat, 3 Oct 2026 10:38:05 -0500 Subject: [PATCH 4/4] test(cli): make the registry-selection test exercise providerAdd Review feedback on #445: the previous test called readConfigSync directly and asserted helper semantics, so it passed against the pathless call it was meant to guard. It documented behaviour without protecting the fix. It now calls providerAdd() with a Terraform project as cwd, an OpenTofu-only projectDirectory and a bare provider source, and asserts the constraint that reaches the DependencyManager carries registry.opentofu.org. A second case covers the mirror - an OpenTofu cwd must not leak into a Terraform destination. Confirmed it is a real guard: reverting provider-add.ts to the pathless readConfigSync() fails both cases. Spying on DependencyManager.prototype.addLocalProvider rather than mocking the module keeps the real ProviderConstraint doing the normalizing, so the assertion is on emitted behaviour. It also avoids the circular import between dependency-manager and registry-api, which makes requireActual on that module throw a TDZ error on DEFAULT_HOSTNAME. Co-Authored-By: Claude Opus 5 --- .../test/lib/provider-add-registry.test.ts | 86 +++++++++++-------- 1 file changed, 52 insertions(+), 34 deletions(-) diff --git a/packages/@cdktn/cli-core/src/test/lib/provider-add-registry.test.ts b/packages/@cdktn/cli-core/src/test/lib/provider-add-registry.test.ts index 7349a26c9..c2f7c04d5 100644 --- a/packages/@cdktn/cli-core/src/test/lib/provider-add-registry.test.ts +++ b/packages/@cdktn/cli-core/src/test/lib/provider-add-registry.test.ts @@ -3,62 +3,80 @@ import * as fs from "fs-extra"; import * as os from "os"; import * as path from "path"; -import { - OPENTOFU_REGISTRY, - TERRAFORM_REGISTRY, - readConfigSync, - registryForTargetVersions, -} from "@cdktn/commons"; +import { Language } from "@cdktn/commons"; +import { DependencyManager } from "../../lib/dependencies/dependency-manager"; +import { providerAdd } from "../../lib/provider-add"; // init() scaffolds into a destination and calls providerAdd with that -// directory without changing cwd, so the registry must come from the project -// being written to, not from wherever the CLI happens to be running. -describe("registry selection for a project directory", () => { +// directory without changing cwd, so the registry has to come from the project +// being written to rather than from wherever the CLI runs. +// +// Spying on addLocalProvider rather than mocking the module keeps the real +// ProviderConstraint doing the normalizing, so the source it receives is what +// proves which registry providerAdd selected. +describe("providerAdd registry selection", () => { let tmpRoot: string; - let cwdProject: string; - let targetProject: string; let previousCwd: string; + let addLocalProvider: jest.SpyInstance; - beforeEach(() => { - tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "provider-add-registry")); - cwdProject = path.join(tmpRoot, "cwd-project"); - targetProject = path.join(tmpRoot, "target-project"); - fs.mkdirpSync(cwdProject); - fs.mkdirpSync(targetProject); - - // The directory the CLI runs from targets Terraform... + function project(name: string, targetVersions?: Record) { + const dir = path.join(tmpRoot, name); + fs.mkdirpSync(dir); fs.writeFileSync( - path.join(cwdProject, "cdktf.json"), - JSON.stringify({ language: "typescript", app: "npx tsx main.ts" }), - ); - // ...while the project being written to targets OpenTofu only. - fs.writeFileSync( - path.join(targetProject, "cdktf.json"), + path.join(dir, "cdktf.json"), JSON.stringify({ language: "typescript", app: "npx tsx main.ts", - targetVersions: { opentofu: ">=1.6.0" }, + ...(targetVersions ? { targetVersions } : {}), }), ); + return dir; + } + + async function sourceAddedTo(projectDirectory: string) { + addLocalProvider.mockClear(); + await providerAdd({ + providers: ["hashicorp/random"], + language: Language.TYPESCRIPT, + projectDirectory, + cdktfVersion: "0.0.0", // non-empty, so determineDeps is never called + forceLocal: true, + }); + expect(addLocalProvider).toHaveBeenCalledTimes(1); + return addLocalProvider.mock.calls[0][0].source as string; + } + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "provider-add-registry")); previousCwd = process.cwd(); - process.chdir(cwdProject); + addLocalProvider = jest + .spyOn(DependencyManager.prototype, "addLocalProvider") + .mockResolvedValue(undefined); }); afterEach(() => { + addLocalProvider.mockRestore(); process.chdir(previousCwd); fs.removeSync(tmpRoot); }); - it("uses the target project's registry, not the cwd's", () => { - const fromTarget = registryForTargetVersions( - readConfigSync(path.join(targetProject, "cdktf.json")).targetVersions, + it("follows the project directory, not the cwd", async () => { + process.chdir(project("cwd-terraform")); + const destination = project("destination-opentofu", { + opentofu: ">=1.6.0", + }); + + expect(await sourceAddedTo(destination)).toBe( + "registry.opentofu.org/hashicorp/random", ); - expect(fromTarget).toBe(OPENTOFU_REGISTRY); }); - it("would pick the wrong registry if it read the cwd", () => { - const fromCwd = registryForTargetVersions(readConfigSync().targetVersions); - expect(fromCwd).toBe(TERRAFORM_REGISTRY); + it("does not let an OpenTofu cwd leak into a Terraform project", async () => { + process.chdir(project("cwd-opentofu", { opentofu: ">=1.6.0" })); + const destination = project("destination-terraform"); + + expect(await sourceAddedTo(destination)).toBe( + "registry.terraform.io/hashicorp/random", + ); }); });