@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.
Files changed (93) hide show
  1. package/README.md +59 -0
  2. package/dist/templates/harness/.env.template +9 -0
  3. package/dist/templates/harness/.gitignore +10 -0
  4. package/dist/templates/harness/README.md +45 -0
  5. package/dist/templates/harness/eslint.config.ts +3 -0
  6. package/dist/templates/harness/index.html +13 -0
  7. package/dist/templates/harness/package.json +70 -0
  8. package/dist/templates/harness/src/app.tsx +81 -0
  9. package/dist/templates/harness/tsconfig.json +70 -0
  10. package/dist/templates/harness/typings.d.ts +13 -0
  11. package/dist/templates/harness/vite.config.ts +111 -0
  12. package/dist/templates/module/.gitignore +11 -0
  13. package/dist/templates/module/README.md +35 -0
  14. package/dist/templates/module/eslint.config.ts +3 -0
  15. package/dist/templates/module/package.json +84 -0
  16. package/dist/templates/module/src/App.test.tsx +53 -0
  17. package/dist/templates/module/src/App.tsx +34 -0
  18. package/dist/templates/module/src/AppProviders.tsx +26 -0
  19. package/dist/templates/module/src/index.ts +3 -0
  20. package/dist/templates/module/src/pluggableApp.tsx +56 -0
  21. package/dist/templates/module/src/translations/de-DE.json +6 -0
  22. package/dist/templates/module/src/translations/en-AU.json +6 -0
  23. package/dist/templates/module/src/translations/en-GB.json +6 -0
  24. package/dist/templates/module/src/translations/en-US.json +6 -0
  25. package/dist/templates/module/src/translations/es-419.json +6 -0
  26. package/dist/templates/module/src/translations/es-ES.json +6 -0
  27. package/dist/templates/module/src/translations/fi-FI.json +6 -0
  28. package/dist/templates/module/src/translations/fr-CA.json +6 -0
  29. package/dist/templates/module/src/translations/fr-FR.json +6 -0
  30. package/dist/templates/module/src/translations/id-ID.json +6 -0
  31. package/dist/templates/module/src/translations/it-IT.json +6 -0
  32. package/dist/templates/module/src/translations/ja-JP.json +6 -0
  33. package/dist/templates/module/src/translations/ko-KR.json +6 -0
  34. package/dist/templates/module/src/translations/nl-NL.json +6 -0
  35. package/dist/templates/module/src/translations/pl-PL.json +6 -0
  36. package/dist/templates/module/src/translations/pt-BR.json +6 -0
  37. package/dist/templates/module/src/translations/pt-PT.json +6 -0
  38. package/dist/templates/module/src/translations/ru-RU.json +6 -0
  39. package/dist/templates/module/src/translations/sl-SI.json +6 -0
  40. package/dist/templates/module/src/translations/th-TH.json +6 -0
  41. package/dist/templates/module/src/translations/tr-TR.json +6 -0
  42. package/dist/templates/module/src/translations/translations.ts +71 -0
  43. package/dist/templates/module/src/translations/uk-UA.json +6 -0
  44. package/dist/templates/module/src/translations/vi-VN.json +6 -0
  45. package/dist/templates/module/src/translations/zh-HK.json +6 -0
  46. package/dist/templates/module/src/translations/zh-Hans.json +6 -0
  47. package/dist/templates/module/src/translations/zh-Hant.json +6 -0
  48. package/dist/templates/module/tsconfig.json +65 -0
  49. package/dist/templates/module/vite.config.ts +140 -0
  50. package/dist/templates/module/vitest.config.ts +12 -0
  51. package/esm/engine/__tests__/anchors.test.d.ts +1 -0
  52. package/esm/engine/__tests__/anchors.test.js +44 -0
  53. package/esm/engine/__tests__/copyTemplate.test.d.ts +1 -0
  54. package/esm/engine/__tests__/copyTemplate.test.js +59 -0
  55. package/esm/engine/__tests__/derived.test.d.ts +1 -0
  56. package/esm/engine/__tests__/derived.test.js +30 -0
  57. package/esm/engine/__tests__/jsonAnchors.test.d.ts +1 -0
  58. package/esm/engine/__tests__/jsonAnchors.test.js +52 -0
  59. package/esm/engine/__tests__/repoDetection.test.d.ts +1 -0
  60. package/esm/engine/__tests__/repoDetection.test.js +63 -0
  61. package/esm/engine/__tests__/scrubPackageJsonVersions.d.ts +1 -0
  62. package/esm/engine/__tests__/scrubPackageJsonVersions.js +34 -0
  63. package/esm/engine/__tests__/scrubPackageJsonVersions.test.d.ts +1 -0
  64. package/esm/engine/__tests__/scrubPackageJsonVersions.test.js +79 -0
  65. package/esm/engine/__tests__/snapshot.test.d.ts +1 -0
  66. package/esm/engine/__tests__/snapshot.test.js +93 -0
  67. package/esm/engine/anchors.d.ts +17 -0
  68. package/esm/engine/anchors.js +43 -0
  69. package/esm/engine/checkAnchors.d.ts +12 -0
  70. package/esm/engine/checkAnchors.js +44 -0
  71. package/esm/engine/copyTemplate.d.ts +11 -0
  72. package/esm/engine/copyTemplate.js +70 -0
  73. package/esm/engine/derived.d.ts +32 -0
  74. package/esm/engine/derived.js +85 -0
  75. package/esm/engine/jsonAnchors.d.ts +13 -0
  76. package/esm/engine/jsonAnchors.js +42 -0
  77. package/esm/engine/prompts.d.ts +15 -0
  78. package/esm/engine/prompts.js +157 -0
  79. package/esm/engine/repoDetection.d.ts +18 -0
  80. package/esm/engine/repoDetection.js +47 -0
  81. package/esm/engine/runProfile.d.ts +18 -0
  82. package/esm/engine/runProfile.js +179 -0
  83. package/esm/engine/spawn.d.ts +13 -0
  84. package/esm/engine/spawn.js +30 -0
  85. package/esm/index.d.ts +10 -0
  86. package/esm/index.js +62 -0
  87. package/esm/profiles/__tests__/client.test.d.ts +1 -0
  88. package/esm/profiles/__tests__/client.test.js +151 -0
  89. package/esm/profiles/client.d.ts +2 -0
  90. package/esm/profiles/client.js +208 -0
  91. package/esm/types.d.ts +194 -0
  92. package/esm/types.js +2 -0
  93. package/package.json +77 -0
