Skip to content

Commit 29c387a

Browse files
authored
Merge pull request #3185 from pyth-network/bduran/create-pyth-app
feat(create-pyth-package): added a create-pyth-package script to generate things with best practices baked-in
2 parents 6f83f1f + 0d3d68c commit 29c387a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+2036
-464
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
},
1515
"devDependencies": {
1616
"@better-builds/ts-duality": "catalog:",
17+
"@pythnetwork/create-pyth-package": "workspace:",
1718
"@cprussin/tsconfig": "catalog:",
1819
"@cprussin/prettier-config": "catalog:",
1920
"prettier": "catalog:",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Coverage directory used by tools like istanbul
2+
coverage
3+
4+
# Dependency directories
5+
node_modules/
6+
7+
# Optional npm cache directory
8+
.npm
9+
10+
# Optional REPL history
11+
.node_repl_history
12+
13+
# Output of 'npm pack'
14+
*.tgz
15+
16+
# dotenv environment variables file
17+
.env
18+
19+
# Build directory
20+
dist/
21+
lib/
22+
23+
tsconfig.tsbuildinfo
24+
25+
# we want to keep binfiles here
26+
!bin/
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
.vscode/
2+
coverage/
3+
dist/
4+
doc/
5+
doc*/
6+
node_modules/
7+
dist/
8+
lib/
9+
build/
10+
node_modules/
11+
package.json
12+
tsconfig*.json
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/bin/bash
2+
3+
THISDIR="$(dirname $0)"
4+
THISPKGDIR="$(realpath $THISDIR/..)"
5+
6+
pnpm --dir "$THISPKGDIR" start
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { base } from "@cprussin/eslint-config";
2+
import { globalIgnores } from "eslint/config";
3+
4+
export default [globalIgnores(["src/templates/**"]), ...base];
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"private": true,
3+
"name": "@pythnetwork/create-pyth-package",
4+
"description": "bootstrapper to quickly create a best-practices TypeScript library or application",
5+
"version": "0.0.0",
6+
"type": "module",
7+
"bin": {
8+
"create-pyth-package": "./bin/create-pyth-package"
9+
},
10+
"engines": {
11+
"pnpm": ">=10.19.0",
12+
"node": ">=22.14.0"
13+
},
14+
"files": [
15+
"bin/**",
16+
"src/**"
17+
],
18+
"scripts": {
19+
"fix:lint": "eslint --fix . --max-warnings 0",
20+
"fix:format": "prettier --write .",
21+
"test:lint": "eslint . --max-warnings 0",
22+
"test:format": "prettier --check .",
23+
"start": "tsx ./src/create-pyth-package.ts",
24+
"test:types": "tsc"
25+
},
26+
"dependencies": {
27+
"app-root-path": "catalog:",
28+
"chalk": "catalog:",
29+
"fast-glob": "catalog:",
30+
"fs-extra": "catalog:",
31+
"micromustache": "catalog:",
32+
"prompts": "catalog:",
33+
"tsx": "catalog:"
34+
},
35+
"devDependencies": {
36+
"@cprussin/tsconfig": "catalog:",
37+
"@cprussin/eslint-config": "catalog:",
38+
"@types/fs-extra": "catalog:",
39+
"@types/prompts": "catalog:",
40+
"eslint": "catalog:",
41+
"prettier": "catalog:",
42+
"type-fest": "catalog:"
43+
}
44+
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/* eslint-disable tsdoc/syntax */
2+
// this rule is absolutely broken for the typings the prompts library
3+
// provides, so we need to hard-disable it for all usages of prompts
4+
5+
import { execSync } from "node:child_process";
6+
import os from "node:os";
7+
import path from "node:path";
8+
9+
import appRootPath from "app-root-path";
10+
import chalk from "chalk";
11+
import glob from "fast-glob";
12+
import fs from "fs-extra";
13+
import { render as renderTemplate } from "micromustache";
14+
import prompts from "prompts";
15+
import type { PackageJson } from "type-fest";
16+
17+
import { getAvailableFolders } from "./get-available-folders.js";
18+
import { getTakenPorts } from "./get-taken-ports.js";
19+
import { Logger } from "./logger.js";
20+
import type {
21+
CreatePythAppResponses,
22+
InProgressCreatePythAppResponses,
23+
} from "./types.js";
24+
import { PACKAGE_PREFIX, PackageType, TEMPLATES_FOLDER } from "./types.js";
25+
26+
/**
27+
* Given either a raw name ("foo") or a scoped name ("@pythnetwork/foo"),
28+
* returns the normalized pair { raw, withOrg } where:
29+
* - raw is the unscoped package name ("foo")
30+
* - withOrg is the scoped package name ("@pythnetwork/foo")
31+
*/
32+
function normalizePackageNameInput(val: string | null | undefined = "") {
33+
// if the user passed a scoped name already, extract the part after `/`
34+
if (val?.startsWith("@")) {
35+
const parts = val.split("/");
36+
const raw = parts[1] ?? "";
37+
return {
38+
raw,
39+
withOrg: `${PACKAGE_PREFIX}${raw}`,
40+
};
41+
}
42+
// otherwise treat input as raw
43+
const raw = val ?? "";
44+
return {
45+
raw,
46+
withOrg: `${PACKAGE_PREFIX}${raw}`,
47+
};
48+
}
49+
50+
/**
51+
* returns the folder that holds the correct templates, based on the user's
52+
* package choice
53+
*/
54+
function getTemplatesInputFolder(packageType: PackageType) {
55+
switch (packageType) {
56+
case PackageType.CLI: {
57+
return path.join(TEMPLATES_FOLDER, "cli");
58+
}
59+
case PackageType.LIBRARY: {
60+
return path.join(TEMPLATES_FOLDER, "library");
61+
}
62+
case PackageType.WEBAPP: {
63+
return path.join(TEMPLATES_FOLDER, "web-app");
64+
}
65+
default: {
66+
throw new Error(
67+
`unsupported package type of "${String(packageType)}" was found`,
68+
);
69+
}
70+
}
71+
}
72+
73+
async function createPythApp() {
74+
const takenServerPorts = getTakenPorts();
75+
76+
const responses = (await prompts([
77+
{
78+
choices: Object.values(PackageType).map((val) => ({
79+
title: val,
80+
value: val,
81+
})),
82+
message: "Which type of package do you want to create?",
83+
name: "packageType",
84+
type: "select",
85+
},
86+
{
87+
// Store the raw name (no format). We'll normalize after prompts
88+
message: (_, responses: InProgressCreatePythAppResponses) =>
89+
`Enter the name for your ${responses.packageType ?? ""} package. ${chalk.magenta(PACKAGE_PREFIX)}`,
90+
name: "packageName",
91+
type: "text",
92+
validate: (name: string) => {
93+
// validate using the full scoped candidate so we ensure the raw name is valid
94+
const proposedName = `${PACKAGE_PREFIX}${name.replace(/^@.*\//, "")}`;
95+
const pjsonNameRegexp = /^@pythnetwork\/(\w)(\w|\d|_|-)+$/;
96+
return (
97+
pjsonNameRegexp.test(proposedName) ||
98+
"Please enter a valid package name (you do not need to add @pythnetwork/ as a prefix, it will be added automatically)"
99+
);
100+
},
101+
},
102+
{
103+
message: "Enter a brief, friendly description for your package",
104+
name: "description",
105+
type: "text",
106+
},
107+
{
108+
choices: (_, { packageType }: InProgressCreatePythAppResponses) =>
109+
getAvailableFolders()
110+
.map((val) => ({
111+
title: val,
112+
value: val,
113+
}))
114+
.filter(
115+
({ value: relPath }) =>
116+
packageType !== PackageType.WEBAPP && !relPath.startsWith("apps"),
117+
),
118+
message: "Where do you want your package to live?",
119+
name: "folder",
120+
type: (_, { packageType }: InProgressCreatePythAppResponses) =>
121+
packageType === PackageType.WEBAPP ? false : "select",
122+
},
123+
{
124+
message:
125+
"On which port do you want your web application server to listen?",
126+
name: "serverPort",
127+
type: (_, { packageType }: InProgressCreatePythAppResponses) =>
128+
packageType === PackageType.WEBAPP ? "number" : false,
129+
validate: (port: number | string) => {
130+
const portStr = String(port);
131+
const taken = takenServerPorts.has(Number(port));
132+
const portHasFourDigits = portStr.length >= 4;
133+
if (taken) {
134+
return `${portStr} is already taken by another application. Please choose another port.`;
135+
}
136+
if (!portHasFourDigits) {
137+
return "please specify a port that has at least 4 digits";
138+
}
139+
return true;
140+
},
141+
},
142+
{
143+
message:
144+
"Are you intending on publishing this, publicly on NPM, for users outside of our org to use?",
145+
name: "isPublic",
146+
type: (_, { packageType }: InProgressCreatePythAppResponses) =>
147+
packageType === PackageType.WEBAPP ? false : "confirm",
148+
},
149+
{
150+
message: (
151+
_,
152+
{ folder, packageName, packageType }: InProgressCreatePythAppResponses,
153+
) => {
154+
// normalize for display
155+
const { raw: pkgRaw, withOrg: pkgWithOrg } =
156+
normalizePackageNameInput(packageName);
157+
158+
let msg = `Please confirm your choices:${os.EOL}`;
159+
msg += `Creating a ${chalk.magenta(packageType)} package, named ${chalk.magenta(pkgWithOrg)}, in ${chalk.magenta(packageType === PackageType.WEBAPP ? "apps" : folder)}/${pkgRaw}.${os.EOL}`;
160+
msg += "Look good?";
161+
162+
return msg;
163+
},
164+
name: "confirm",
165+
type: "confirm",
166+
},
167+
])) as CreatePythAppResponses;
168+
169+
const {
170+
confirm,
171+
description,
172+
folder,
173+
isPublic,
174+
packageName,
175+
packageType,
176+
serverPort,
177+
} = responses;
178+
179+
if (!confirm) {
180+
Logger.warn("oops, you did not confirm your choices.");
181+
return;
182+
}
183+
184+
// normalize package-name inputs to deterministic values
185+
const { raw: packageNameWithoutOrg, withOrg: packageNameWithOrg } =
186+
normalizePackageNameInput(packageName);
187+
188+
const relDest =
189+
packageType === PackageType.WEBAPP
190+
? path.join("apps", packageNameWithoutOrg)
191+
: path.join(folder, packageNameWithoutOrg);
192+
const absDest = path.join(appRootPath.toString(), relDest);
193+
194+
Logger.info("ensuring", relDest, `exists (abs path: ${absDest})`);
195+
await fs.ensureDir(absDest);
196+
197+
Logger.info("copying files");
198+
const templateInputFolder = getTemplatesInputFolder(packageType);
199+
await fs.copy(templateInputFolder, absDest, { overwrite: true });
200+
201+
const destFiles = await glob(path.join(absDest, "**", "*"), {
202+
absolute: true,
203+
dot: true,
204+
onlyFiles: true,
205+
});
206+
207+
Logger.info(
208+
"updating files with the choices you made in the initial prompts",
209+
);
210+
await Promise.all(
211+
destFiles
212+
.filter((fp) => !fp.includes("node_module"))
213+
.map(async (fp) => {
214+
const contents = await fs.readFile(fp, "utf8");
215+
const updatedContents = renderTemplate(contents, {
216+
description,
217+
name: packageNameWithOrg,
218+
packageNameWithoutOrg,
219+
relativeFolder: relDest,
220+
serverPort,
221+
});
222+
await fs.writeFile(fp, updatedContents, "utf8");
223+
224+
if (fp.endsWith("package.json")) {
225+
const pjson = JSON.parse(updatedContents) as PackageJson;
226+
// ensure package name in package.json is the scoped name
227+
pjson.name = packageNameWithOrg;
228+
pjson.private = !isPublic;
229+
if (isPublic) {
230+
pjson.publishConfig = {
231+
access: "public",
232+
};
233+
} else {
234+
// ensure publishConfig is removed if present and not public
235+
if (pjson.publishConfig) {
236+
delete pjson.publishConfig;
237+
}
238+
}
239+
240+
await fs.writeFile(fp, JSON.stringify(pjson, undefined, 2), "utf8");
241+
}
242+
}),
243+
);
244+
245+
Logger.info("installing deps");
246+
execSync("pnpm i", { cwd: appRootPath.toString(), stdio: "inherit" });
247+
248+
Logger.info(`Done! ${packageNameWithOrg} is ready for development`);
249+
Logger.info("please checkout your package's README for more information:");
250+
Logger.info(` ${path.join(relDest, "README.md")}`);
251+
}
252+
253+
await createPythApp();
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { execSync } from "node:child_process";
2+
3+
import appRootPath from "app-root-path";
4+
5+
export type PNPMPackageInfo = {
6+
name: string;
7+
path: string;
8+
private: boolean;
9+
version: string;
10+
};
11+
12+
/**
13+
* returns basic info about all of the monorepo packages available in
14+
* the pyth crosschain repo
15+
*/
16+
export function getAllMonorepoPackages(repoRoot = appRootPath.toString()) {
17+
const allPackages = JSON.parse(
18+
execSync("pnpm list --recursive --depth -1 --json", {
19+
cwd: repoRoot,
20+
stdio: "pipe",
21+
}).toString("utf8"),
22+
) as PNPMPackageInfo[];
23+
24+
return allPackages;
25+
}

0 commit comments

Comments
 (0)