@gooddata/create-pluggable-module 11.40.0-alpha.3
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.
- package/README.md +59 -0
- package/dist/templates/harness/.env.template +9 -0
- package/dist/templates/harness/.gitignore +10 -0
- package/dist/templates/harness/README.md +45 -0
- package/dist/templates/harness/eslint.config.ts +3 -0
- package/dist/templates/harness/index.html +13 -0
- package/dist/templates/harness/package.json +70 -0
- package/dist/templates/harness/src/app.tsx +81 -0
- package/dist/templates/harness/tsconfig.json +70 -0
- package/dist/templates/harness/typings.d.ts +13 -0
- package/dist/templates/harness/vite.config.ts +111 -0
- package/dist/templates/module/.gitignore +11 -0
- package/dist/templates/module/README.md +35 -0
- package/dist/templates/module/eslint.config.ts +3 -0
- package/dist/templates/module/package.json +84 -0
- package/dist/templates/module/src/App.test.tsx +53 -0
- package/dist/templates/module/src/App.tsx +34 -0
- package/dist/templates/module/src/AppProviders.tsx +26 -0
- package/dist/templates/module/src/index.ts +3 -0
- package/dist/templates/module/src/pluggableApp.tsx +56 -0
- package/dist/templates/module/src/translations/de-DE.json +6 -0
- package/dist/templates/module/src/translations/en-AU.json +6 -0
- package/dist/templates/module/src/translations/en-GB.json +6 -0
- package/dist/templates/module/src/translations/en-US.json +6 -0
- package/dist/templates/module/src/translations/es-419.json +6 -0
- package/dist/templates/module/src/translations/es-ES.json +6 -0
- package/dist/templates/module/src/translations/fi-FI.json +6 -0
- package/dist/templates/module/src/translations/fr-CA.json +6 -0
- package/dist/templates/module/src/translations/fr-FR.json +6 -0
- package/dist/templates/module/src/translations/id-ID.json +6 -0
- package/dist/templates/module/src/translations/it-IT.json +6 -0
- package/dist/templates/module/src/translations/ja-JP.json +6 -0
- package/dist/templates/module/src/translations/ko-KR.json +6 -0
- package/dist/templates/module/src/translations/nl-NL.json +6 -0
- package/dist/templates/module/src/translations/pl-PL.json +6 -0
- package/dist/templates/module/src/translations/pt-BR.json +6 -0
- package/dist/templates/module/src/translations/pt-PT.json +6 -0
- package/dist/templates/module/src/translations/ru-RU.json +6 -0
- package/dist/templates/module/src/translations/sl-SI.json +6 -0
- package/dist/templates/module/src/translations/th-TH.json +6 -0
- package/dist/templates/module/src/translations/tr-TR.json +6 -0
- package/dist/templates/module/src/translations/translations.ts +71 -0
- package/dist/templates/module/src/translations/uk-UA.json +6 -0
- package/dist/templates/module/src/translations/vi-VN.json +6 -0
- package/dist/templates/module/src/translations/zh-HK.json +6 -0
- package/dist/templates/module/src/translations/zh-Hans.json +6 -0
- package/dist/templates/module/src/translations/zh-Hant.json +6 -0
- package/dist/templates/module/tsconfig.json +65 -0
- package/dist/templates/module/vite.config.ts +140 -0
- package/dist/templates/module/vitest.config.ts +12 -0
- package/esm/engine/__tests__/anchors.test.d.ts +1 -0
- package/esm/engine/__tests__/anchors.test.js +44 -0
- package/esm/engine/__tests__/copyTemplate.test.d.ts +1 -0
- package/esm/engine/__tests__/copyTemplate.test.js +59 -0
- package/esm/engine/__tests__/derived.test.d.ts +1 -0
- package/esm/engine/__tests__/derived.test.js +30 -0
- package/esm/engine/__tests__/jsonAnchors.test.d.ts +1 -0
- package/esm/engine/__tests__/jsonAnchors.test.js +52 -0
- package/esm/engine/__tests__/repoDetection.test.d.ts +1 -0
- package/esm/engine/__tests__/repoDetection.test.js +63 -0
- package/esm/engine/__tests__/scrubPackageJsonVersions.d.ts +1 -0
- package/esm/engine/__tests__/scrubPackageJsonVersions.js +34 -0
- package/esm/engine/__tests__/scrubPackageJsonVersions.test.d.ts +1 -0
- package/esm/engine/__tests__/scrubPackageJsonVersions.test.js +79 -0
- package/esm/engine/__tests__/snapshot.test.d.ts +1 -0
- package/esm/engine/__tests__/snapshot.test.js +93 -0
- package/esm/engine/anchors.d.ts +17 -0
- package/esm/engine/anchors.js +43 -0
- package/esm/engine/checkAnchors.d.ts +12 -0
- package/esm/engine/checkAnchors.js +44 -0
- package/esm/engine/copyTemplate.d.ts +11 -0
- package/esm/engine/copyTemplate.js +70 -0
- package/esm/engine/derived.d.ts +32 -0
- package/esm/engine/derived.js +85 -0
- package/esm/engine/jsonAnchors.d.ts +13 -0
- package/esm/engine/jsonAnchors.js +42 -0
- package/esm/engine/prompts.d.ts +15 -0
- package/esm/engine/prompts.js +157 -0
- package/esm/engine/repoDetection.d.ts +18 -0
- package/esm/engine/repoDetection.js +47 -0
- package/esm/engine/runProfile.d.ts +18 -0
- package/esm/engine/runProfile.js +179 -0
- package/esm/engine/spawn.d.ts +13 -0
- package/esm/engine/spawn.js +30 -0
- package/esm/index.d.ts +10 -0
- package/esm/index.js +62 -0
- package/esm/profiles/__tests__/client.test.d.ts +1 -0
- package/esm/profiles/__tests__/client.test.js +151 -0
- package/esm/profiles/client.d.ts +2 -0
- package/esm/profiles/client.js +208 -0
- package/esm/types.d.ts +194 -0
- package/esm/types.js +2 -0
- package/package.json +77 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
|
|
3
|
+
import { defineConfig } from "vitest/config";
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line no-restricted-exports
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
test: {
|
|
8
|
+
environment: "happy-dom",
|
|
9
|
+
include: ["src/**/*.test.{ts,tsx}"],
|
|
10
|
+
globals: true,
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { findAnchorLineIdx, fullAnchor, insertBeforeAnchor } from "../anchors.js";
|
|
4
|
+
describe("fullAnchor", () => {
|
|
5
|
+
it("returns the bare keyword when no suffix is given", () => {
|
|
6
|
+
expect(fullAnchor()).toBe("PLUGGABLE_APP_SCAFFOLD_ANCHOR");
|
|
7
|
+
});
|
|
8
|
+
it("appends the parenthesised suffix", () => {
|
|
9
|
+
expect(fullAnchor("(stages)")).toBe("PLUGGABLE_APP_SCAFFOLD_ANCHOR (stages)");
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
describe("findAnchorLineIdx", () => {
|
|
13
|
+
it("returns the 0-based line index of the anchor", () => {
|
|
14
|
+
const lines = ["{", " // some comment", " // PLUGGABLE_APP_SCAFFOLD_ANCHOR", "}"];
|
|
15
|
+
expect(findAnchorLineIdx(lines, "test.json")).toBe(2);
|
|
16
|
+
});
|
|
17
|
+
it("disambiguates by suffix when multiple anchors are present", () => {
|
|
18
|
+
const lines = [
|
|
19
|
+
"// PLUGGABLE_APP_SCAFFOLD_ANCHOR (stages)",
|
|
20
|
+
"// PLUGGABLE_APP_SCAFFOLD_ANCHOR (remotes)",
|
|
21
|
+
];
|
|
22
|
+
expect(findAnchorLineIdx(lines, "Dockerfile", "(stages)")).toBe(0);
|
|
23
|
+
expect(findAnchorLineIdx(lines, "Dockerfile", "(remotes)")).toBe(1);
|
|
24
|
+
});
|
|
25
|
+
it("throws when the anchor is missing", () => {
|
|
26
|
+
expect(() => findAnchorLineIdx(["foo", "bar"], "test.json")).toThrow(/Could not find "PLUGGABLE_APP_SCAFFOLD_ANCHOR" in test\.json/);
|
|
27
|
+
});
|
|
28
|
+
it("throws when the same anchor appears more than once", () => {
|
|
29
|
+
const lines = ["// PLUGGABLE_APP_SCAFFOLD_ANCHOR", "// PLUGGABLE_APP_SCAFFOLD_ANCHOR"];
|
|
30
|
+
expect(() => findAnchorLineIdx(lines, "test.json")).toThrow(/Found 2 lines matching/);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("insertBeforeAnchor", () => {
|
|
34
|
+
it("inserts the lines immediately before the anchor line", () => {
|
|
35
|
+
const content = "line A\n// PLUGGABLE_APP_SCAFFOLD_ANCHOR\nline B";
|
|
36
|
+
const result = insertBeforeAnchor(content, "test.json", ["INSERTED 1", "INSERTED 2"]);
|
|
37
|
+
expect(result).toBe("line A\nINSERTED 1\nINSERTED 2\n// PLUGGABLE_APP_SCAFFOLD_ANCHOR\nline B");
|
|
38
|
+
});
|
|
39
|
+
it("preserves trailing newline behaviour from split/join roundtrip", () => {
|
|
40
|
+
const content = "// PLUGGABLE_APP_SCAFFOLD_ANCHOR\n";
|
|
41
|
+
const result = insertBeforeAnchor(content, "test.json", ["X"]);
|
|
42
|
+
expect(result).toBe("X\n// PLUGGABLE_APP_SCAFFOLD_ANCHOR\n");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { copyTemplate } from "../copyTemplate.js";
|
|
7
|
+
describe("copyTemplate", () => {
|
|
8
|
+
let src;
|
|
9
|
+
let dest;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
src = mkdtempSync(join(tmpdir(), "scaff-copy-src-"));
|
|
12
|
+
dest = mkdtempSync(join(tmpdir(), "scaff-copy-dest-"));
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(src, { recursive: true, force: true });
|
|
16
|
+
rmSync(dest, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
it("copies files + applies token substitutions in deterministic order", () => {
|
|
19
|
+
writeFileSync(join(src, "a.txt"), "name=PLACEHOLDER value=PLACEHOLDER2");
|
|
20
|
+
const { written } = copyTemplate(src, dest, [
|
|
21
|
+
["PLACEHOLDER2", "second"],
|
|
22
|
+
["PLACEHOLDER", "first"],
|
|
23
|
+
]);
|
|
24
|
+
expect(written).toHaveLength(1);
|
|
25
|
+
expect(readFileSync(join(dest, "a.txt"), "utf-8")).toBe("name=first value=second");
|
|
26
|
+
});
|
|
27
|
+
it("recurses into subdirectories", () => {
|
|
28
|
+
mkdirSync(join(src, "sub"));
|
|
29
|
+
writeFileSync(join(src, "sub", "nested.txt"), "hi TOKEN");
|
|
30
|
+
copyTemplate(src, dest, [["TOKEN", "world"]]);
|
|
31
|
+
expect(readFileSync(join(dest, "sub", "nested.txt"), "utf-8")).toBe("hi world");
|
|
32
|
+
});
|
|
33
|
+
it("skips directories named in SKIP_DIRS (e.g. node_modules)", () => {
|
|
34
|
+
mkdirSync(join(src, "node_modules"));
|
|
35
|
+
writeFileSync(join(src, "node_modules", "junk.txt"), "should not be copied");
|
|
36
|
+
writeFileSync(join(src, "real.txt"), "real");
|
|
37
|
+
copyTemplate(src, dest, []);
|
|
38
|
+
// node_modules subdir should not be created in dest
|
|
39
|
+
expect(() => readFileSync(join(dest, "node_modules", "junk.txt"))).toThrow();
|
|
40
|
+
expect(readFileSync(join(dest, "real.txt"), "utf-8")).toBe("real");
|
|
41
|
+
});
|
|
42
|
+
it("substitutes only in text files — binaries copied bytewise", () => {
|
|
43
|
+
// .png is in BINARY_EXTENSIONS; the bytes shouldn't be touched even if they happen
|
|
44
|
+
// to contain a token-looking string.
|
|
45
|
+
const binaryContent = Buffer.from("PNGHEADER\x00TOKENISH\xff");
|
|
46
|
+
writeFileSync(join(src, "image.png"), binaryContent);
|
|
47
|
+
copyTemplate(src, dest, [["TOKENISH", "REPLACED"]]);
|
|
48
|
+
const copied = readFileSync(join(dest, "image.png"));
|
|
49
|
+
expect(copied.equals(binaryContent)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it("token substitution ORDER matters — longer-first prevents partial overlap", () => {
|
|
52
|
+
writeFileSync(join(src, "a.txt"), "foo-bar foo");
|
|
53
|
+
const { written: _w } = copyTemplate(src, dest, [
|
|
54
|
+
["foo-bar", "LONG"],
|
|
55
|
+
["foo", "SHORT"],
|
|
56
|
+
]);
|
|
57
|
+
expect(readFileSync(join(dest, "a.txt"), "utf-8")).toBe("LONG SHORT");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { computeFederationName, computeRemoteUrlEnvVar, computeRoute } from "../derived.js";
|
|
4
|
+
describe("computeRoute", () => {
|
|
5
|
+
it("strips the gdc- prefix and prepends a slash", () => {
|
|
6
|
+
expect(computeRoute("gdc-foo")).toBe("/foo");
|
|
7
|
+
});
|
|
8
|
+
it("preserves internal hyphens", () => {
|
|
9
|
+
expect(computeRoute("gdc-foo-bar-baz")).toBe("/foo-bar-baz");
|
|
10
|
+
});
|
|
11
|
+
it("handles single-segment names", () => {
|
|
12
|
+
expect(computeRoute("gdc-x")).toBe("/x");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe("computeFederationName", () => {
|
|
16
|
+
it("converts hyphens to underscores so the result is a valid JS identifier", () => {
|
|
17
|
+
expect(computeFederationName("gdc-foo-bar")).toBe("gdc_foo_bar");
|
|
18
|
+
});
|
|
19
|
+
it("preserves the gdc_ prefix (no leading-prefix stripping here)", () => {
|
|
20
|
+
expect(computeFederationName("gdc-x")).toBe("gdc_x");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe("computeRemoteUrlEnvVar", () => {
|
|
24
|
+
it("strips gdc-, uppercases, and adds the _REMOTE_URL suffix", () => {
|
|
25
|
+
expect(computeRemoteUrlEnvVar("gdc-foo")).toBe("FOO_REMOTE_URL");
|
|
26
|
+
});
|
|
27
|
+
it("converts internal hyphens to underscores", () => {
|
|
28
|
+
expect(computeRemoteUrlEnvVar("gdc-foo-bar")).toBe("FOO_BAR_REMOTE_URL");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { ensureTrailingCommaBeforeAnchor } from "../jsonAnchors.js";
|
|
4
|
+
describe("ensureTrailingCommaBeforeAnchor", () => {
|
|
5
|
+
it("adds a comma to a code line that lacks one", () => {
|
|
6
|
+
const input = `[
|
|
7
|
+
{ "a": 1 }
|
|
8
|
+
// PLUGGABLE_APP_SCAFFOLD_ANCHOR
|
|
9
|
+
]`;
|
|
10
|
+
const output = ensureTrailingCommaBeforeAnchor(input);
|
|
11
|
+
expect(output).toContain('{ "a": 1 },');
|
|
12
|
+
});
|
|
13
|
+
it("is idempotent when the comma is already present", () => {
|
|
14
|
+
const input = `[
|
|
15
|
+
{ "a": 1 },
|
|
16
|
+
// PLUGGABLE_APP_SCAFFOLD_ANCHOR
|
|
17
|
+
]`;
|
|
18
|
+
expect(ensureTrailingCommaBeforeAnchor(input)).toBe(input);
|
|
19
|
+
});
|
|
20
|
+
it("inserts the comma before an inline // comment, preserving the comment position", () => {
|
|
21
|
+
const input = `[
|
|
22
|
+
{ "a": 1 } // last entry
|
|
23
|
+
// PLUGGABLE_APP_SCAFFOLD_ANCHOR
|
|
24
|
+
]`;
|
|
25
|
+
const output = ensureTrailingCommaBeforeAnchor(input);
|
|
26
|
+
const lines = output.split("\n");
|
|
27
|
+
expect(lines[1]).toBe(' { "a": 1 }, // last entry');
|
|
28
|
+
});
|
|
29
|
+
it("skips blank lines and pure-comment lines when looking back for the last code line", () => {
|
|
30
|
+
const input = `[
|
|
31
|
+
{ "a": 1 }
|
|
32
|
+
|
|
33
|
+
// a separator comment
|
|
34
|
+
// PLUGGABLE_APP_SCAFFOLD_ANCHOR
|
|
35
|
+
]`;
|
|
36
|
+
const output = ensureTrailingCommaBeforeAnchor(input);
|
|
37
|
+
expect(output).toContain('{ "a": 1 },');
|
|
38
|
+
});
|
|
39
|
+
it("honours the disambiguating suffix", () => {
|
|
40
|
+
const input = `[
|
|
41
|
+
{ "a": 1 }
|
|
42
|
+
// PLUGGABLE_APP_SCAFFOLD_ANCHOR (alpha)
|
|
43
|
+
{ "b": 2 }
|
|
44
|
+
// PLUGGABLE_APP_SCAFFOLD_ANCHOR (beta)
|
|
45
|
+
]`;
|
|
46
|
+
const output = ensureTrailingCommaBeforeAnchor(input, "(beta)");
|
|
47
|
+
// Only the (beta) anchor's preceding line gets the comma added.
|
|
48
|
+
const lines = output.split("\n");
|
|
49
|
+
expect(lines.find((l) => l.includes('"a": 1'))).toBe(' { "a": 1 }');
|
|
50
|
+
expect(lines.find((l) => l.includes('"b": 2'))).toBe(' { "b": 2 },');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { detectRepo, findUp } from "../repoDetection.js";
|
|
7
|
+
describe("findUp", () => {
|
|
8
|
+
let root;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
root = mkdtempSync(join(tmpdir(), "scaff-findup-"));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
rmSync(root, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
it("returns the directory containing the marker when present at cwd", () => {
|
|
16
|
+
writeFileSync(join(root, "marker.txt"), "");
|
|
17
|
+
expect(findUp(root, "marker.txt")).toBe(root);
|
|
18
|
+
});
|
|
19
|
+
it("walks upward and finds the marker in an ancestor", () => {
|
|
20
|
+
writeFileSync(join(root, "marker.txt"), "");
|
|
21
|
+
mkdirSync(join(root, "a", "b"), { recursive: true });
|
|
22
|
+
expect(findUp(join(root, "a", "b"), "marker.txt")).toBe(root);
|
|
23
|
+
});
|
|
24
|
+
it("returns undefined when no ancestor has the marker", () => {
|
|
25
|
+
// unlikely but possible: tmpdir's parents won't have our marker
|
|
26
|
+
expect(findUp(root, "definitely-not-here-marker.json")).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe("detectRepo", () => {
|
|
30
|
+
let root;
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
root = mkdtempSync(join(tmpdir(), "scaff-detect-"));
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
rmSync(root, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
it("reports rush when rush.json is present", () => {
|
|
38
|
+
writeFileSync(join(root, "rush.json"), "{}");
|
|
39
|
+
const repo = detectRepo(root);
|
|
40
|
+
expect(repo.packageManager).toBe("rush");
|
|
41
|
+
expect(repo.repoRoot).toBe(root);
|
|
42
|
+
});
|
|
43
|
+
it("detects pnpm via pnpm-lock.yaml", () => {
|
|
44
|
+
writeFileSync(join(root, "pnpm-lock.yaml"), "");
|
|
45
|
+
const repo = detectRepo(root);
|
|
46
|
+
expect(repo.packageManager).toBe("pnpm");
|
|
47
|
+
});
|
|
48
|
+
it("detects yarn via yarn.lock", () => {
|
|
49
|
+
writeFileSync(join(root, "yarn.lock"), "");
|
|
50
|
+
const repo = detectRepo(root);
|
|
51
|
+
expect(repo.packageManager).toBe("yarn");
|
|
52
|
+
});
|
|
53
|
+
it("falls back to npm when no lockfile is present", () => {
|
|
54
|
+
const repo = detectRepo(root);
|
|
55
|
+
expect(repo.packageManager).toBe("npm");
|
|
56
|
+
});
|
|
57
|
+
it("prefers rush over other lockfiles", () => {
|
|
58
|
+
writeFileSync(join(root, "rush.json"), "{}");
|
|
59
|
+
writeFileSync(join(root, "pnpm-lock.yaml"), "");
|
|
60
|
+
const repo = detectRepo(root);
|
|
61
|
+
expect(repo.packageManager).toBe("rush");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function scrubPackageJsonVersions(content: string): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
/**
|
|
3
|
+
* Replace dep/SDK version strings in a scaffolded package.json with the
|
|
4
|
+
* placeholder `<VERSION>`. Walks the JSON tree (rather than regex-matching
|
|
5
|
+
* raw text) so it only touches `version` and values inside the dep maps —
|
|
6
|
+
* `description`, URLs, `engines`, etc. stay verbatim.
|
|
7
|
+
*
|
|
8
|
+
* Re-serializes with the same 4-space indent + trailing newline the scaffolder
|
|
9
|
+
* itself writes (runProfile.ts), so the output is byte-identical to the
|
|
10
|
+
* scaffolded file except for the scrubbed slots.
|
|
11
|
+
*
|
|
12
|
+
* Lives outside `*.test.ts` so vitest's auto-discovery doesn't re-run callers
|
|
13
|
+
* as duplicate tests; both `snapshot.test.ts` and `scrubPackageJsonVersions.test.ts`
|
|
14
|
+
* import it.
|
|
15
|
+
*/
|
|
16
|
+
const DEP_MAPS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
|
|
17
|
+
export function scrubPackageJsonVersions(content) {
|
|
18
|
+
const pkg = JSON.parse(content);
|
|
19
|
+
if (typeof pkg["version"] === "string") {
|
|
20
|
+
pkg["version"] = "<VERSION>";
|
|
21
|
+
}
|
|
22
|
+
for (const key of DEP_MAPS) {
|
|
23
|
+
const deps = pkg[key];
|
|
24
|
+
if (deps && typeof deps === "object" && !Array.isArray(deps)) {
|
|
25
|
+
const depsRecord = deps;
|
|
26
|
+
for (const dep of Object.keys(depsRecord)) {
|
|
27
|
+
if (typeof depsRecord[dep] === "string") {
|
|
28
|
+
depsRecord[dep] = "<VERSION>";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify(pkg, null, 4) + "\n";
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { scrubPackageJsonVersions } from "./scrubPackageJsonVersions.js";
|
|
4
|
+
/**
|
|
5
|
+
* Direct tests for the package.json version-scrubber used by the snapshot test.
|
|
6
|
+
* The scrubber's contract:
|
|
7
|
+
* 1. Replaces top-level `version` with "<VERSION>".
|
|
8
|
+
* 2. Replaces every string value inside the four dep maps with "<VERSION>".
|
|
9
|
+
* 3. Leaves everything else alone — descriptions, URLs, engines, repository,
|
|
10
|
+
* author, etc. — even when those strings happen to contain an X.Y.Z
|
|
11
|
+
* substring.
|
|
12
|
+
*
|
|
13
|
+
* Without these tests the scrubber's correctness is only inferred from the
|
|
14
|
+
* full snapshot, which would also pass if the function quietly over-matched.
|
|
15
|
+
*/
|
|
16
|
+
describe("scrubPackageJsonVersions", () => {
|
|
17
|
+
function scrubAndParse(pkg) {
|
|
18
|
+
return JSON.parse(scrubPackageJsonVersions(JSON.stringify(pkg, null, 4) + "\n"));
|
|
19
|
+
}
|
|
20
|
+
it("scrubs the top-level version field", () => {
|
|
21
|
+
const out = scrubAndParse({ name: "x", version: "1.2.3-alpha.0" });
|
|
22
|
+
expect(out["version"]).toBe("<VERSION>");
|
|
23
|
+
});
|
|
24
|
+
it("scrubs every entry in dependencies, devDependencies, peerDependencies, optionalDependencies", () => {
|
|
25
|
+
const out = scrubAndParse({
|
|
26
|
+
dependencies: { react: "19.1.1", "@gooddata/sdk-ui": "workspace:*" },
|
|
27
|
+
devDependencies: { vite: "^8.0.0" },
|
|
28
|
+
peerDependencies: { typescript: ">=5.0.0" },
|
|
29
|
+
optionalDependencies: { fsevents: "2.3.3" },
|
|
30
|
+
});
|
|
31
|
+
expect(out["dependencies"]["react"]).toBe("<VERSION>");
|
|
32
|
+
expect(out["dependencies"]["@gooddata/sdk-ui"]).toBe("<VERSION>");
|
|
33
|
+
expect(out["devDependencies"]["vite"]).toBe("<VERSION>");
|
|
34
|
+
expect(out["peerDependencies"]["typescript"]).toBe("<VERSION>");
|
|
35
|
+
expect(out["optionalDependencies"]["fsevents"]).toBe("<VERSION>");
|
|
36
|
+
});
|
|
37
|
+
it("does NOT scrub a description containing an X.Y.Z substring (regression: this is what the old regex broke on)", () => {
|
|
38
|
+
const out = scrubAndParse({
|
|
39
|
+
name: "x",
|
|
40
|
+
description: "Works against the GoodData API v2.5.1 endpoint",
|
|
41
|
+
});
|
|
42
|
+
expect(out["description"]).toBe("Works against the GoodData API v2.5.1 endpoint");
|
|
43
|
+
});
|
|
44
|
+
it("does NOT scrub a URL containing a version-shaped path segment", () => {
|
|
45
|
+
const out = scrubAndParse({
|
|
46
|
+
name: "x",
|
|
47
|
+
homepage: "https://example.com/docs/v1.2.3/intro",
|
|
48
|
+
repository: { type: "git", url: "https://github.com/foo/bar/tree/v3.4.5" },
|
|
49
|
+
});
|
|
50
|
+
expect(out["homepage"]).toBe("https://example.com/docs/v1.2.3/intro");
|
|
51
|
+
expect(out["repository"]["url"]).toBe("https://github.com/foo/bar/tree/v3.4.5");
|
|
52
|
+
});
|
|
53
|
+
it("does NOT scrub engines (different drift cadence; informative to surface in snapshots)", () => {
|
|
54
|
+
const out = scrubAndParse({ engines: { node: ">=24.12.0" } });
|
|
55
|
+
expect(out["engines"]["node"]).toBe(">=24.12.0");
|
|
56
|
+
});
|
|
57
|
+
it("does NOT scrub author / name / license / type fields", () => {
|
|
58
|
+
const out = scrubAndParse({
|
|
59
|
+
name: "my-pkg",
|
|
60
|
+
author: "Acme",
|
|
61
|
+
license: "MIT",
|
|
62
|
+
type: "module",
|
|
63
|
+
});
|
|
64
|
+
expect(out["name"]).toBe("my-pkg");
|
|
65
|
+
expect(out["author"]).toBe("Acme");
|
|
66
|
+
expect(out["license"]).toBe("MIT");
|
|
67
|
+
expect(out["type"]).toBe("module");
|
|
68
|
+
});
|
|
69
|
+
it("survives missing / non-object dep maps without throwing", () => {
|
|
70
|
+
expect(() => scrubPackageJsonVersions(JSON.stringify({ name: "x" }))).not.toThrow();
|
|
71
|
+
// dep maps deliberately wrong shape — should not crash, just skip.
|
|
72
|
+
expect(() => scrubPackageJsonVersions(JSON.stringify({ name: "x", dependencies: null }))).not.toThrow();
|
|
73
|
+
});
|
|
74
|
+
it("emits 4-space indent + trailing newline (matches what the scaffolder itself writes)", () => {
|
|
75
|
+
const raw = scrubPackageJsonVersions(JSON.stringify({ name: "x", version: "1.0.0" }));
|
|
76
|
+
expect(raw.endsWith("\n")).toBe(true);
|
|
77
|
+
expect(raw).toContain('\n "name"');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "fs";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { dirname, join, relative } from "path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { clientProfile } from "../../profiles/client.js";
|
|
8
|
+
import { runProfileWithAnswers } from "../runProfile.js";
|
|
9
|
+
import { scrubPackageJsonVersions } from "./scrubPackageJsonVersions.js";
|
|
10
|
+
// Resolve workspace template paths via pnpm's symlinks. The template packages
|
|
11
|
+
// are devDependencies of @gooddata/create-pluggable-module — pnpm symlinks
|
|
12
|
+
// them into this package's node_modules in workspace mode. Probe target is
|
|
13
|
+
// `<pkg>/tsconfig.json` (template packages don't export `./package.json`).
|
|
14
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
15
|
+
const WORKSPACE_TEMPLATE_ROOTS = {
|
|
16
|
+
module: dirname(requireFromHere.resolve("gdc-app-template-name-module/tsconfig.json")),
|
|
17
|
+
harness: dirname(requireFromHere.resolve("gdc-app-template-name-harness/tsconfig.json")),
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Walks a directory and returns a stable, sorted map of relative-path → file
|
|
21
|
+
* content. Used to snapshot the entire scaffolded tree.
|
|
22
|
+
*/
|
|
23
|
+
function treeToMap(root) {
|
|
24
|
+
const out = {};
|
|
25
|
+
function walk(dir) {
|
|
26
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
27
|
+
const abs = join(dir, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
walk(abs);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const rel = relative(root, abs);
|
|
33
|
+
// Binary files (images, fonts) — record only their size so changes
|
|
34
|
+
// surface in the snapshot without including raw bytes.
|
|
35
|
+
const ext = entry.name.slice(entry.name.lastIndexOf(".")).toLowerCase();
|
|
36
|
+
if ([".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot"].includes(ext)) {
|
|
37
|
+
out[rel] = `<binary, ${statSync(abs).size} bytes>`;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const raw = readFileSync(abs, "utf-8");
|
|
41
|
+
out[rel] = entry.name === "package.json" ? scrubPackageJsonVersions(raw) : raw;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
walk(root);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
describe("snapshot: client profile end-to-end scaffold", () => {
|
|
48
|
+
let testRoot;
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
testRoot = mkdtempSync(join(tmpdir(), "scaff-snapshot-"));
|
|
51
|
+
// Silence the scaffolder's console output during tests.
|
|
52
|
+
vi.spyOn(console, "log").mockImplementation(() => { });
|
|
53
|
+
vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
54
|
+
vi.spyOn(console, "error").mockImplementation(() => { });
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
58
|
+
vi.restoreAllMocks();
|
|
59
|
+
});
|
|
60
|
+
it("produces a deterministic scaffold (client profile, fresh non-rush dir)", async () => {
|
|
61
|
+
// Two overrides on the client profile for snapshot purposes:
|
|
62
|
+
// 1. postCopy: client profile would otherwise try to run `npm install` —
|
|
63
|
+
// irrelevant to the file-tree snapshot.
|
|
64
|
+
// 2. preferWorkspaceTemplate: hermetic source selection. The engine's
|
|
65
|
+
// default is "use dist/templates/ if it exists, else workspace" —
|
|
66
|
+
// which silently flips the test's source depending on whether anyone
|
|
67
|
+
// ran prepack on this machine. Force workspace so the snapshot is
|
|
68
|
+
// reproducible from a clean checkout.
|
|
69
|
+
const profileForSnapshot = {
|
|
70
|
+
...clientProfile,
|
|
71
|
+
preferWorkspaceTemplate: true,
|
|
72
|
+
// Engine refuses to resolve workspace templates without explicit roots —
|
|
73
|
+
// supply the require.resolve-derived paths.
|
|
74
|
+
templateSubtreeRoots: WORKSPACE_TEMPLATE_ROOTS,
|
|
75
|
+
postCopy: async () => {
|
|
76
|
+
// no-op — snapshot only cares about the produced file tree
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
await runProfileWithAnswers(profileForSnapshot, {
|
|
80
|
+
appName: "gdc-snap-test",
|
|
81
|
+
title: "Snapshot Test",
|
|
82
|
+
scope: "workspace",
|
|
83
|
+
}, {
|
|
84
|
+
repo: {
|
|
85
|
+
repoRoot: testRoot,
|
|
86
|
+
packageManager: "npm",
|
|
87
|
+
},
|
|
88
|
+
steps: [], // no registration anchors needed in a non-rush dir
|
|
89
|
+
});
|
|
90
|
+
const tree = treeToMap(join(testRoot, "modules", "gdc-snap-test"));
|
|
91
|
+
expect(tree).toMatchSnapshot();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchor-based insertion. Files touched by the scaffolder carry a
|
|
3
|
+
* `PLUGGABLE_APP_SCAFFOLD_ANCHOR` comment marking the insertion point. The
|
|
4
|
+
* keyword is identical across file types; only the surrounding comment syntax
|
|
5
|
+
* differs (`//`, `#`, etc.). Files with more than one insertion point
|
|
6
|
+
* disambiguate with a parenthesised suffix — e.g.
|
|
7
|
+
* `PLUGGABLE_APP_SCAFFOLD_ANCHOR (stages)`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const ANCHOR_KEYWORD = "PLUGGABLE_APP_SCAFFOLD_ANCHOR";
|
|
10
|
+
export declare function fullAnchor(suffix?: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Locates the line containing the anchor. Throws if zero or more-than-one
|
|
13
|
+
* matches are found — the latter would mean the file's anchor disambiguation
|
|
14
|
+
* is broken and would silently corrupt subsequent runs.
|
|
15
|
+
*/
|
|
16
|
+
export declare function findAnchorLineIdx(lines: readonly string[], filename: string, suffix?: string): number;
|
|
17
|
+
export declare function insertBeforeAnchor(content: string, filename: string, linesToInsert: readonly string[], suffix?: string): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
/**
|
|
3
|
+
* Anchor-based insertion. Files touched by the scaffolder carry a
|
|
4
|
+
* `PLUGGABLE_APP_SCAFFOLD_ANCHOR` comment marking the insertion point. The
|
|
5
|
+
* keyword is identical across file types; only the surrounding comment syntax
|
|
6
|
+
* differs (`//`, `#`, etc.). Files with more than one insertion point
|
|
7
|
+
* disambiguate with a parenthesised suffix — e.g.
|
|
8
|
+
* `PLUGGABLE_APP_SCAFFOLD_ANCHOR (stages)`.
|
|
9
|
+
*/
|
|
10
|
+
export const ANCHOR_KEYWORD = "PLUGGABLE_APP_SCAFFOLD_ANCHOR";
|
|
11
|
+
export function fullAnchor(suffix) {
|
|
12
|
+
return suffix ? `${ANCHOR_KEYWORD} ${suffix}` : ANCHOR_KEYWORD;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Locates the line containing the anchor. Throws if zero or more-than-one
|
|
16
|
+
* matches are found — the latter would mean the file's anchor disambiguation
|
|
17
|
+
* is broken and would silently corrupt subsequent runs.
|
|
18
|
+
*/
|
|
19
|
+
export function findAnchorLineIdx(lines, filename, suffix) {
|
|
20
|
+
const needle = fullAnchor(suffix);
|
|
21
|
+
const matches = [];
|
|
22
|
+
for (let i = 0; i < lines.length; i++) {
|
|
23
|
+
if (lines[i].includes(needle))
|
|
24
|
+
matches.push(i);
|
|
25
|
+
}
|
|
26
|
+
if (matches.length === 0) {
|
|
27
|
+
throw new Error(`Could not find "${needle}" in ${filename}. The anchor comment must be present so ` +
|
|
28
|
+
`the scaffolder knows where to insert new pluggable app entries.`);
|
|
29
|
+
}
|
|
30
|
+
if (matches.length > 1) {
|
|
31
|
+
throw new Error(`Found ${matches.length} lines matching "${needle}" in ${filename} (at lines ${matches
|
|
32
|
+
.map((i) => i + 1)
|
|
33
|
+
.join(", ")}). Each anchor must be unique — use a "(suffix)" if multiple ` +
|
|
34
|
+
`insertion points are needed.`);
|
|
35
|
+
}
|
|
36
|
+
return matches[0];
|
|
37
|
+
}
|
|
38
|
+
export function insertBeforeAnchor(content, filename, linesToInsert, suffix) {
|
|
39
|
+
const lines = content.split("\n");
|
|
40
|
+
const idx = findAnchorLineIdx(lines, filename, suffix);
|
|
41
|
+
lines.splice(idx, 0, ...linesToInsert);
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IRegistrationStep } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Pre-flight check. Files edited by the scaffolder must already contain the
|
|
4
|
+
* PLUGGABLE_APP_SCAFFOLD_ANCHOR comment (with the right suffix where applicable)
|
|
5
|
+
* — otherwise the script would prompt the user, copy template files, and only
|
|
6
|
+
* THEN fail mid-way leaving a half-scaffolded state. Bail before any of that
|
|
7
|
+
* happens.
|
|
8
|
+
*
|
|
9
|
+
* Iterates over the profile's `registrations()` list — same list used by the
|
|
10
|
+
* insertion pass, so the two cannot drift.
|
|
11
|
+
*/
|
|
12
|
+
export declare function checkAnchors(steps: readonly IRegistrationStep[], repoRoot: string): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { fullAnchor } from "./anchors.js";
|
|
5
|
+
/**
|
|
6
|
+
* Pre-flight check. Files edited by the scaffolder must already contain the
|
|
7
|
+
* PLUGGABLE_APP_SCAFFOLD_ANCHOR comment (with the right suffix where applicable)
|
|
8
|
+
* — otherwise the script would prompt the user, copy template files, and only
|
|
9
|
+
* THEN fail mid-way leaving a half-scaffolded state. Bail before any of that
|
|
10
|
+
* happens.
|
|
11
|
+
*
|
|
12
|
+
* Iterates over the profile's `registrations()` list — same list used by the
|
|
13
|
+
* insertion pass, so the two cannot drift.
|
|
14
|
+
*/
|
|
15
|
+
export function checkAnchors(steps, repoRoot) {
|
|
16
|
+
const missing = [];
|
|
17
|
+
for (const { file, suffix } of steps) {
|
|
18
|
+
const fullPath = join(repoRoot, file);
|
|
19
|
+
if (!existsSync(fullPath)) {
|
|
20
|
+
missing.push({ file, suffix, reason: "file not found" });
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
24
|
+
const needle = fullAnchor(suffix);
|
|
25
|
+
if (!content.includes(needle)) {
|
|
26
|
+
missing.push({ file, suffix, reason: `missing "${needle}" comment` });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (missing.length === 0)
|
|
30
|
+
return;
|
|
31
|
+
console.error("\n Error: required PLUGGABLE_APP_SCAFFOLD_ANCHOR comments are missing.\n");
|
|
32
|
+
console.error(" The scaffolder inserts new entries before an anchor comment in each file");
|
|
33
|
+
console.error(" it touches. Without the anchor it cannot register the new pluggable app.\n");
|
|
34
|
+
console.error(" Missing in:");
|
|
35
|
+
for (const { file, suffix, reason } of missing) {
|
|
36
|
+
const label = suffix ? `${file} (anchor "${suffix}")` : file;
|
|
37
|
+
console.error(` - ${label}: ${reason}`);
|
|
38
|
+
}
|
|
39
|
+
console.error(`\n Each file needs a comment containing "PLUGGABLE_APP_SCAFFOLD_ANCHOR"`);
|
|
40
|
+
console.error(` (with the listed suffix in parentheses if applicable) at the line where new`);
|
|
41
|
+
console.error(` entries should be inserted. See an existing entry in any of the other anchor`);
|
|
42
|
+
console.error(` files for the exact comment format used in that file type.\n`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TokenReplacements } from "../types.js";
|
|
2
|
+
export interface ICopyResult {
|
|
3
|
+
/** Absolute paths of every file written. Used for the package.json transform pass. */
|
|
4
|
+
written: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Recursively copies srcDir → destDir applying ordered string replacement to
|
|
8
|
+
* every non-binary file. Returns the list of files written so the caller can
|
|
9
|
+
* post-process specific paths (e.g. transformPackageJson).
|
|
10
|
+
*/
|
|
11
|
+
export declare function copyTemplate(srcDir: string, destDir: string, replacements: TokenReplacements): ICopyResult;
|