@@ -0,0 +1,179 @@
1
+ // (C) 2026 GoodData Corporation
2
+ import { existsSync, readFileSync, writeFileSync } from "fs";
3
+ import { basename, dirname, join } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { insertBeforeAnchor } from "./anchors.js";
6
+ import { checkAnchors } from "./checkAnchors.js";
7
+ import { copyTemplate } from "./copyTemplate.js";
8
+ import { runPrompts } from "./prompts.js";
9
+ import { detectRepo } from "./repoDetection.js";
10
+ /**
11
+ * Resolves the on-disk location of a single template subtree (`module` or `harness`).
12
+ *
13
+ * Two cases:
14
+ * 1. Published: prepack-bundle-template.sh copied templates into our own
15
+ * package's `dist/templates/<subtree>/`. Use those — already path-rewritten.
16
+ * 2. Workspace dev: resolve the template package via require.resolve, so the
17
+ * dependency graph stays honest. Templates live as workspace siblings.
18
+ *
19
+ * The template package names are the literal placeholder strings that get
20
+ * substituted at scaffold time. They look like template tokens; they ARE real
21
+ * workspace package identifiers.
22
+ */
23
+ function getTemplateSubtreeRoot(subtree, preferWorkspace, explicitRoots) {
24
+ // 1. Explicit override wins. Profiles use this when the templates aren't
25
+ // resolvable from the engine's runtime location — e.g. gdc-ui's internal
26
+ // scaffolder, where the engine runs from a transitively-installed
27
+ // `.pnpm/.../@gooddata/create-pluggable-module/` location that doesn't
28
+ // have the workspace template packages alongside.
29
+ const explicit = explicitRoots?.[subtree];
30
+ if (explicit) {
31
+ if (!existsSync(explicit)) {
32
+ throw new Error(`Profile-supplied templateSubtreeRoots["${subtree}"] points at "${explicit}" but no directory exists there.`);
33
+ }
34
+ return explicit;
35
+ }
36
+ // 2. `preferWorkspaceTemplate` opts out of the bundled `dist/templates/`
37
+ // path AND requires the profile to supply paths. Without paths there's
38
+ // no safe place to find workspace templates at runtime — fail loudly
39
+ // instead of falling through to brittle dependency-graph resolution.
40
+ if (preferWorkspace) {
41
+ throw new Error(`Profile sets preferWorkspaceTemplate=true but did not supply templateSubtreeRoots["${subtree}"]. ` +
42
+ `Workspace templates can't be resolved at runtime without an explicit absolute path — ` +
43
+ `populate templateSubtreeRoots[] in the profile (typically via import.meta.url-relative resolution).`);
44
+ }
45
+ // 3. Published path under dist/templates/ — created by prepack-bundle-template.sh
46
+ // just before `npm pack` and consumed by external clients via `npm init`.
47
+ // The bundled tsconfigs are pre-inlined so they don't need workspace parents.
48
+ const here = dirname(fileURLToPath(import.meta.url));
49
+ const publishedRoot = join(here, "..", "..", "dist", "templates", subtree);
50
+ if (existsSync(publishedRoot))
51
+ return publishedRoot;
52
+ // 4. Nothing left to try. Both paths require setup we don't have.
53
+ throw new Error(`No template root for subtree "${subtree}". Either supply templateSubtreeRoots["${subtree}"] in the profile, ` +
54
+ `or ensure dist/templates/${subtree}/ exists (run prepack-bundle-template.sh).`);
55
+ }
56
+ export async function runProfile(profile) {
57
+ // 1. Detect the repo shape — drives everything downstream.
58
+ const repo = detectRepo(process.cwd());
59
+ // 2. Pre-flight: every file the profile wants to register in must already
60
+ // carry the right anchor. Bail before prompts so we don't half-scaffold.
61
+ const steps = profile.registrations(repo);
62
+ checkAnchors(steps, repo.repoRoot);
63
+ // 3. Prompts: shared appName/title/scope + the profile's extras.
64
+ const answers = await runPrompts(profile.extraPrompts);
65
+ await runProfileWithAnswers(profile, answers, { repo, steps });
66
+ }
67
+ /**
68
+ * Test-friendly variant: skip prompt collection, take answers as input.
69
+ * Also useful for programmatic consumers that want to drive the scaffolder
70
+ * without an interactive TTY.
71
+ *
72
+ * @alpha
73
+ */
74
+ export async function runProfileWithAnswers(profile, answers, options) {
75
+ const repo = options?.repo ?? detectRepo(process.cwd());
76
+ const steps = options?.steps ?? profile.registrations(repo);
77
+ // Pre-flight in case caller didn't already do it (the prompts entry does).
78
+ if (!options?.steps) {
79
+ checkAnchors(steps, repo.repoRoot);
80
+ }
81
+ // 4. Resolve destRoot via the profile (it may consume an extra prompt like
82
+ // destPath) THEN check the destination doesn't already exist. Hard-coding
83
+ // `modules/<name>` here would miss real collisions when the client profile
84
+ // rewrote destPath, and would false-positive when the user explicitly
85
+ // targeted somewhere else.
86
+ const destRoot = profile.resolveDestRoot(answers, repo.repoRoot);
87
+ if (existsSync(destRoot)) {
88
+ throw new Error(`Destination ${destRoot} already exists. Choose a different app name or destPath, or remove the directory.`);
89
+ }
90
+ // 5. Derived values. Profile-specific because devPort scanning and menuOrder
91
+ // computation depend on the host's registry shape.
92
+ const derived = profile.derive(answers, repo);
93
+ const ctx = {
94
+ answers,
95
+ derived,
96
+ repoRoot: repo.repoRoot,
97
+ destRoot,
98
+ packageManager: repo.packageManager,
99
+ };
100
+ printPlan(ctx, profile);
101
+ // 6. Copy template subtrees in declared order, applying token replacements.
102
+ const replacements = profile.buildReplacements(ctx);
103
+ const packageJsonsToTransform = [];
104
+ const preferWorkspace = profile.preferWorkspaceTemplate ?? false;
105
+ for (const subtree of profile.templateSubtrees) {
106
+ // getTemplateSubtreeRoot guarantees the returned path exists — every
107
+ // branch either throws or returns a path that just passed existsSync.
108
+ const srcDir = getTemplateSubtreeRoot(subtree, preferWorkspace, profile.templateSubtreeRoots);
109
+ const destSubtree = join(destRoot, subtree);
110
+ console.log(`\n Copying template ${subtree}/ → ${relativeTo(repo.repoRoot, destSubtree)}/ ...`);
111
+ const { written } = copyTemplate(srcDir, destSubtree, replacements);
112
+ // Collect package.json paths for the post-copy transform pass.
113
+ for (const path of written) {
114
+ if (basename(path) === "package.json") {
115
+ packageJsonsToTransform.push(path);
116
+ }
117
+ }
118
+ }
119
+ // 6b. Apply profile-specific overlays on top of the templates. Used by
120
+ // profiles that need files which shouldn't live in the shared template
121
+ // (e.g. internal-only deploy configs that would leak GoodData infra if
122
+ // shipped to client repos). See IOverlayPath.
123
+ for (const overlay of profile.overlayPaths ?? []) {
124
+ if (!existsSync(overlay.srcDir)) {
125
+ throw new Error(`Overlay source directory "${overlay.srcDir}" (destSubdir "${overlay.destSubdir}") does not exist.`);
126
+ }
127
+ const destSubtree = join(destRoot, overlay.destSubdir);
128
+ console.log(` Applying overlay → ${relativeTo(repo.repoRoot, destSubtree)}/ (from ${overlay.srcDir}) ...`);
129
+ const { written } = copyTemplate(overlay.srcDir, destSubtree, replacements);
130
+ for (const path of written) {
131
+ if (basename(path) === "package.json") {
132
+ packageJsonsToTransform.push(path);
133
+ }
134
+ }
135
+ }
136
+ // 7. Per-package.json transform. Internal profile is a no-op; client rewrites
137
+ // workspace:* deps to concrete semver versions.
138
+ for (const path of packageJsonsToTransform) {
139
+ const before = JSON.parse(readFileSync(path, "utf-8"));
140
+ const after = profile.transformPackageJson(before, ctx);
141
+ // 4-space indent + trailing newline matches the rest of the SDK.
142
+ writeFileSync(path, JSON.stringify(after, null, 4) + "\n", "utf-8");
143
+ }
144
+ // 8. Anchor-driven file edits. Sequential because edits to the same file from
145
+ // multiple steps (e.g. host Dockerfile with two anchors) would race.
146
+ for (const step of steps) {
147
+ const fullPath = join(repo.repoRoot, step.file);
148
+ const suffixLabel = step.suffix ? ` (${step.suffix})` : "";
149
+ console.log(` Registering in ${step.file}${suffixLabel} ...`);
150
+ const original = readFileSync(fullPath, "utf-8");
151
+ // preInsertTransform handles structural fix-ups before splice (e.g.
152
+ // adding a trailing comma to the last entry in a JSONC array).
153
+ const transformed = step.preInsertTransform
154
+ ? step.preInsertTransform(original, step.suffix)
155
+ : original;
156
+ const linesToInsert = step.emit(ctx);
157
+ const after = insertBeforeAnchor(transformed, step.file, linesToInsert, step.suffix);
158
+ writeFileSync(fullPath, after, "utf-8");
159
+ }
160
+ // 9. Profile-specific postCopy — rush update / npm install / nothing.
161
+ await profile.postCopy(ctx);
162
+ }
163
+ function relativeTo(repoRoot, abs) {
164
+ if (abs.startsWith(repoRoot)) {
165
+ return abs.slice(repoRoot.length).replace(/^[\\/]/, "");
166
+ }
167
+ return abs;
168
+ }
169
+ function printPlan(ctx, profile) {
170
+ const { answers, derived } = ctx;
171
+ console.log(`\n Creating ${answers.appName} (profile: ${profile.name})...`);
172
+ console.log(` Route: ${derived.route}`);
173
+ console.log(` Scope: ${answers.scope}`);
174
+ console.log(` Menu order: ${derived.menuOrder}`);
175
+ console.log(` Harness port: ${derived.devPort}`);
176
+ console.log(` Module fed port: ${derived.moduleDevPort}`);
177
+ console.log(` Federation name: ${derived.federationName}`);
178
+ console.log(` Remote URL env: ${derived.remoteUrlEnvVar}`);
179
+ }
@@ -0,0 +1,13 @@
1
+ import type { PackageManager } from "../types.js";
2
+ /**
3
+ * Runs `<command> <args>` synchronously, inheriting stdio so the user sees the
4
+ * output as it streams. Returns true on exit code 0, false otherwise — the
5
+ * caller decides whether to surface the failure as fatal or as a hint in the
6
+ * "Next steps" message.
7
+ */
8
+ export declare function runCommand(command: string, args: readonly string[], cwd: string): boolean;
9
+ /** Returns the install command for a given package manager. */
10
+ export declare function installCommand(pm: PackageManager): {
11
+ command: string;
12
+ args: string[];
13
+ };
@@ -0,0 +1,30 @@
1
+ // (C) 2026 GoodData Corporation
2
+ import { spawnSync } from "child_process";
3
+ /**
4
+ * Runs `<command> <args>` synchronously, inheriting stdio so the user sees the
5
+ * output as it streams. Returns true on exit code 0, false otherwise — the
6
+ * caller decides whether to surface the failure as fatal or as a hint in the
7
+ * "Next steps" message.
8
+ */
9
+ export function runCommand(command, args, cwd) {
10
+ const result = spawnSync(command, [...args], {
11
+ cwd,
12
+ stdio: "inherit",
13
+ // Rush on Windows ships as `rush.cmd`; without shell:true spawnSync can't find it.
14
+ shell: process.platform === "win32",
15
+ });
16
+ return result.status === 0;
17
+ }
18
+ /** Returns the install command for a given package manager. */
19
+ export function installCommand(pm) {
20
+ switch (pm) {
21
+ case "rush":
22
+ return { command: "rush", args: ["update"] };
23
+ case "pnpm":
24
+ return { command: "pnpm", args: ["install"] };
25
+ case "yarn":
26
+ return { command: "yarn", args: ["install"] };
27
+ case "npm":
28
+ return { command: "npm", args: ["install"] };
29
+ }
30
+ }
package/esm/index.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { runProfile, runProfileWithAnswers } from "./engine/runProfile.js";
3
+ import { clientProfile } from "./profiles/client.js";
4
+ export { runProfile, runProfileWithAnswers, clientProfile };
5
+ export type { ApplicationScope, IDerivedValues, IOverlayPath, IProfileContext, IPromptDescriptor, IRegistrationStep, IRepoInfo, IScaffoldAnswers, IScaffoldProfile, PackageManager, ProfileName, TokenReplacements, } from "./types.js";
6
+ export { ensureTrailingCommaBeforeAnchor } from "./engine/jsonAnchors.js";
7
+ export { fullAnchor, findAnchorLineIdx, insertBeforeAnchor, ANCHOR_KEYWORD } from "./engine/anchors.js";
8
+ export { computeRoute, computeFederationName, computeRemoteUrlEnvVar, computeDevPort, getOwnPackageVersion, } from "./engine/derived.js";
9
+ export { runCommand, installCommand } from "./engine/spawn.js";
10
+ export { validateTextValue } from "./engine/prompts.js";
package/esm/index.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ // (C) 2026 GoodData Corporation
3
+ /* oxlint-disable no-barrel-files/no-barrel-files -- this file IS the package's curated public surface */
4
+ import { realpathSync } from "fs";
5
+ import { fileURLToPath } from "url";
6
+ import { runProfile, runProfileWithAnswers } from "./engine/runProfile.js";
7
+ import { clientProfile } from "./profiles/client.js";
8
+ // Engine API for external profile authors (e.g. gdc-ui's internal scaffolder).
9
+ // Anything re-exported here is part of the public surface of @gooddata/create-pluggable-module.
10
+ export { runProfile, runProfileWithAnswers, clientProfile };
11
+ export { ensureTrailingCommaBeforeAnchor } from "./engine/jsonAnchors.js";
12
+ export { fullAnchor, findAnchorLineIdx, insertBeforeAnchor, ANCHOR_KEYWORD } from "./engine/anchors.js";
13
+ export { computeRoute, computeFederationName, computeRemoteUrlEnvVar, computeDevPort, getOwnPackageVersion, } from "./engine/derived.js";
14
+ export { runCommand, installCommand } from "./engine/spawn.js";
15
+ export { validateTextValue } from "./engine/prompts.js";
16
+ // CLI entry — only ships the client profile. Internal/private scaffolders are
17
+ // expected to import `runProfile` programmatically and supply their own profile.
18
+ async function main() {
19
+ const argv = process.argv.slice(2);
20
+ if (argv.includes("--help") || argv.includes("-h")) {
21
+ printHelp();
22
+ return;
23
+ }
24
+ await runProfile(clientProfile);
25
+ }
26
+ function printHelp() {
27
+ console.log(`
28
+ @gooddata/create-pluggable-module — scaffold a new GoodData pluggable application.
29
+
30
+ Usage:
31
+ npm init @gooddata/pluggable-module
32
+
33
+ Flags:
34
+ --help, -h Print this help and exit.
35
+
36
+ Programmatic API:
37
+ import { runProfile, clientProfile, type IScaffoldProfile } from "@gooddata/create-pluggable-module";
38
+ await runProfile(yourCustomProfile);
39
+ `);
40
+ }
41
+ // Only run the CLI when this file is the entry point (allows library use too).
42
+ // `process.argv[1]` is the script path Node was invoked with — when this file is
43
+ // installed as a bin, that's actually a symlink in node_modules/.bin/ pointing
44
+ // here. Compare the realpath against this module's file URL so symlinked
45
+ // invocation (npx, npm install -g, npm init …) still detects entry-point use.
46
+ function isCliInvocation() {
47
+ const argv1 = process.argv[1];
48
+ if (!argv1)
49
+ return false;
50
+ try {
51
+ return fileURLToPath(import.meta.url) === realpathSync(argv1);
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ if (isCliInvocation()) {
58
+ main().catch((err) => {
59
+ console.error(err);
60
+ process.exit(1);
61
+ });
62
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,151 @@
1
+ // (C) 2026 GoodData Corporation
2
+ import { describe, expect, it } from "vitest";
3
+ import { clientProfile } from "../client.js";
4
+ function mockCtx(overrides = {}) {
5
+ return {
6
+ answers: { appName: "gdc-mock", title: "Mock", scope: "workspace" },
7
+ derived: {
8
+ route: "/mock",
9
+ federationName: "gdc_mock",
10
+ remoteUrlEnvVar: "MOCK_REMOTE_URL",
11
+ devPort: 8500,
12
+ moduleDevPort: 8600,
13
+ menuOrder: 100,
14
+ },
15
+ repoRoot: "/fake/repo",
16
+ destRoot: "/fake/repo/modules/gdc-mock",
17
+ packageManager: "npm",
18
+ ...overrides,
19
+ };
20
+ }
21
+ describe("clientProfile", () => {
22
+ it("declares the expected templateSubtrees in dep-aware order", () => {
23
+ // harness imports from module → module must build first
24
+ expect(clientProfile.templateSubtrees).toEqual(["module", "harness"]);
25
+ });
26
+ it("does not prefer workspace templates (would leak workspace tsconfig paths to clients)", () => {
27
+ expect(clientProfile.preferWorkspaceTemplate ?? false).toBe(false);
28
+ });
29
+ describe("buildReplacements", () => {
30
+ const replacements = clientProfile.buildReplacements(mockCtx());
31
+ const map = Object.fromEntries(replacements);
32
+ it("substitutes -module and -harness package names BEFORE the bare app name (longer-first ordering)", () => {
33
+ const keys = replacements.map(([k]) => k);
34
+ const idxBare = keys.indexOf("gdc-app-template-name");
35
+ const idxModule = keys.indexOf("gdc-app-template-name-module");
36
+ expect(idxModule).toBeLessThan(idxBare);
37
+ });
38
+ it("provides every template token used in scaffolded files", () => {
39
+ expect(map["gdc-app-template-name"]).toBe("gdc-mock");
40
+ expect(map["gdc-app-template-name-module"]).toBe("gdc-mock-module");
41
+ expect(map["gdc-app-template-name-harness"]).toBe("gdc-mock-harness");
42
+ expect(map["{applicationTemplateTitle}"]).toBe("Mock");
43
+ expect(map["{applicationTemplateScope}"]).toBe("workspace");
44
+ expect(map["{applicationTemplateFederationName}"]).toBe("gdc_mock");
45
+ expect(map["APP_TEMPLATE_REMOTE_URL"]).toBe("MOCK_REMOTE_URL");
46
+ expect(map["/app-template-route"]).toBe("/mock");
47
+ expect(map["{harnessPort}"]).toBe("8500");
48
+ expect(map["{modulePort}"]).toBe("8600");
49
+ });
50
+ it("strips the gdc-ui internal clean-command-state.sh prefix (helper not present in client repos)", () => {
51
+ expect(map["../../../../common/scripts/clean-command-state.sh && "]).toBe("");
52
+ });
53
+ it("does NOT carry the internal workspace-path depth adjustment (only the internal profile owns that)", () => {
54
+ // The 4→3 depth shift only makes sense inside gdc-ui where `common/`
55
+ // exists at the repo root. For clients, no depth shift can rescue
56
+ // the missing helper — strip the call instead (asserted above).
57
+ expect(map["../../../../common/"]).toBeUndefined();
58
+ });
59
+ });
60
+ describe("resolveDestRoot", () => {
61
+ it("uses modules/<appName> when destPath is not overridden", () => {
62
+ const destRoot = clientProfile.resolveDestRoot({ appName: "gdc-x", title: "X", scope: "workspace" }, "/fake/repo");
63
+ expect(destRoot).toBe("/fake/repo/modules/gdc-x");
64
+ });
65
+ it("accepts an in-cwd destPath override", () => {
66
+ const destRoot = clientProfile.resolveDestRoot({ appName: "gdc-x", title: "X", scope: "workspace", destPath: "apps/gdc-x" }, "/fake/repo");
67
+ expect(destRoot).toBe("/fake/repo/apps/gdc-x");
68
+ });
69
+ it("rejects path-traversal destPath that resolves outside cwd", () => {
70
+ expect(() => clientProfile.resolveDestRoot({
71
+ appName: "gdc-x",
72
+ title: "X",
73
+ scope: "workspace",
74
+ destPath: "../../../etc",
75
+ }, "/fake/repo")).toThrow(/resolves outside the current directory/);
76
+ });
77
+ it("rejects an absolute destPath outside cwd", () => {
78
+ expect(() => clientProfile.resolveDestRoot({
79
+ appName: "gdc-x",
80
+ title: "X",
81
+ scope: "workspace",
82
+ destPath: "/somewhere/else",
83
+ }, "/fake/repo")).toThrow(/resolves outside the current directory/);
84
+ });
85
+ it("does NOT confuse a cwd-prefix-overlap (e.g. `/fake/repo-other`) as inside cwd", () => {
86
+ expect(() => clientProfile.resolveDestRoot({
87
+ appName: "gdc-x",
88
+ title: "X",
89
+ scope: "workspace",
90
+ destPath: "../repo-other/sub",
91
+ }, "/fake/repo")).toThrow(/resolves outside the current directory/);
92
+ });
93
+ });
94
+ describe("registrations", () => {
95
+ it("registers in rush.json when target repo IS a Rush monorepo", () => {
96
+ const steps = clientProfile.registrations({
97
+ repoRoot: "/tmp",
98
+ packageManager: "rush",
99
+ });
100
+ expect(steps.map((s) => s.file)).toEqual(["rush.json"]);
101
+ });
102
+ it("does NOT register anywhere when target repo is not Rush (purely additive scaffold)", () => {
103
+ const steps = clientProfile.registrations({
104
+ repoRoot: "/tmp",
105
+ packageManager: "npm",
106
+ });
107
+ expect(steps).toEqual([]);
108
+ });
109
+ });
110
+ describe("transformPackageJson", () => {
111
+ it("rewrites workspace:* deps to concrete versions", () => {
112
+ const after = clientProfile.transformPackageJson({
113
+ dependencies: {
114
+ "@gooddata/sdk-ui": "workspace:*",
115
+ lodash: "^4.17.23",
116
+ },
117
+ }, mockCtx());
118
+ const deps = after["dependencies"];
119
+ expect(deps["@gooddata/sdk-ui"]).not.toBe("workspace:*");
120
+ expect(deps.lodash).toBe("^4.17.23"); // non-workspace deps untouched
121
+ });
122
+ it("preserves workspace:* for the just-scaffolded siblings (they have no published version yet)", () => {
123
+ const after = clientProfile.transformPackageJson({
124
+ dependencies: {
125
+ "gdc-mock-module": "workspace:*",
126
+ "gdc-mock-harness": "workspace:*",
127
+ "@gooddata/sdk-ui": "workspace:*",
128
+ },
129
+ }, mockCtx());
130
+ const deps = after["dependencies"];
131
+ // sibling refs stay workspace:* — rush/pnpm/yarn workspaces resolve them locally
132
+ expect(deps["gdc-mock-module"]).toBe("workspace:*");
133
+ expect(deps["gdc-mock-harness"]).toBe("workspace:*");
134
+ // SDK deps still get a concrete version
135
+ expect(deps["@gooddata/sdk-ui"]).not.toBe("workspace:*");
136
+ });
137
+ it("strips _phase:* scripts (Rush-only, would break in non-Rush repos)", () => {
138
+ const after = clientProfile.transformPackageJson({
139
+ scripts: {
140
+ "_phase:build": "npm run build",
141
+ build: "tsc",
142
+ test: "vitest",
143
+ },
144
+ }, mockCtx());
145
+ const scripts = after["scripts"];
146
+ expect(scripts["_phase:build"]).toBeUndefined();
147
+ expect(scripts.build).toBe("tsc");
148
+ expect(scripts.test).toBe("vitest");
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,2 @@
1
+ import type { IScaffoldProfile } from "../types.js";
2
+ export declare const clientProfile: IScaffoldProfile;
@@ -0,0 +1,208 @@
1
+ // (C) 2026 GoodData Corporation
2
+ import { existsSync } from "fs";
3
+ import { join, resolve, sep } from "path";
4
+ import { computeDevPort, computeFederationName, computeRemoteUrlEnvVar, computeRoute, getOwnPackageVersion, } from "../engine/derived.js";
5
+ import { ensureTrailingCommaBeforeAnchor } from "../engine/jsonAnchors.js";
6
+ import { validateTextValue } from "../engine/prompts.js";
7
+ import { installCommand, runCommand } from "../engine/spawn.js";
8
+ const DEST_PATH_KEY = "destPath";
9
+ /**
10
+ * Rewrites `workspace:*` deps to a concrete semver range based on this
11
+ * scaffolder's own version. The scaffolder ships locked to the SDK release
12
+ * train (versionPolicyName: "sdk"), so its version matches the SDK packages
13
+ * the templates depend on.
14
+ *
15
+ * We use a caret range so consumers get patch + minor updates within the SDK
16
+ * release train; alpha versions get exact pin since semver caret behaves
17
+ * surprisingly across prerelease boundaries.
18
+ */
19
+ function buildClientDepVersion() {
20
+ const ownVersion = getOwnPackageVersion();
21
+ if (ownVersion.includes("-")) {
22
+ // Prerelease (alpha/beta/rc) — pin exactly.
23
+ return ownVersion;
24
+ }
25
+ return `^${ownVersion}`;
26
+ }
27
+ /**
28
+ * Rewrites `workspace:*` deps to a concrete version — except for the
29
+ * just-scaffolded siblings (the new `<app>-module` / `<app>-harness`), which
30
+ * stay `workspace:*` because they're brand-new local packages with no
31
+ * published version. The client repo's package manager resolves them via its
32
+ * own workspaces feature (Rush, pnpm workspaces, yarn workspaces, etc.).
33
+ */
34
+ function rewriteWorkspaceDeps(deps, version, siblings) {
35
+ if (!deps)
36
+ return deps;
37
+ const out = {};
38
+ for (const [name, range] of Object.entries(deps)) {
39
+ if (range === "workspace:*") {
40
+ out[name] = siblings.has(name) ? "workspace:*" : version;
41
+ }
42
+ else {
43
+ out[name] = range;
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+ export const clientProfile = {
49
+ name: "client",
50
+ extraPrompts: [
51
+ {
52
+ key: DEST_PATH_KEY,
53
+ label: "Destination path (relative to cwd, default ./modules/<name>)",
54
+ optional: true,
55
+ validate: validateTextValue,
56
+ },
57
+ ],
58
+ resolveDestRoot(answers, cwd) {
59
+ const overridden = answers[DEST_PATH_KEY];
60
+ if (overridden) {
61
+ const resolved = resolve(cwd, overridden);
62
+ // Reject path traversal: `..` segments or absolute paths outside cwd.
63
+ // The text-validation regex blocks shell-injection chars but not `..`,
64
+ // so a value like "../../etc" would otherwise resolve outside cwd.
65
+ // `+ sep` prevents prefix-overlap matches (`/proj` vs `/proj-other`).
66
+ if (resolved !== cwd && !resolved.startsWith(cwd + sep)) {
67
+ throw new Error(`destPath "${overridden}" resolves outside the current directory (${resolved}). ` +
68
+ `Choose a destination inside ${cwd}.`);
69
+ }
70
+ return resolved;
71
+ }
72
+ return join(cwd, "modules", answers.appName);
73
+ },
74
+ templateSubtrees: ["module", "harness"],
75
+ derive(answers, repo) {
76
+ const devPort = computeDevPort(repo.repoRoot);
77
+ return {
78
+ route: computeRoute(answers.appName),
79
+ federationName: computeFederationName(answers.appName),
80
+ remoteUrlEnvVar: computeRemoteUrlEnvVar(answers.appName),
81
+ devPort,
82
+ moduleDevPort: devPort + 100,
83
+ // No host menu to slot into — keep a stable default so scaffolded apps
84
+ // are still self-consistent and the harness can still read it.
85
+ menuOrder: 100,
86
+ };
87
+ },
88
+ buildReplacements(ctx) {
89
+ const { answers, derived } = ctx;
90
+ // Order matters: longer tokens (containing shorter ones as substring)
91
+ // MUST come first. `gdc-app-template-module` before `gdc-app-template`.
92
+ return [
93
+ ["gdc-app-template-name-module", `${answers.appName}-module`],
94
+ ["gdc-app-template-name-harness", `${answers.appName}-harness`],
95
+ ["gdc-app-template-name", answers.appName],
96
+ ["{applicationTemplateTitle}", answers.title],
97
+ ["{applicationTemplateScope}", answers.scope],
98
+ ["{applicationTemplateFederationName}", derived.federationName],
99
+ ["APP_TEMPLATE_REMOTE_URL", derived.remoteUrlEnvVar],
100
+ ["/app-template-route", derived.route],
101
+ ["{harnessPort}", String(derived.devPort)],
102
+ ["{modulePort}", String(derived.moduleDevPort)],
103
+ // The template `clean` script prepends a call to gdc-ui's
104
+ // `common/scripts/clean-command-state.sh` — that helper exists only
105
+ // inside the gdc-ui monorepo. For client scaffolds, strip the helper
106
+ // call entirely; the trailing `rm -rf …` cleans the dirs that matter
107
+ // and is self-contained.
108
+ ["../../../../common/scripts/clean-command-state.sh && ", ""],
109
+ ];
110
+ },
111
+ transformPackageJson(pkg, ctx) {
112
+ const version = buildClientDepVersion();
113
+ // Brand-new sibling packages we just scaffolded — they have no published
114
+ // version yet, so `workspace:*` must stay for the client repo's
115
+ // workspace machinery (Rush / pnpm / yarn workspaces) to resolve them.
116
+ const siblings = new Set([
117
+ `${ctx.answers.appName}-module`,
118
+ `${ctx.answers.appName}-harness`,
119
+ ]);
120
+ const out = { ...pkg };
121
+ out.dependencies = rewriteWorkspaceDeps(pkg.dependencies, version, siblings);
122
+ out.devDependencies = rewriteWorkspaceDeps(pkg.devDependencies, version, siblings);
123
+ out.peerDependencies = rewriteWorkspaceDeps(pkg.peerDependencies, version, siblings);
124
+ // Drop _phase:* scripts — those are Rush-specific build phases and would
125
+ // break npm/yarn/pnpm install in a client repo.
126
+ if (out.scripts && typeof out.scripts === "object") {
127
+ const filtered = {};
128
+ for (const [k, v] of Object.entries(out.scripts)) {
129
+ if (!k.startsWith("_phase:"))
130
+ filtered[k] = v;
131
+ }
132
+ out.scripts = filtered;
133
+ }
134
+ return out;
135
+ },
136
+ registrations(repo) {
137
+ if (repo.packageManager !== "rush") {
138
+ // Client repos without a Rush workspace have nothing to register;
139
+ // the scaffold is purely additive (new files only).
140
+ return [];
141
+ }
142
+ // Inside a Rush workspace, still register in rush.json so `rush update`
143
+ // picks up the new packages. Doesn't touch the host (none in client repos).
144
+ return [
145
+ {
146
+ file: "rush.json",
147
+ preInsertTransform: ensureTrailingCommaBeforeAnchor,
148
+ emit: (ctx) => emitRushJsonEntries(ctx.answers.appName, " "),
149
+ },
150
+ ];
151
+ },
152
+ async postCopy(ctx) {
153
+ const { command, args } = installCommand(ctx.packageManager);
154
+ console.log(`\n Running ${command} ${args.join(" ")} ...\n`);
155
+ const ok = runCommand(command, args, ctx.repoRoot);
156
+ printClientNextSteps(ctx, command, args, ok);
157
+ },
158
+ };
159
+ function emitRushJsonEntries(appName, indent) {
160
+ return [
161
+ `${indent}{`,
162
+ `${indent} "packageName": "${appName}-module",`,
163
+ `${indent} "projectFolder": "modules/${appName}/module"`,
164
+ `${indent}},`,
165
+ `${indent}{`,
166
+ `${indent} "packageName": "${appName}-harness",`,
167
+ `${indent} "projectFolder": "modules/${appName}/harness"`,
168
+ `${indent}},`,
169
+ ];
170
+ }
171
+ function printClientNextSteps(ctx, installCmd, installArgs, installOk) {
172
+ const { answers, derived, destRoot, repoRoot } = ctx;
173
+ const relativeDest = destRoot.startsWith(repoRoot)
174
+ ? destRoot.slice(repoRoot.length).replace(/^[\\/]/, "")
175
+ : destRoot;
176
+ const harnessDir = `${relativeDest}/harness`;
177
+ const moduleDir = `${relativeDest}/module`;
178
+ const nextSteps = [];
179
+ if (!installOk)
180
+ nextSteps.push(`${installCmd} ${installArgs.join(" ")}`);
181
+ nextSteps.push(`cd ${harnessDir}`);
182
+ nextSteps.push("# Local harness dev loop (imports module source directly — no federation):");
183
+ nextSteps.push("cp .env.template .env # and fill in TIGER_API_TOKEN");
184
+ nextSteps.push("npm run dev");
185
+ nextSteps.push(`# Run the module as a federation remote (rebuild on change + preview at :${derived.moduleDevPort}):`);
186
+ nextSteps.push(`cd ${moduleDir} && npm run dev`);
187
+ const numbered = nextSteps.map((step, i) => ` ${i + 1}. ${step}`).join("\n");
188
+ const header = installOk
189
+ ? "Done! Your new application has been scaffolded and dependencies installed."
190
+ : "Your new application has been scaffolded, but the dependency install failed — re-run it before continuing.";
191
+ console.log(`
192
+ ${header}
193
+
194
+ Next steps:
195
+ ${numbered}
196
+
197
+ Summary:
198
+ - ID: ${answers.appName}
199
+ - Route: ${derived.route}
200
+ - Federation name: ${derived.federationName}
201
+ - Remote URL env: ${derived.remoteUrlEnvVar} (set in harness .env to point at a federation preview)
202
+ - Dev default: https://localhost:${derived.moduleDevPort}/remoteEntry.js
203
+ `);
204
+ // Sanity check — print a warning if .env.template doesn't exist where we expect.
205
+ if (!existsSync(join(destRoot, "harness", ".env.template"))) {
206
+ console.warn(` Warning: ${harnessDir}/.env.template not found. Expected one from the template subtree.`);
207
+ }
208
+ }