@cdktn/provider-generator 0.25.0-pre.6 → 0.25.0-pre.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +12 -0
  2. package/build/__tests__/edge-provider-schema/builder.js +3 -3
  3. package/build/__tests__/edge-provider-schema/cli.js +18 -8
  4. package/build/__tests__/edge-provider-schema.test.js +18 -8
  5. package/build/__tests__/provider.test.js +44 -16
  6. package/build/get/__tests__/constructs-maker.test.js +91 -8
  7. package/build/get/__tests__/generator/complex-computed-types.test.js +18 -8
  8. package/build/get/__tests__/generator/deep-nested-attributes.test.js +18 -8
  9. package/build/get/__tests__/generator/description-escaping.test.js +18 -8
  10. package/build/get/__tests__/generator/empty-provider-resources.test.js +18 -8
  11. package/build/get/__tests__/generator/ephemeral-resources.test.js +18 -8
  12. package/build/get/__tests__/generator/export-sharding.test.js +18 -8
  13. package/build/get/__tests__/generator/import-style.test.js +18 -8
  14. package/build/get/__tests__/generator/module-generator.test.js +18 -8
  15. package/build/get/__tests__/generator/nested-types.test.js +18 -8
  16. package/build/get/__tests__/generator/provider-functions-name-collision.test.d.ts +2 -0
  17. package/build/get/__tests__/generator/provider-functions-name-collision.test.js +89 -0
  18. package/build/get/__tests__/generator/provider-functions.test.js +18 -8
  19. package/build/get/__tests__/generator/provider.test.js +18 -8
  20. package/build/get/__tests__/generator/resource-types.test.js +18 -8
  21. package/build/get/__tests__/generator/skipped-attributes.test.js +18 -8
  22. package/build/get/__tests__/generator/types.test.js +18 -8
  23. package/build/get/__tests__/generator/versions-file.test.js +25 -10
  24. package/build/get/__tests__/generator/write-only.test.js +18 -8
  25. package/build/get/__tests__/target-versions.test.js +18 -8
  26. package/build/get/__tests__/util.js +20 -11
  27. package/build/get/constructs-maker.d.ts +17 -0
  28. package/build/get/constructs-maker.js +68 -10
  29. package/build/get/generator/emitter/struct-emitter.js +20 -10
  30. package/build/get/generator/loop-detection.js +2 -3
  31. package/build/get/generator/models/attribute-model.js +3 -3
  32. package/build/get/generator/models/attribute-type-model.js +3 -3
  33. package/build/get/generator/models/provider-function-model.js +3 -4
  34. package/build/get/generator/models/resource-model.d.ts +5 -1
  35. package/build/get/generator/models/resource-model.js +20 -10
  36. package/build/get/generator/models/supported-stored-classes.js +2 -3
  37. package/build/get/generator/provider-generator.d.ts +15 -1
  38. package/build/get/generator/provider-generator.js +20 -5
  39. package/build/get/generator/resource-parser.d.ts +3 -1
  40. package/build/get/generator/resource-parser.js +16 -7
  41. package/build/get/generator/sanitized-comments.js +2 -3
  42. package/build/get/generator/skipped-attributes.js +2 -3
  43. package/build/index.js +3 -3
  44. package/build/util.js +3 -4
  45. package/package.json +36 -13
  46. package/src/__tests__/__snapshots__/provider.test.ts.snap +1309 -3206
  47. package/src/__tests__/provider.test.ts +31 -8
  48. package/src/get/__tests__/constructs-maker.test.ts +125 -1
  49. package/src/get/__tests__/generator/fixtures/provider-functions-name-collision.test.fixture.json +42 -0
  50. package/src/get/__tests__/generator/provider-functions-name-collision.test.ts +84 -0
  51. package/src/get/__tests__/generator/versions-file.test.ts +10 -5
  52. package/src/get/constructs-maker.ts +65 -0
  53. package/src/get/generator/emitter/struct-emitter.ts +2 -2
  54. package/src/get/generator/models/attribute-type-model.ts +1 -2
  55. package/src/get/generator/models/resource-model.ts +32 -9
  56. package/src/get/generator/provider-generator.ts +27 -4
  57. package/src/get/generator/resource-parser.ts +21 -3
  58. package/tsconfig.json +4 -0
