Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"devDependencies": {
"@better-builds/ts-duality": "catalog:",
"@pythnetwork/create-pyth-package": "workspace:",
"@cprussin/tsconfig": "catalog:",
"@cprussin/prettier-config": "catalog:",
"prettier": "catalog:",
Expand Down
26 changes: 26 additions & 0 deletions packages/create-pyth-package/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Coverage directory used by tools like istanbul
coverage

# Dependency directories
node_modules/

# Optional npm cache directory
.npm

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# dotenv environment variables file
.env

# Build directory
dist/
lib/

tsconfig.tsbuildinfo

# we want to keep binfiles here
!bin/
12 changes: 12 additions & 0 deletions packages/create-pyth-package/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.vscode/
coverage/
dist/
doc/
doc*/
node_modules/
dist/
lib/
build/
node_modules/
package.json
tsconfig*.json
6 changes: 6 additions & 0 deletions packages/create-pyth-package/bin/create-pyth-package
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/bin/bash

THISDIR="$(dirname $0)"
THISPKGDIR="$(realpath $THISDIR/..)"

pnpm --dir "$THISPKGDIR" start
4 changes: 4 additions & 0 deletions packages/create-pyth-package/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { base } from "@cprussin/eslint-config";
import { globalIgnores } from "eslint/config";

export default [globalIgnores(["src/templates/**"]), ...base];
44 changes: 44 additions & 0 deletions packages/create-pyth-package/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"private": true,
"name": "@pythnetwork/create-pyth-package",
"description": "bootstrapper to quickly create a best-practices TypeScript library or application",
"version": "0.0.0",
"type": "module",
"bin": {
"create-pyth-package": "./bin/create-pyth-package"
},
"engines": {
"pnpm": ">=10.19.0",
"node": ">=22.14.0"
},
"files": [
"bin/**",
"src/**"
],
"scripts": {
"fix:lint": "eslint --fix . --max-warnings 0",
"fix:format": "prettier --write .",
"test:lint": "eslint . --max-warnings 0",
"test:format": "prettier --check .",
"start": "tsx ./src/create-pyth-package.ts",
"test:types": "tsc"
},
"dependencies": {
"app-root-path": "catalog:",
"chalk": "catalog:",
"fast-glob": "catalog:",
"fs-extra": "catalog:",
"micromustache": "catalog:",
"prompts": "catalog:",
"tsx": "catalog:"
},
"devDependencies": {
"@cprussin/tsconfig": "catalog:",
"@cprussin/eslint-config": "catalog:",
"@types/fs-extra": "catalog:",
"@types/prompts": "catalog:",
"eslint": "catalog:",
"prettier": "catalog:",
"type-fest": "catalog:"
}
}
253 changes: 253 additions & 0 deletions packages/create-pyth-package/src/create-pyth-package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
/* eslint-disable tsdoc/syntax */
// this rule is absolutely broken for the typings the prompts library
// provides, so we need to hard-disable it for all usages of prompts

import { execSync } from "node:child_process";
import os from "node:os";
import path from "node:path";

import appRootPath from "app-root-path";
import chalk from "chalk";
import glob from "fast-glob";
import fs from "fs-extra";
import { render as renderTemplate } from "micromustache";
import prompts from "prompts";
import type { PackageJson } from "type-fest";

import { getAvailableFolders } from "./get-available-folders.js";
import { getTakenPorts } from "./get-taken-ports.js";
import { Logger } from "./logger.js";
import type {
CreatePythAppResponses,
InProgressCreatePythAppResponses,
} from "./types.js";
import { PACKAGE_PREFIX, PackageType, TEMPLATES_FOLDER } from "./types.js";

/**
* Given either a raw name ("foo") or a scoped name ("@pythnetwork/foo"),
* returns the normalized pair { raw, withOrg } where:
* - raw is the unscoped package name ("foo")
* - withOrg is the scoped package name ("@pythnetwork/foo")
*/
function normalizePackageNameInput(val: string | null | undefined = "") {
// if the user passed a scoped name already, extract the part after `/`
if (val?.startsWith("@")) {
const parts = val.split("/");
const raw = parts[1] ?? "";
return {
raw,
withOrg: `${PACKAGE_PREFIX}${raw}`,
};
}
// otherwise treat input as raw
const raw = val ?? "";
return {
raw,
withOrg: `${PACKAGE_PREFIX}${raw}`,
};
}

/**
* returns the folder that holds the correct templates, based on the user's
* package choice
*/
function getTemplatesInputFolder(packageType: PackageType) {
switch (packageType) {
case PackageType.CLI: {
return path.join(TEMPLATES_FOLDER, "cli");
}
case PackageType.LIBRARY: {
return path.join(TEMPLATES_FOLDER, "library");
}
case PackageType.WEBAPP: {
return path.join(TEMPLATES_FOLDER, "web-app");
}
default: {
throw new Error(
`unsupported package type of "${String(packageType)}" was found`,
);
}
}
}

