@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,70 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { cpSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
/**
|
|
5
|
+
* Directories that should never be copied. Tooling output (`node_modules`,
|
|
6
|
+
* `esm`, `dist`, `.rush`, etc.) gets regenerated by the consumer's package
|
|
7
|
+
* manager once the new module is installed, and including them would balloon
|
|
8
|
+
* the published tarball and risk shipping stale .tsbuildinfo / .vite caches.
|
|
9
|
+
*/
|
|
10
|
+
const SKIP_DIRS = new Set(["node_modules", "esm", "dist", ".rush", "rush-logs", ".__mf__temp", ".vite"]);
|
|
11
|
+
const SKIP_SUFFIXES = [".tsbuildinfo"];
|
|
12
|
+
/**
|
|
13
|
+
* Files for which token-substitution-in-text would corrupt the byte stream.
|
|
14
|
+
* Binary files must be copied verbatim.
|
|
15
|
+
*/
|
|
16
|
+
const BINARY_EXTENSIONS = new Set([
|
|
17
|
+
".ico",
|
|
18
|
+
".png",
|
|
19
|
+
".jpg",
|
|
20
|
+
".jpeg",
|
|
21
|
+
".gif",
|
|
22
|
+
".woff",
|
|
23
|
+
".woff2",
|
|
24
|
+
".ttf",
|
|
25
|
+
".eot",
|
|
26
|
+
]);
|
|
27
|
+
/**
|
|
28
|
+
* Recursively copies srcDir → destDir applying ordered string replacement to
|
|
29
|
+
* every non-binary file. Returns the list of files written so the caller can
|
|
30
|
+
* post-process specific paths (e.g. transformPackageJson).
|
|
31
|
+
*/
|
|
32
|
+
export function copyTemplate(srcDir, destDir, replacements) {
|
|
33
|
+
const written = [];
|
|
34
|
+
copyRecursive(srcDir, destDir, replacements, written);
|
|
35
|
+
return { written };
|
|
36
|
+
}
|
|
37
|
+
function copyRecursive(srcDir, destDir, replacements, written) {
|
|
38
|
+
mkdirSync(destDir, { recursive: true });
|
|
39
|
+
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
40
|
+
const srcPath = join(srcDir, entry.name);
|
|
41
|
+
const destPath = join(destDir, entry.name);
|
|
42
|
+
// Symlinks and rush-style packages-link dirs would not survive npm-pack anyway —
|
|
43
|
+
// and copying them in client repos would yank in unrelated workspace state.
|
|
44
|
+
if (entry.isSymbolicLink() || entry.name === "packages-link")
|
|
45
|
+
continue;
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
if (SKIP_DIRS.has(entry.name))
|
|
48
|
+
continue;
|
|
49
|
+
copyRecursive(srcPath, destPath, replacements, written);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (SKIP_SUFFIXES.some((s) => entry.name.endsWith(s)))
|
|
53
|
+
continue;
|
|
54
|
+
copyFile(srcPath, destPath, entry.name, replacements, written);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function copyFile(srcPath, destPath, name, replacements, written) {
|
|
58
|
+
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
59
|
+
if (BINARY_EXTENSIONS.has(ext)) {
|
|
60
|
+
cpSync(srcPath, destPath);
|
|
61
|
+
written.push(destPath);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
let content = readFileSync(srcPath, "utf-8");
|
|
65
|
+
for (const [token, value] of replacements) {
|
|
66
|
+
content = content.replaceAll(token, value);
|
|
67
|
+
}
|
|
68
|
+
writeFileSync(destPath, content, "utf-8");
|
|
69
|
+
written.push(destPath);
|
|
70
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure name transformations shared between profiles. No filesystem access —
|
|
3
|
+
* these turn an `appName` into the conventionally-derived route, federation
|
|
4
|
+
* name, and remote-URL env-var name.
|
|
5
|
+
*/
|
|
6
|
+
export declare function computeRoute(appName: string): string;
|
|
7
|
+
/** Module Federation `name` must be a valid JS identifier. */
|
|
8
|
+
export declare function computeFederationName(appName: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Compile-time global name. Follows the existing host pattern
|
|
11
|
+
* (HOME_UI_REMOTE_URL): strip "gdc-", uppercase, "-" → "_", add suffix.
|
|
12
|
+
*/
|
|
13
|
+
export declare function computeRemoteUrlEnvVar(appName: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Scans every harness vite.config.ts under <repoRoot>/modules/ (and apps/) for
|
|
16
|
+
* the PORT default literal, returns the next available port. The harness port
|
|
17
|
+
* vs module-federation preview port live in pairs separated by +100, hence the
|
|
18
|
+
* +1 here (caller adds +100 for the federation port).
|
|
19
|
+
*
|
|
20
|
+
* `extraDirs` adds host-shaped directories beyond `modules/` to also scan —
|
|
21
|
+
* gdc-ui includes `apps/` here, the lean repo passes nothing.
|
|
22
|
+
*/
|
|
23
|
+
export declare function computeDevPort(repoRoot: string, extraDirs?: readonly string[]): number;
|
|
24
|
+
/**
|
|
25
|
+
* Returns this package's own version, read at runtime from its package.json.
|
|
26
|
+
* Used by the client profile to stamp `workspace:*` SDK deps with a concrete
|
|
27
|
+
* version that matches the SDK release this scaffolder shipped against.
|
|
28
|
+
*
|
|
29
|
+
* Resolved via import.meta.url so it works whether the engine runs from src/
|
|
30
|
+
* (during local dev) or esm/ (after publish).
|
|
31
|
+
*/
|
|
32
|
+
export declare function getOwnPackageVersion(): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
/**
|
|
6
|
+
* Pure name transformations shared between profiles. No filesystem access —
|
|
7
|
+
* these turn an `appName` into the conventionally-derived route, federation
|
|
8
|
+
* name, and remote-URL env-var name.
|
|
9
|
+
*/
|
|
10
|
+
export function computeRoute(appName) {
|
|
11
|
+
return "/" + appName.replace(/^gdc-/, "");
|
|
12
|
+
}
|
|
13
|
+
/** Module Federation `name` must be a valid JS identifier. */
|
|
14
|
+
export function computeFederationName(appName) {
|
|
15
|
+
return appName.replaceAll("-", "_");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Compile-time global name. Follows the existing host pattern
|
|
19
|
+
* (HOME_UI_REMOTE_URL): strip "gdc-", uppercase, "-" → "_", add suffix.
|
|
20
|
+
*/
|
|
21
|
+
export function computeRemoteUrlEnvVar(appName) {
|
|
22
|
+
return appName.replace(/^gdc-/, "").replaceAll("-", "_").toUpperCase() + "_REMOTE_URL";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Scans every harness vite.config.ts under <repoRoot>/modules/ (and apps/) for
|
|
26
|
+
* the PORT default literal, returns the next available port. The harness port
|
|
27
|
+
* vs module-federation preview port live in pairs separated by +100, hence the
|
|
28
|
+
* +1 here (caller adds +100 for the federation port).
|
|
29
|
+
*
|
|
30
|
+
* `extraDirs` adds host-shaped directories beyond `modules/` to also scan —
|
|
31
|
+
* gdc-ui includes `apps/` here, the lean repo passes nothing.
|
|
32
|
+
*/
|
|
33
|
+
export function computeDevPort(repoRoot, extraDirs = []) {
|
|
34
|
+
// 8449 is the seed: gdc-sample-users sits at 8450; new apps start above it.
|
|
35
|
+
let maxPort = 8449;
|
|
36
|
+
const scanForHarnessPorts = (root, viteRelativePath) => {
|
|
37
|
+
if (!existsSync(root))
|
|
38
|
+
return;
|
|
39
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
40
|
+
if (!entry.isDirectory())
|
|
41
|
+
continue;
|
|
42
|
+
const viteConfig = join(root, entry.name, viteRelativePath);
|
|
43
|
+
if (!existsSync(viteConfig))
|
|
44
|
+
continue;
|
|
45
|
+
const content = readFileSync(viteConfig, "utf-8");
|
|
46
|
+
const portMatch = content.match(/["']PORT["']\]?\)?\s*\|\|\s*["'](\d+)["']/);
|
|
47
|
+
if (portMatch) {
|
|
48
|
+
const port = parseInt(portMatch[1], 10);
|
|
49
|
+
if (port > maxPort)
|
|
50
|
+
maxPort = port;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// The regex is tuned to the harness template shape
|
|
54
|
+
// (`env["PORT"] || "NNNN"`). A miss usually means the harness
|
|
55
|
+
// was hand-refactored — warn so the next scaffolded app's port
|
|
56
|
+
// collision doesn't surprise the user at dev-server start time.
|
|
57
|
+
console.warn(` Warning: could not detect port literal in ${viteConfig} — port collision possible.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
scanForHarnessPorts(join(repoRoot, "modules"), "harness/vite.config.ts");
|
|
62
|
+
for (const extra of extraDirs) {
|
|
63
|
+
scanForHarnessPorts(join(repoRoot, extra), "vite.config.ts");
|
|
64
|
+
}
|
|
65
|
+
return maxPort + 1;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Returns this package's own version, read at runtime from its package.json.
|
|
69
|
+
* Used by the client profile to stamp `workspace:*` SDK deps with a concrete
|
|
70
|
+
* version that matches the SDK release this scaffolder shipped against.
|
|
71
|
+
*
|
|
72
|
+
* Resolved via import.meta.url so it works whether the engine runs from src/
|
|
73
|
+
* (during local dev) or esm/ (after publish).
|
|
74
|
+
*/
|
|
75
|
+
export function getOwnPackageVersion() {
|
|
76
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
77
|
+
// engine/derived.{ts,js} → src or esm, then up one more to package root.
|
|
78
|
+
const pkgPath = join(here, "..", "..", "package.json");
|
|
79
|
+
const raw = readFileSync(pkgPath, "utf-8");
|
|
80
|
+
const parsed = JSON.parse(raw);
|
|
81
|
+
if (!parsed.version) {
|
|
82
|
+
throw new Error(`create-pluggable-module package.json at ${pkgPath} has no "version" field.`);
|
|
83
|
+
}
|
|
84
|
+
return parsed.version;
|
|
85
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walks back from the anchor line in a JSONC file and appends a comma to the
|
|
3
|
+
* last real code line if it doesn't already end with one. Idempotent: if the
|
|
4
|
+
* comma is already there (e.g. a previous scaffold added it), this is a no-op.
|
|
5
|
+
*
|
|
6
|
+
* JSONC requires a comma between array entries. The original `rush init-pluggable-app`
|
|
7
|
+
* scripts in both gdc-ui repos did this fix-up by hand; this generalises it so any
|
|
8
|
+
* profile inserting into a JSONC array can use it.
|
|
9
|
+
*
|
|
10
|
+
* Inline `// ...` comments after a JSON token are preserved exactly where they were —
|
|
11
|
+
* the comma slots into the gap between the code part and the comment, not at end of line.
|
|
12
|
+
*/
|
|
13
|
+
export declare function ensureTrailingCommaBeforeAnchor(content: string, suffix?: string): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { findAnchorLineIdx, fullAnchor } from "./anchors.js";
|
|
3
|
+
/**
|
|
4
|
+
* Walks back from the anchor line in a JSONC file and appends a comma to the
|
|
5
|
+
* last real code line if it doesn't already end with one. Idempotent: if the
|
|
6
|
+
* comma is already there (e.g. a previous scaffold added it), this is a no-op.
|
|
7
|
+
*
|
|
8
|
+
* JSONC requires a comma between array entries. The original `rush init-pluggable-app`
|
|
9
|
+
* scripts in both gdc-ui repos did this fix-up by hand; this generalises it so any
|
|
10
|
+
* profile inserting into a JSONC array can use it.
|
|
11
|
+
*
|
|
12
|
+
* Inline `// ...` comments after a JSON token are preserved exactly where they were —
|
|
13
|
+
* the comma slots into the gap between the code part and the comment, not at end of line.
|
|
14
|
+
*/
|
|
15
|
+
export function ensureTrailingCommaBeforeAnchor(content, suffix) {
|
|
16
|
+
const lines = content.split("\n");
|
|
17
|
+
// Synthetic filename — findAnchorLineIdx's error messages need a label and this
|
|
18
|
+
// helper isn't told the real path. The label only matters if the anchor is missing,
|
|
19
|
+
// which can't actually happen here because checkAnchors already verified the file.
|
|
20
|
+
const anchorIdx = findAnchorLineIdx(lines, `<jsonc with ${fullAnchor(suffix)}>`, suffix);
|
|
21
|
+
for (let i = anchorIdx - 1; i >= 0; i--) {
|
|
22
|
+
const raw = lines[i];
|
|
23
|
+
const trimmed = raw.trim();
|
|
24
|
+
if (!trimmed || trimmed.startsWith("//"))
|
|
25
|
+
continue;
|
|
26
|
+
const commentIdx = raw.indexOf("//");
|
|
27
|
+
const codePart = commentIdx === -1 ? raw : raw.slice(0, commentIdx);
|
|
28
|
+
if (!codePart.trimEnd().endsWith(",")) {
|
|
29
|
+
if (commentIdx === -1) {
|
|
30
|
+
lines[i] = raw.trimEnd() + ",";
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const code = codePart.replace(/\s+$/, "");
|
|
34
|
+
// Whitespace between token and `//`.
|
|
35
|
+
const gap = codePart.slice(code.length);
|
|
36
|
+
lines[i] = code + "," + gap + raw.slice(commentIdx);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IPromptDescriptor, IScaffoldAnswers } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Shared text-value sanity check. Blocks characters that would either break
|
|
4
|
+
* shell/script substitution (`"`, `\``, `$`, `\`), break template token
|
|
5
|
+
* substitution (`{`, `}`), or break line-based file formats (`\n`, `\r`).
|
|
6
|
+
*
|
|
7
|
+
* Exported so profiles can reuse it on their own extra prompts and keep the
|
|
8
|
+
* accepted character set consistent across the scaffolder.
|
|
9
|
+
*/
|
|
10
|
+
export declare function validateTextValue(value: string): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Drives the full prompt sequence: shared appName/title/scope, then the
|
|
13
|
+
* profile's extraPrompts in order. Returns once every answer is collected.
|
|
14
|
+
*/
|
|
15
|
+
export declare function runPrompts(extraPrompts: readonly IPromptDescriptor[]): Promise<IScaffoldAnswers>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { createInterface } from "readline";
|
|
3
|
+
/**
|
|
4
|
+
* Thin wrapper over readline's `line` events. The reason we can't use
|
|
5
|
+
* `rl.question(prompt, cb)` is that `cb` is only attached when question() runs;
|
|
6
|
+
* if input arrives faster than question() can re-attach (which happens whenever
|
|
7
|
+
* stdin is a pipe rather than a TTY), `line` events fire with no listeners and
|
|
8
|
+
* the data is lost. We buffer them ourselves so piped input works correctly —
|
|
9
|
+
* essential for non-interactive smoke tests and CI.
|
|
10
|
+
*/
|
|
11
|
+
class LineReader {
|
|
12
|
+
rl;
|
|
13
|
+
buffer = [];
|
|
14
|
+
pending;
|
|
15
|
+
closed = false;
|
|
16
|
+
constructor() {
|
|
17
|
+
this.rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
18
|
+
this.rl.on("line", (line) => {
|
|
19
|
+
if (this.pending) {
|
|
20
|
+
const fn = this.pending;
|
|
21
|
+
this.pending = undefined;
|
|
22
|
+
fn(line);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
this.buffer.push(line);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
this.rl.on("close", () => {
|
|
29
|
+
this.closed = true;
|
|
30
|
+
if (this.pending) {
|
|
31
|
+
const fn = this.pending;
|
|
32
|
+
this.pending = undefined;
|
|
33
|
+
fn(undefined);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/** Resolves with the next line, or undefined if stdin closed without one. */
|
|
38
|
+
next() {
|
|
39
|
+
if (this.pending) {
|
|
40
|
+
// Footgun-guard: two pending reads on the same LineReader would
|
|
41
|
+
// have the second silently overwrite the first's resolver. Callers
|
|
42
|
+
// must await each next() before starting the next one.
|
|
43
|
+
throw new Error("LineReader.next() called while previous next() is still pending");
|
|
44
|
+
}
|
|
45
|
+
if (this.buffer.length > 0) {
|
|
46
|
+
return Promise.resolve(this.buffer.shift());
|
|
47
|
+
}
|
|
48
|
+
if (this.closed) {
|
|
49
|
+
return Promise.resolve(undefined);
|
|
50
|
+
}
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
this.pending = resolve;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
close() {
|
|
56
|
+
this.rl.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const APP_NAME_RE = /^gdc-[a-z][a-z0-9-]*$/;
|
|
60
|
+
/**
|
|
61
|
+
* Shared text-value sanity check. Blocks characters that would either break
|
|
62
|
+
* shell/script substitution (`"`, `\``, `$`, `\`), break template token
|
|
63
|
+
* substitution (`{`, `}`), or break line-based file formats (`\n`, `\r`).
|
|
64
|
+
*
|
|
65
|
+
* Exported so profiles can reuse it on their own extra prompts and keep the
|
|
66
|
+
* accepted character set consistent across the scaffolder.
|
|
67
|
+
*/
|
|
68
|
+
export function validateTextValue(value) {
|
|
69
|
+
if (/["\\\n\r`${}]/.test(value)) {
|
|
70
|
+
return "Must not contain double quotes, backslashes, backticks, dollar signs, curly braces, or newlines.";
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
// Name format only — destination-collision checking happens later in the engine,
|
|
75
|
+
// AFTER the profile has resolved the dest root (the client profile lets the user
|
|
76
|
+
// override the destPath, so checking modules/<name> here would miss real collisions
|
|
77
|
+
// and false-positive on overrides). See runProfile.ts.
|
|
78
|
+
function validateAppName(name) {
|
|
79
|
+
if (!APP_NAME_RE.test(name)) {
|
|
80
|
+
return `App name must start with "gdc-" and contain only lowercase letters, digits, and hyphens.`;
|
|
81
|
+
}
|
|
82
|
+
if (name.startsWith("gdc-app-template")) {
|
|
83
|
+
return `Names starting with "gdc-app-template" are reserved for the template itself. Choose a different name.`;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
async function ask(reader, question) {
|
|
88
|
+
process.stdout.write(question);
|
|
89
|
+
const line = await reader.next();
|
|
90
|
+
if (line === undefined) {
|
|
91
|
+
throw new Error("stdin closed before a value was provided — non-interactive input ran out.");
|
|
92
|
+
}
|
|
93
|
+
return line;
|
|
94
|
+
}
|
|
95
|
+
async function askUntilValid(reader, label, validate) {
|
|
96
|
+
while (true) {
|
|
97
|
+
const value = (await ask(reader, ` ${label}: `)).trim();
|
|
98
|
+
const err = validate(value);
|
|
99
|
+
if (!err)
|
|
100
|
+
return value;
|
|
101
|
+
console.log(` Error: ${err}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function askScope(reader) {
|
|
105
|
+
while (true) {
|
|
106
|
+
const choice = (await ask(reader, " Application scope (1 = workspace, 2 = organization): ")).trim();
|
|
107
|
+
if (choice === "1")
|
|
108
|
+
return "workspace";
|
|
109
|
+
if (choice === "2")
|
|
110
|
+
return "organization";
|
|
111
|
+
console.log(' Error: Enter "1" or "2".');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Drives the full prompt sequence: shared appName/title/scope, then the
|
|
116
|
+
* profile's extraPrompts in order. Returns once every answer is collected.
|
|
117
|
+
*/
|
|
118
|
+
export async function runPrompts(extraPrompts) {
|
|
119
|
+
const reader = new LineReader();
|
|
120
|
+
try {
|
|
121
|
+
console.log("\n Scaffold a new pluggable application\n");
|
|
122
|
+
const appName = await askUntilValid(reader, "App name (must start with gdc-)", validateAppName);
|
|
123
|
+
const title = await askUntilValid(reader, "en-US title", (v) => {
|
|
124
|
+
if (!v)
|
|
125
|
+
return "Title is required.";
|
|
126
|
+
return validateTextValue(v);
|
|
127
|
+
});
|
|
128
|
+
const scope = await askScope(reader);
|
|
129
|
+
const answers = { appName, title, scope };
|
|
130
|
+
for (const prompt of extraPrompts) {
|
|
131
|
+
const value = await askExtra(reader, prompt);
|
|
132
|
+
if (value !== undefined) {
|
|
133
|
+
answers[prompt.key] = value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return answers;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
reader.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function askExtra(reader, prompt) {
|
|
143
|
+
while (true) {
|
|
144
|
+
const suffix = prompt.optional ? " (optional, press Enter to skip)" : "";
|
|
145
|
+
const value = (await ask(reader, ` ${prompt.label}${suffix}: `)).trim();
|
|
146
|
+
if (!value) {
|
|
147
|
+
if (prompt.optional)
|
|
148
|
+
return undefined;
|
|
149
|
+
console.log(" Error: A value is required.");
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const err = prompt.validate ? prompt.validate(value) : null;
|
|
153
|
+
if (!err)
|
|
154
|
+
return value;
|
|
155
|
+
console.log(` Error: ${err}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IRepoInfo } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Walks from `startDir` upward looking for a marker file. Returns the directory
|
|
4
|
+
* containing the marker, or undefined if reached the filesystem root without
|
|
5
|
+
* finding it. Used to locate `rush.json` from any subdir within a Rush monorepo.
|
|
6
|
+
*/
|
|
7
|
+
export declare function findUp(startDir: string, marker: string): string | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Detects the repo shape from `cwd`. The result drives:
|
|
10
|
+
* - which postCopy command runs (rush update vs npm install vs ...)
|
|
11
|
+
* - whether profiles register in rush.json
|
|
12
|
+
* - the absolute `repoRoot` that other engine steps anchor against.
|
|
13
|
+
*
|
|
14
|
+
* Precedence: rush.json wins (Rush monorepos always have a lockfile too).
|
|
15
|
+
* Otherwise, pick the package manager from whichever lockfile exists. Falls
|
|
16
|
+
* back to `npm` for fresh repos with no lockfile yet.
|
|
17
|
+
*/
|
|
18
|
+
export declare function detectRepo(cwd: string): IRepoInfo;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// (C) 2026 GoodData Corporation
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
|
+
/**
|
|
5
|
+
* Walks from `startDir` upward looking for a marker file. Returns the directory
|
|
6
|
+
* containing the marker, or undefined if reached the filesystem root without
|
|
7
|
+
* finding it. Used to locate `rush.json` from any subdir within a Rush monorepo.
|
|
8
|
+
*/
|
|
9
|
+
export function findUp(startDir, marker) {
|
|
10
|
+
let dir = resolve(startDir);
|
|
11
|
+
while (true) {
|
|
12
|
+
if (existsSync(join(dir, marker)))
|
|
13
|
+
return dir;
|
|
14
|
+
const parent = resolve(dir, "..");
|
|
15
|
+
if (parent === dir)
|
|
16
|
+
return undefined;
|
|
17
|
+
dir = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Detects the repo shape from `cwd`. The result drives:
|
|
22
|
+
* - which postCopy command runs (rush update vs npm install vs ...)
|
|
23
|
+
* - whether profiles register in rush.json
|
|
24
|
+
* - the absolute `repoRoot` that other engine steps anchor against.
|
|
25
|
+
*
|
|
26
|
+
* Precedence: rush.json wins (Rush monorepos always have a lockfile too).
|
|
27
|
+
* Otherwise, pick the package manager from whichever lockfile exists. Falls
|
|
28
|
+
* back to `npm` for fresh repos with no lockfile yet.
|
|
29
|
+
*/
|
|
30
|
+
export function detectRepo(cwd) {
|
|
31
|
+
const rushRoot = findUp(cwd, "rush.json");
|
|
32
|
+
if (rushRoot) {
|
|
33
|
+
return { repoRoot: rushRoot, packageManager: "rush" };
|
|
34
|
+
}
|
|
35
|
+
const repoRoot = resolve(cwd);
|
|
36
|
+
const packageManager = detectPackageManager(repoRoot);
|
|
37
|
+
return { repoRoot, packageManager };
|
|
38
|
+
}
|
|
39
|
+
function detectPackageManager(repoRoot) {
|
|
40
|
+
if (existsSync(join(repoRoot, "pnpm-lock.yaml")))
|
|
41
|
+
return "pnpm";
|
|
42
|
+
if (existsSync(join(repoRoot, "yarn.lock")))
|
|
43
|
+
return "yarn";
|
|
44
|
+
// package-lock.json or no lockfile at all → default to npm. A scaffolder
|
|
45
|
+
// running in a fresh empty repo can still invoke `npm install` afterwards.
|
|
46
|
+
return "npm";
|
|
47
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IRegistrationStep, IRepoInfo, IScaffoldAnswers, IScaffoldProfile } from "../types.js";
|
|
2
|
+
export declare function runProfile(profile: IScaffoldProfile): Promise<void>;
|
|
3
|
+
/**
|
|
4
|
+
* Test-friendly variant: skip prompt collection, take answers as input.
|
|
5
|
+
* Also useful for programmatic consumers that want to drive the scaffolder
|
|
6
|
+
* without an interactive TTY.
|
|
7
|
+
*
|
|
8
|
+
* @alpha
|
|
9
|
+
*/
|
|
10
|
+
export declare function runProfileWithAnswers(profile: IScaffoldProfile, answers: IScaffoldAnswers, options?: {
|
|
11
|
+
/** Optional repo override (defaults to detecting from process.cwd()). */
|
|
12
|
+
repo?: IRepoInfo;
|
|
13
|
+
/**
|
|
14
|
+
* Optional pre-resolved registration steps. The engine usually recomputes
|
|
15
|
+
* these from `profile.registrations(repo)`; tests can pass a fixture set.
|
|
16
|
+
*/
|
|
17
|
+
steps?: readonly IRegistrationStep[];
|
|
18
|
+
}): Promise<void>;
|