@@ -70,6 +70,27 @@ function resourceTypesPresentInSnapshot(
70
70
  return resources;
71
71
  }
72
72
 
73
+ /**
74
+ * Rewrites the registry host in any fully-qualified-name key. The host records
75
+ * which CLI fetched the schema - terraform's or tofu's - rather than anything
76
+ * about the generated code, so one snapshot serves both.
77
+ */
78
+ function stubRegistryHosts(value: any): any {
79
+ if (Array.isArray(value)) return value.map(stubRegistryHosts);
80
+ if (value && typeof value === "object") {
81
+ return Object.fromEntries(
82
+ Object.entries(value).map(([k, v]) => [
83
+ k.replace(
84
+ /^registry\.(?:terraform\.io|opentofu\.org)\//,
85
+ "STUBBED_REGISTRY/",
86
+ ),
87
+ stubRegistryHosts(v),
88
+ ]),
89
+ );
90
+ }
91
+ return value;
92
+ }
93
+
73
94
  describe("Provider", () => {
74
95
  it("generates a provider", async () => {
75
96
  const constraint = new TerraformProviderConstraint(
@@ -86,7 +107,7 @@ describe("Provider", () => {
86
107
  process.env.CDKTF_EXPERIMENTAL_PROVIDER_SCHEMA_CACHE_PATH,
87
108
  );
88
109
  await maker.generate([constraint]);
89
- const snapshot = directorySnapshot(workdir);
110
+ const snapshot = stubRegistryHosts(directorySnapshot(workdir));
90
111
  expect(snapshot).toMatchSnapshot();
91
112
  });
92
113
  }, 600_000);
@@ -103,7 +124,7 @@ describe("Provider", () => {
103
124
  targetLanguage: Language.TYPESCRIPT,
104
125
  });
105
126
  await maker.generate([constraint]);
106
- const snapshot = directorySnapshot(workdir);
127
+ const snapshot = stubRegistryHosts(directorySnapshot(workdir));
107
128
 
108
129
  const terraformResourceTypesPresent: string[] =
109
130
  resourceTypesPresentInSnapshot(snapshot, "datadog");
@@ -127,7 +148,7 @@ describe("Provider", () => {
127
148
  const constraint = new TerraformProviderConstraint({
128
149
  name: "dockerr",
129
150
  source: "registry.terraform.io/kreuzwerker/docker",
130
- version: "3.0.2",
151
+ version: "3.9.0",
131
152
  });
132
153
  return await mkdtemp(async (workdir) => {
133
154
  const jsiiPath = path.join(workdir, ".jsii");
@@ -140,7 +161,7 @@ describe("Provider", () => {
140
161
  process.env.CDKTF_EXPERIMENTAL_PROVIDER_SCHEMA_CACHE_PATH,
141
162
  );
142
163
  await maker.generate([constraint]);
143
- const snapshot = directorySnapshot(workdir);
164
+ const snapshot = stubRegistryHosts(directorySnapshot(workdir));
144
165
 
145
166
  const terraformResourceTypesPresent: string[] =
146
167
  resourceTypesPresentInSnapshot(snapshot, "dockerr");
@@ -160,11 +181,13 @@ describe("Provider", () => {
160
181
  version: "2.51.0",
161
182
  source: "DrFaust92/bitbucket",
162
183
  });
184
+ // andsafe-AG/bitbucket is Terraform-registry only; zahiar/bitbucket is
185
+ // mirrored on both, so this runs under either CLI.
163
186
  const constraint2 = new TerraformProviderConstraint({
164
187
  name: "abitbucket",
165
- namespace: "andsafe-AG",
166
- version: "2.5.0",
167
- source: "andsafe-AG/bitbucket",
188
+ namespace: "zahiar",
189
+ version: "0.3.0",
190
+ source: "zahiar/bitbucket",
168
191
  });
169
192
  return await mkdtemp(async (workdir) => {
170
193
  const jsiiPath = path.join(workdir, ".jsii");
@@ -178,7 +201,7 @@ describe("Provider", () => {
178
201
  );
179
202
  await maker.generate([constraint, constraint2]);
180
203
  console.log("workdir", workdir);
181
- const snapshot = directorySnapshot(workdir);
204
+ const snapshot = stubRegistryHosts(directorySnapshot(workdir));
182
205
  expect(snapshot).toMatchSnapshot();
183
206
  });
184
207
  }, 600_000);
@@ -8,7 +8,7 @@ import {
8
8
  ConstructsMakerModuleTarget,
9
9
  ConstructsMakerProviderTarget,
10
10
  } from "@cdktn/commons";
11
- import { determineGoModuleName } from "../constructs-maker";
11
+ import { ConstructsMaker, determineGoModuleName } from "../constructs-maker";
12
12
 
13
13
  describe("constructsMaker", () => {
14
14
  describe("determineGoModuleName", () => {
@@ -115,4 +115,128 @@ describe("constructsMaker", () => {
115
115
  expect(target.srcMakName).toEqual("security_group");
116
116
  });
117
117
  });
118
+
119
+ describe("removeOrphanedResourceFolders", () => {
120
+ let tmpDir: string;
121
+
122
+ const providerDir = (provider: string) =>
123
+ path.join(tmpDir, "providers", provider);
124
+
125
+ /**
126
+ * Lay out a provider folder containing the given resource directories plus
127
+ * the provider-level index files a real generation run writes.
128
+ */
129
+ const seedProvider = async (provider: string, resources: string[]) => {
130
+ for (const resource of resources) {
131
+ await fs.mkdirs(path.join(providerDir(provider), resource));
132
+ await fs.writeFile(
133
+ path.join(providerDir(provider), resource, "index.ts"),
134
+ "// generated\n",
135
+ );
136
+ }
137
+ await fs.writeFile(
138
+ path.join(providerDir(provider), "index.ts"),
139
+ "// generated by cdktn get\n",
140
+ );
141
+ await fs.writeFile(
142
+ path.join(providerDir(provider), "lazy-index.ts"),
143
+ "// generated by cdktn get\n",
144
+ );
145
+ };
146
+
147
+ const makeMaker = (targetLanguage: Language) =>
148
+ new ConstructsMaker({
149
+ codeMakerOutput: tmpDir,
150
+ targetLanguage,
151
+ });
152
+
153
+ beforeEach(async () => {
154
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cdktf.orphans"));
155
+ });
156
+
157
+ afterEach(async () => {
158
+ await fs.remove(tmpDir);
159
+ });
160
+
161
+ it("removes resource folders the current run did not emit", async () => {
162
+ // 'teams' and 'data-lake-pipeline' exist only in the previously generated
163
+ // provider version; the new schema no longer produces them.
164
+ await seedProvider("mongodbatlas", [
165
+ "cluster",
166
+ "teams",
167
+ "data-lake-pipeline",
168
+ ]);
169
+
170
+ const maker = makeMaker(Language.TYPESCRIPT);
171
+ maker.emittedResourceFolders = { mongodbatlas: ["cluster"] };
172
+ await maker.removeOrphanedResourceFolders();
173
+
174
+ expect(
175
+ await fs.pathExists(path.join(providerDir("mongodbatlas"), "cluster")),
176
+ ).toBe(true);
177
+ expect(
178
+ await fs.pathExists(path.join(providerDir("mongodbatlas"), "teams")),
179
+ ).toBe(false);
180
+ expect(
181
+ await fs.pathExists(
182
+ path.join(providerDir("mongodbatlas"), "data-lake-pipeline"),
183
+ ),
184
+ ).toBe(false);
185
+ });
186
+
187
+ it("keeps provider-level files", async () => {
188
+ await seedProvider("mongodbatlas", ["cluster", "teams"]);
189
+
190
+ const maker = makeMaker(Language.TYPESCRIPT);
191
+ maker.emittedResourceFolders = { mongodbatlas: ["cluster"] };
192
+ await maker.removeOrphanedResourceFolders();
193
+
194
+ expect(
195
+ await fs.pathExists(path.join(providerDir("mongodbatlas"), "index.ts")),
196
+ ).toBe(true);
197
+ expect(
198
+ await fs.pathExists(
199
+ path.join(providerDir("mongodbatlas"), "lazy-index.ts"),
200
+ ),
201
+ ).toBe(true);
202
+ });
203
+
204
+ it("leaves providers that were not regenerated untouched", async () => {
205
+ // filterAlreadyGenerated can skip a provider entirely, in which case it
206
+ // has no entry in emittedResourceFolders and must not be pruned.
207
+ await seedProvider("mongodbatlas", ["cluster"]);
208
+ await seedProvider("onepassword", ["item"]);
209
+
210
+ const maker = makeMaker(Language.TYPESCRIPT);
211
+ maker.emittedResourceFolders = { mongodbatlas: ["cluster"] };
212
+ await maker.removeOrphanedResourceFolders();
213
+
214
+ expect(
215
+ await fs.pathExists(path.join(providerDir("onepassword"), "item")),
216
+ ).toBe(true);
217
+ });
218
+
219
+ it("is a no-op for non-TypeScript targets", async () => {
220
+ // Other languages co-locate providers and modules, so a dropped resource
221
+ // is indistinguishable from a hand-written construct.
222
+ await seedProvider("mongodbatlas", ["cluster", "teams"]);
223
+
224
+ const maker = makeMaker(Language.PYTHON);
225
+ maker.emittedResourceFolders = { mongodbatlas: ["cluster"] };
226
+ await maker.removeOrphanedResourceFolders();
227
+
228
+ expect(
229
+ await fs.pathExists(path.join(providerDir("mongodbatlas"), "teams")),
230
+ ).toBe(true);
231
+ });
232
+
233
+ it("does not throw when the provider folder is missing", async () => {
234
+ const maker = makeMaker(Language.TYPESCRIPT);
235
+ maker.emittedResourceFolders = { mongodbatlas: ["cluster"] };
236
+
237
+ await expect(
238
+ maker.removeOrphanedResourceFolders(),
239
+ ).resolves.not.toThrow();
240
+ });
241
+ });
118
242
  });
@@ -0,0 +1,42 @@
1
+ {
2
+ "provider_schemas": {
3
+ "registry.terraform.io/hashicorp/example": {
4
+ "resource_schemas": {
5
+ "example_provider_functions": {
6
+ "version": 0,
7
+ "block": {
8
+ "attributes": {
9
+ "id": {
10
+ "type": "string",
11
+ "description_kind": "plain",
12
+ "computed": true
13
+ },
14
+ "name": {
15
+ "type": "string",
16
+ "description_kind": "plain",
17
+ "optional": true
18
+ }
19
+ }
20
+ }
21
+ }
22
+ },
23
+ "functions": {
24
+ "sum": {
25
+ "description": "Adds up two numbers.",
26
+ "summary": "Sum numbers",
27
+ "return_type": "number",
28
+ "parameters": [
29
+ {
30
+ "name": "a",
31
+ "type": "number"
32
+ },
33
+ {
34
+ "name": "b",
35
+ "type": "number"
36
+ }
37
+ ]
38
+ }
39
+ }
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,84 @@
1
+ // Copyright (c) HashiCorp, Inc
2
+ // SPDX-License-Identifier: MPL-2.0
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { CodeMaker } from "codemaker";
6
+ import { TerraformProviderGenerator } from "../../generator/provider-generator";
7
+ import { sanitizeClassOrNamespaceName } from "../../generator/resource-parser";
8
+ import { createTmpHelper } from "../util";
9
+
10
+ const tmp = createTmpHelper();
11
+
12
+ // A provider declaring provider-defined functions emits them to
13
+ // providers/<provider>/provider-functions/index.ts. A resource named
14
+ // <provider>_provider_functions maps to that same directory, so without a
15
+ // guard the resource overwrites the functions wrapper and the provider index
16
+ // exports the name twice.
17
+ describe("resource named like the provider functions submodule", () => {
18
+ let workdir: string;
19
+
20
+ beforeAll(async () => {
21
+ const code = new CodeMaker();
22
+ workdir = tmp("provider-functions-name-collision.test");
23
+ const spec = JSON.parse(
24
+ fs.readFileSync(
25
+ path.join(
26
+ __dirname,
27
+ "fixtures",
28
+ "provider-functions-name-collision.test.fixture.json",
29
+ ),
30
+ "utf-8",
31
+ ),
32
+ );
33
+ new TerraformProviderGenerator(code, spec).generateAll();
34
+ await code.save(workdir);
35
+ });
36
+
37
+ const read = (relPath: string) =>
38
+ fs.readFileSync(path.join(workdir, relPath), "utf-8");
39
+
40
+ test("the functions submodule holds the functions wrapper, not the resource", () => {
41
+ const output = read("providers/example/provider-functions/index.ts");
42
+ expect(output).toContain("class ExampleProviderFunctions");
43
+ expect(output).not.toContain("cdktn.TerraformResource");
44
+ });
45
+
46
+ test("the resource is emitted to its own directory", () => {
47
+ const output = read(
48
+ "providers/example/provider-functions-resource/index.ts",
49
+ );
50
+ expect(output).toContain("class ProviderFunctionsResource");
51
+ expect(output).toContain("cdktn.TerraformResource");
52
+ });
53
+
54
+ test("the provider index exports both, without duplicate names", () => {
55
+ const index = read("providers/example/index.ts");
56
+ expect(index).toContain(
57
+ "export * as providerFunctions from './provider-functions/index';",
58
+ );
59
+ expect(index).toContain(
60
+ "export * as providerFunctionsResource from './provider-functions-resource/index';",
61
+ );
62
+
63
+ const exportedNames = [...index.matchAll(/export \* as (\w+) from/g)].map(
64
+ (m) => m[1],
65
+ );
66
+ expect(new Set(exportedNames).size).toBe(exportedNames.length);
67
+ });
68
+ });
69
+
70
+ describe("sanitizeClassOrNamespaceName", () => {
71
+ test("suffixes a resource named provider_functions", () => {
72
+ expect(sanitizeClassOrNamespaceName("provider_functions")).toBe(
73
+ "provider_functions_resource",
74
+ );
75
+ });
76
+
77
+ test("still suffixes a resource named provider", () => {
78
+ expect(sanitizeClassOrNamespaceName("provider")).toBe("provider_resource");
79
+ });
80
+
81
+ test("leaves an unrelated resource name alone", () => {
82
+ expect(sanitizeClassOrNamespaceName("function_app")).toBe("function_app");
83
+ });
84
+ });
@@ -33,7 +33,8 @@ describe("versions.json file generation", () => {
33
33
  fqn: "kreuzwerker/docker",
34
34
  name: "docker",
35
35
  source: "kreuzwerker/docker",
36
- version: "2.16.0",
36
+ // OpenTofu rejects everything below 3.7.0 as unsigned; see #440.
37
+ version: "3.9.0",
37
38
  },
38
39
  ];
39
40
 
@@ -62,10 +63,14 @@ describe("versions.json file generation", () => {
62
63
  path.join(workdir, "versions.json"),
63
64
  "utf-8",
64
65
  );
65
- expect(Object.keys(JSON.parse(output))).toEqual(
66
- expect.arrayContaining(
67
- constraints.map((c) => `registry.terraform.io/${c.fqn}`),
68
- ),
66
+ // versions.json is keyed by fully qualified name, and the host is
67
+ // whichever registry the fetching CLI used - terraform's or tofu's - so
68
+ // assert on the provider part rather than pinning one registry.
69
+ const keys = Object.keys(JSON.parse(output)).map((k) =>
70
+ k.split("/").slice(1).join("/"),
71
+ );
72
+ expect(keys).toEqual(
73
+ expect.arrayContaining(constraints.map((c) => c.fqn)),
69
74
  );
70
75
  },
71
76
  );
@@ -315,6 +315,8 @@ export class ConstructsMaker {
315
315
  private readonly codeMakerOutdir: string;
316
316
  private readonly code: CodeMaker;
317
317
  private versions: { [providerName: string]: string | undefined };
318
+ /** Resource directory names emitted per provider folder during this run */
319
+ public emittedResourceFolders: { [providerName: string]: string[] };
318
320
 
319
321
  constructor(
320
322
  private readonly options: GetOptions,
@@ -328,6 +330,7 @@ export class ConstructsMaker {
328
330
  fs.mkdirpSync(this.codeMakerOutdir);
329
331
  this.code = new CodeMaker();
330
332
  this.versions = {};
333
+ this.emittedResourceFolders = {};
331
334
  }
332
335
  private async generateTypescriptProvider(
333
336
  target: ConstructsMakerProviderTarget,
@@ -336,10 +339,15 @@ export class ConstructsMaker {
336
339
  const endTSTimer = logTimespan(`Generate Typescript for ${target.name}`);
337
340
  const generator = new TerraformProviderGenerator(this.code, schema, {
338
341
  importExtension: this.options.languageOptions?.importExtension,
342
+ targetVersions: this.options.targetVersions,
339
343
  });
340
344
  generator.generate(target);
341
345
 
342
346
  this.versions = { ...this.versions, ...generator.versions };
347
+ this.emittedResourceFolders = {
348
+ ...this.emittedResourceFolders,
349
+ ...generator.emittedResourceFolders,
350
+ };
343
351
  endTSTimer();
344
352
  }
345
353
 
@@ -595,6 +603,62 @@ export class ConstructsMaker {
595
603
  });
596
604
  }
597
605
 
606
+ /**
607
+ * Delete resource directories under `providers/` that this run did not emit, so each provider folder holds
608
+ * exactly the resources in the version it was generated against.
609
+ *
610
+ * Walks only providers present in `emittedResourceFolders`, keeping any provider this run skipped. Within those,
611
+ * removes every subdirectory not emitted and leaves provider-level files (`index.ts`, `lazy-index.ts`) in place.
612
+ * Deletes files, so it must run after `save()` has written the current run's output.
613
+ *
614
+ * TypeScript only: `providers/<name>/<resource>/` maps one-to-one onto the emitted index exports. Other languages
615
+ * co-locate providers and modules in the same folders, which makes a dropped resource indistinguishable from a
616
+ * hand-written construct.
617
+ */
618
+ public async removeOrphanedResourceFolders() {
619
+ if (!this.isJavascriptTarget) {
620
+ return;
621
+ }
622
+
623
+ for (const [provider, emittedFolders] of Object.entries(
624
+ this.emittedResourceFolders,
625
+ )) {
626
+ const providerFolder = path.resolve(
627
+ this.codeMakerOutdir,
628
+ "providers",
629
+ provider,
630
+ );
631
+
632
+ let entries: string[] = [];
633
+ try {
634
+ entries = await fs.readdir(providerFolder);
635
+ } catch (e) {
636
+ logger.debug(
637
+ `Could not list resource folders in '${providerFolder}': ${e}`,
638
+ );
639
+ continue;
640
+ }
641
+
642
+ const expected = new Set(emittedFolders);
643
+ for (const entry of entries) {
644
+ if (expected.has(entry)) {
645
+ continue;
646
+ }
647
+
648
+ const entryPath = path.resolve(providerFolder, entry);
649
+ if (!fs.statSync(entryPath).isDirectory()) {
650
+ // Leave index.ts / lazy-index.ts and any other provider-level files alone.
651
+ continue;
652
+ }
653
+
654
+ logger.debug(
655
+ `Removing orphaned resource folder '${entry}' from provider '${provider}': it was not emitted for the current provider version`,
656
+ );
657
+ await fs.remove(entryPath);
658
+ }
659
+ }
660
+ }
661
+
598
662
  // emits a constraints.json file with a map of the used provider fqpns and version constraints
599
663
  // this is used for caching purposes
600
664
  private emitConstraintsFile(
@@ -757,6 +821,7 @@ a NODE_OPTIONS variable, we won't override it. Hence, the provider generation mi
757
821
 
758
822
  if (this.isJavascriptTarget) {
759
823
  await this.save();
824
+ await this.removeOrphanedResourceFolders();
760
825
  }
761
826
 
762
827
  if (!this.isJavascriptTarget || this.options.outputJsii) {
@@ -61,13 +61,13 @@ export class StructEmitter {
61
61
  comment.line(att.description);
62
62
  comment.line(``);
63
63
  comment.line(
64
- `Docs at Terraform Registry: {@link ${resource.linkToDocs}#${att.terraformName} ${resource.className}#${att.terraformName}}`,
64
+ `Docs at ${resource.docsRegistryName}: {@link ${resource.linkToDocs}#${att.terraformName} ${resource.className}#${att.terraformName}}`,
65
65
  );
66
66
  this.warnAboutIdField(att);
67
67
  comment.end();
68
68
  } else {
69
69
  comment.line(
70
- `Docs at Terraform Registry: {@link ${resource.linkToDocs}#${att.terraformName} ${resource.className}#${att.terraformName}}`,
70
+ `Docs at ${resource.docsRegistryName}: {@link ${resource.linkToDocs}#${att.terraformName} ${resource.className}#${att.terraformName}}`,
71
71
  );
72
72
  this.warnAboutIdField(att);
73
73
  comment.end();
@@ -215,8 +215,7 @@ export interface CollectionAttributeTypeModel extends AttributeTypeModel {
215
215
  // into the nearest typed "Any"-wrapper. undefined only defensively - see
216
216
  // resolveStoredClassName in supported-stored-classes.ts.
217
217
  readonly resolvedStoredClass:
218
- | { name: string; collapsed: boolean }
219
- | undefined;
218
+ { name: string; collapsed: boolean } | undefined;
220
219
  }
221
220
 
222
221
  export class ListAttributeTypeModel implements CollectionAttributeTypeModel {
@@ -5,7 +5,11 @@ import path from "path";
5
5
  import { FQPN, parseFQPN, ProviderName } from "@cdktn/provider-schema";
6
6
  import { AttributeModel } from "./attribute-model";
7
7
  import { Struct, ConfigStruct } from "./struct";
8
- import { Schema } from "@cdktn/commons";
8
+ import {
9
+ Schema,
10
+ TerraformTargetVersions,
11
+ registryForTargetVersions,
12
+ } from "@cdktn/commons";
9
13
  import { ProviderFunctionsModel } from "./provider-function-model";
10
14
 
11
15
  // Limit is 1200 to prevent stack size error.
@@ -25,6 +29,7 @@ interface ResourceModelOptions {
25
29
  schema: Schema;
26
30
  terraformSchemaType: string;
27
31
  configStructName: string;
32
+ targetVersions?: TerraformTargetVersions;
28
33
  }
29
34
 
30
35
  type DefinitionPath = string;
@@ -35,6 +40,7 @@ export class ResourceModel {
35
40
  public baseName: string;
36
41
  public provider: ProviderName;
37
42
  public fqpn: FQPN;
43
+ private readonly targetVersions?: TerraformTargetVersions;
38
44
  public providerVersionConstraint?: string;
39
45
  public providerVersion?: string;
40
46
  public terraformProviderSource?: string;
@@ -68,6 +74,7 @@ export class ResourceModel {
68
74
  this.attributes = options.attributes;
69
75
  this.schema = options.schema;
70
76
  this.fqpn = options.fqpn;
77
+ this.targetVersions = options.targetVersions;
71
78
  this.provider = parseFQPN(options.fqpn).name;
72
79
  this.terraformProviderName = this.provider;
73
80
  this.fileName = options.fileName;
@@ -108,16 +115,32 @@ export class ResourceModel {
108
115
  return this.escapeSchema(JSON.stringify(this.schema, undefined, 2));
109
116
  }
110
117
 
118
+ /** Registry that linkToDocs points at, for use in the surrounding prose. */
119
+ public get docsRegistryName(): string {
120
+ return registryForTargetVersions(this.targetVersions).displayName;
121
+ }
122
+
111
123
  public get linkToDocs(): string {
112
- const { hostname, namespace, name } = parseFQPN(this.fqpn);
124
+ // Keyed off the project's declared targets, not the FQPN hostname: that
125
+ // hostname reflects whichever CLI fetched the schema, which would make the
126
+ // generated link differ between a terraform and a tofu `cdktn get`.
127
+ const registry = registryForTargetVersions(this.targetVersions);
128
+ const { namespace, name } = parseFQPN(this.fqpn);
113
129
  const version = this.providerVersion || "latest";
114
- const base = `https://${hostname}/providers/${namespace}/${name}/${version}/docs`;
115
- if (this.isProvider) return base;
116
- if (this.isDataSource)
117
- return `${base}/data-sources/${this.terraformDocName}`;
118
- if (this.isEphemeralResource)
119
- return `${base}/ephemeral-resources/${this.terraformDocName}`;
120
- return `${base}/resources/${this.terraformDocName}`;
130
+ const kind = this.isProvider
131
+ ? "provider"
132
+ : this.isDataSource
133
+ ? "data-source"
134
+ : this.isEphemeralResource
135
+ ? "ephemeral-resource"
136
+ : "resource";
137
+ return registry.docsUrl(
138
+ namespace,
139
+ name,
140
+ version,
141
+ kind,
142
+ this.terraformDocName,
143
+ );
121
144
  }
122
145
 
123
146
  public get isProvider(): boolean {
@@ -8,6 +8,7 @@ import {
8
8
  logger,
9
9
  ProviderSchema,
10
10
  TerraformProviderConstraint,
11
+ TerraformTargetVersions,
11
12
  } from "@cdktn/commons";
12
13
  import { FQPN, parseFQPN, ProviderName } from "@cdktn/provider-schema";
13
14
  import {
@@ -29,6 +30,8 @@ export interface TerraformProviderGeneratorOptions {
29
30
  * Defaults to "" (CommonJS).
30
31
  */
31
32
  readonly importExtension?: string;
33
+ /** Project's declared targetVersions; selects the docs registry. */
34
+ readonly targetVersions?: TerraformTargetVersions;
32
35
  }
33
36
 
34
37
  interface ProviderData {
@@ -107,12 +110,17 @@ export interface ProviderConstraints {
107
110
  }
108
111
 
109
112
  export class TerraformProviderGenerator {
110
- private resourceParser = new ResourceParser();
113
+ private resourceParser: ResourceParser;
111
114
  private resourceEmitter: ResourceEmitter;
112
115
  private structEmitter: StructEmitter;
113
116
  private providerFunctionsEmitter: ProviderFunctionsEmitter;
114
117
  private readonly importExtension: string;
115
118
  public versions: { [fqpn: string]: string | undefined } = {};
119
+ /**
120
+ * Resource directory names emitted per provider folder Used to prune directories
121
+ * left over from a previous provider version.
122
+ */
123
+ public emittedResourceFolders: { [provider: string]: string[] } = {};
116
124
 
117
125
  constructor(
118
126
  private readonly code: CodeMaker,
@@ -121,6 +129,7 @@ export class TerraformProviderGenerator {
121
129
  ) {
122
130
  this.code.indentation = 2;
123
131
  this.importExtension = options.importExtension ?? "";
132
+ this.resourceParser = new ResourceParser(options.targetVersions);
124
133
  this.resourceEmitter = new ResourceEmitter(this.code, this.importExtension);
125
134
  this.structEmitter = new StructEmitter(this.code, this.importExtension);
126
135
  this.providerFunctionsEmitter = new ProviderFunctionsEmitter(this.code);
@@ -298,6 +307,20 @@ export class TerraformProviderGenerator {
298
307
 
299
308
  this.emitIndexFile(name, files);
300
309
  this.emitLazyIndexFile(name, files);
310
+
311
+ // Record exactly what this run emitted, derived the same way the index
312
+ // files derive their exports, so pruning can never disagree with them.
313
+ this.emittedResourceFolders[name] = files.map((file) =>
314
+ this.resourceFolderName(name, file),
315
+ );
316
+ }
317
+
318
+ /**
319
+ * Map an emitted file path to the resource directory name it lives in, e.g.
320
+ * `providers/mongodbatlas/teams/index.ts` -> `teams`.
321
+ */
322
+ private resourceFolderName(provider: ProviderName, file: string): string {
323
+ return file.replace(`providers/${provider}/`, "").replace("/index.ts", "");
301
324
  }
302
325
 
303
326
  private emitProviderFunctions(
@@ -322,7 +345,7 @@ export class TerraformProviderGenerator {
322
345
  ? resource.provider
323
346
  : resource.terraformType;
324
347
  this.code.line(
325
- `Refer to the Terraform Registry for docs: [\`${type}\`](${resource.linkToDocs}).`,
348
+ `Refer to the ${resource.docsRegistryName} for docs: [\`${type}\`](${resource.linkToDocs}).`,
326
349
  );
327
350
  this.code.closeFile(filePath);
328
351
  }
@@ -333,7 +356,7 @@ export class TerraformProviderGenerator {
333
356
  this.code.openFile(filePath);
334
357
  this.code.line("// generated by cdktn get");
335
358
  for (const file of files) {
336
- const dirName = file.replace(`${folder}/`, "").replace("/index.ts", "");
359
+ const dirName = this.resourceFolderName(provider, file);
337
360
  this.code.line(
338
361
  `export * as ${toCamelCase(dirName)} from './${dirName}/index${this.importExtension}';`,
339
362
  );
@@ -348,7 +371,7 @@ export class TerraformProviderGenerator {
348
371
  this.code.openFile(filePath);
349
372
  this.code.line("// generated by cdktn get");
350
373
  for (const file of files) {
351
- const dirName = file.replace(`${folder}/`, "").replace("/index.ts", "");
374
+ const dirName = this.resourceFolderName(provider, file);
352
375
  this.code.line(
353
376
  `Object.defineProperty(exports, '${toCamelCase(
354
377
  dirName,