async function createPythApp() {
const takenServerPorts = getTakenPorts();

const responses = (await prompts([
{
choices: Object.values(PackageType).map((val) => ({
title: val,
value: val,
})),
message: "Which type of package do you want to create?",
name: "packageType",
type: "select",
},
{
// Store the raw name (no format). We'll normalize after prompts
message: (_, responses: InProgressCreatePythAppResponses) =>
`Enter the name for your ${responses.packageType ?? ""} package. ${chalk.magenta(PACKAGE_PREFIX)}`,
name: "packageName",
type: "text",
validate: (name: string) => {
// validate using the full scoped candidate so we ensure the raw name is valid
const proposedName = `${PACKAGE_PREFIX}${name.replace(/^@.*\//, "")}`;
const pjsonNameRegexp = /^@pythnetwork\/(\w)(\w|\d|_|-)+$/;
return (
pjsonNameRegexp.test(proposedName) ||
"Please enter a valid package name (you do not need to add @pythnetwork/ as a prefix, it will be added automatically)"
);
},
},
{
message: "Enter a brief, friendly description for your package",
name: "description",
type: "text",
},
{
choices: (_, { packageType }: InProgressCreatePythAppResponses) =>
getAvailableFolders()
.map((val) => ({
title: val,
value: val,
}))
.filter(
({ value: relPath }) =>
packageType !== PackageType.WEBAPP && !relPath.startsWith("apps"),
),
message: "Where do you want your package to live?",
name: "folder",
type: (_, { packageType }: InProgressCreatePythAppResponses) =>
packageType === PackageType.WEBAPP ? false : "select",
},
{
message:
"On which port do you want your web application server to listen?",
name: "serverPort",
type: (_, { packageType }: InProgressCreatePythAppResponses) =>
packageType === PackageType.WEBAPP ? "number" : false,
validate: (port: number | string) => {
const portStr = String(port);
const taken = takenServerPorts.has(Number(port));
const portHasFourDigits = portStr.length >= 4;
if (taken) {
return `${portStr} is already taken by another application. Please choose another port.`;
}
if (!portHasFourDigits) {
return "please specify a port that has at least 4 digits";
}
return true;
},
},
{
message:
"Are you intending on publishing this, publicly on NPM, for users outside of our org to use?",
name: "isPublic",
type: (_, { packageType }: InProgressCreatePythAppResponses) =>
packageType === PackageType.WEBAPP ? false : "confirm",
},
{
message: (
_,
{ folder, packageName, packageType }: InProgressCreatePythAppResponses,
) => {
// normalize for display
const { raw: pkgRaw, withOrg: pkgWithOrg } =
normalizePackageNameInput(packageName);

let msg = `Please confirm your choices:${os.EOL}`;
msg += `Creating a ${chalk.magenta(packageType)} package, named ${chalk.magenta(pkgWithOrg)}, in ${chalk.magenta(packageType === PackageType.WEBAPP ? "apps" : folder)}/${pkgRaw}.${os.EOL}`;
msg += "Look good?";

return msg;
},
name: "confirm",
type: "confirm",
},
])) as CreatePythAppResponses;

const {
confirm,
description,
folder,
isPublic,
packageName,
packageType,
serverPort,
} = responses;

if (!confirm) {
Logger.warn("oops, you did not confirm your choices.");
return;
}

// normalize package-name inputs to deterministic values
const { raw: packageNameWithoutOrg, withOrg: packageNameWithOrg } =
normalizePackageNameInput(packageName);

const relDest =
packageType === PackageType.WEBAPP
? path.join("apps", packageNameWithoutOrg)
: path.join(folder, packageNameWithoutOrg);
const absDest = path.join(appRootPath.toString(), relDest);

Logger.info("ensuring", relDest, `exists (abs path: ${absDest})`);
await fs.ensureDir(absDest);

Logger.info("copying files");
const templateInputFolder = getTemplatesInputFolder(packageType);
await fs.copy(templateInputFolder, absDest, { overwrite: true });

const destFiles = await glob(path.join(absDest, "**", "*"), {
absolute: true,
dot: true,
onlyFiles: true,
});

Logger.info(
"updating files with the choices you made in the initial prompts",
);
await Promise.all(
destFiles
.filter((fp) => !fp.includes("node_module"))
.map(async (fp) => {
const contents = await fs.readFile(fp, "utf8");
const updatedContents = renderTemplate(contents, {
description,
name: packageNameWithOrg,
packageNameWithoutOrg,
relativeFolder: relDest,
serverPort,
});
await fs.writeFile(fp, updatedContents, "utf8");

if (fp.endsWith("package.json")) {
const pjson = JSON.parse(updatedContents) as PackageJson;
// ensure package name in package.json is the scoped name
pjson.name = packageNameWithOrg;
pjson.private = !isPublic;
if (isPublic) {
pjson.publishConfig = {
access: "public",
};
} else {
// ensure publishConfig is removed if present and not public
if (pjson.publishConfig) {
delete pjson.publishConfig;
}
}

await fs.writeFile(fp, JSON.stringify(pjson, undefined, 2), "utf8");
}
}),
);

Logger.info("installing deps");
execSync("pnpm i", { cwd: appRootPath.toString(), stdio: "inherit" });

Logger.info(`Done! ${packageNameWithOrg} is ready for development`);
Logger.info("please checkout your package's README for more information:");
Logger.info(` ${path.join(relDest, "README.md")}`);
}

await createPythApp();
25 changes: 25 additions & 0 deletions packages/create-pyth-package/src/get-all-monorepo-packages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { execSync } from "node:child_process";

import appRootPath from "app-root-path";

export type PNPMPackageInfo = {
name: string;
path: string;
private: boolean;
version: string;
};

/**
* returns basic info about all of the monorepo packages available in
* the pyth crosschain repo
*/
export function getAllMonorepoPackages(repoRoot = appRootPath.toString()) {
const allPackages = JSON.parse(
execSync("pnpm list --recursive --depth -1 --json", {
cwd: repoRoot,
stdio: "pipe",
}).toString("utf8"),
) as PNPMPackageInfo[];

return allPackages;
}
Loading
Loading