diff --git a/pkgs/create-neon/data/templates/.gitignore.hbs b/pkgs/create-neon/data/templates/.gitignore.hbs index 17b9e6149..af4906d12 100644 --- a/pkgs/create-neon/data/templates/.gitignore.hbs +++ b/pkgs/create-neon/data/templates/.gitignore.hbs @@ -2,7 +2,7 @@ target index.node **/node_modules **/.DS_Store -npm-debug.log*{{#eq packageSpec.library.lang compare="ts"}} +npm-debug.log*{{#eq options.library.lang compare="ts"}} lib {{/eq}} cargo.log diff --git a/pkgs/create-neon/data/templates/Cargo.toml.hbs b/pkgs/create-neon/data/templates/Cargo.toml.hbs index fbb985e3a..54eebb01b 100644 --- a/pkgs/create-neon/data/templates/Cargo.toml.hbs +++ b/pkgs/create-neon/data/templates/Cargo.toml.hbs @@ -1,14 +1,14 @@ [package] -name = "{{package.name}}" -version = "{{package.version}}" -{{#if package.description}} -description = {{package.quotedDescription}} +name = {{crate.escaped.name}} +version = {{crate.escaped.version}} +{{#if crate.description}} +description = {{crate.escaped.description}} {{/if}} -{{#if package.author}} -authors = [{{package.quotedAuthor}}] +{{#if crate.author}} +authors = [{{crate.escaped.author}}] {{/if}} -{{#if package.license}} -license = "{{package.license}}" +{{#if crate.license}} +license = {{crate.escaped.license}} {{/if}} edition = "2021" exclude = ["index.node"] diff --git a/pkgs/create-neon/data/templates/README.md.hbs b/pkgs/create-neon/data/templates/README.md.hbs index d4b6b739b..f5467a981 100644 --- a/pkgs/create-neon/data/templates/README.md.hbs +++ b/pkgs/create-neon/data/templates/README.md.hbs @@ -22,7 +22,7 @@ This command uses the [@neon-rs/cli](https://www.npmjs.com/package/@neon-rs/cli) After building {{package.name}}, you can explore its exports at the Node console: -{{#if packageSpec.library}} +{{#if options.library}} ```sh $ npm i $ npm run build @@ -44,7 +44,7 @@ $ node In the project directory, you can run: -{{#unless packageSpec.library}} +{{#unless options.library}} #### `npm install` Installs the project, including running `npm run build`. @@ -68,7 +68,7 @@ Similar to `npm run build` but generates a debug build with `cargo`. Similar to `npm run build` but uses [cross-rs](https://github.com/cross-rs/cross) to cross-compile for another platform. Use the [`CARGO_BUILD_TARGET`](https://doc.rust-lang.org/cargo/reference/config.html#buildtarget) environment variable to select the build target. -{{#eq packageSpec.library.ci.type compare="github"}} +{{#eq options.library.ci.type compare="github"}} #### `npm run release` Initiate a full build and publication of a new patch release of this library via GitHub Actions. @@ -90,9 +90,9 @@ The directory structure of this project is: {{package.name}}/ ├── Cargo.toml ├── README.md -{{#if packageSpec.library}} +{{#if options.library}} ├── lib/ -{{#eq packageSpec.library.lang compare="ts"}} +{{#eq options.library.lang compare="ts"}} ├── src/ | ├── index.mts | └── index.cts @@ -115,8 +115,8 @@ The directory structure of this project is: |----------------|------------------------------------------------------------------------------------------------------------------------------------------| | `Cargo.toml` | The Cargo [manifest file](https://doc.rust-lang.org/cargo/reference/manifest.html), which informs the `cargo` command. | | `README.md` | This file. | -{{#if packageSpec.library}} -{{#eq packageSpec.library.lang compare="ts"}} +{{#if options.library}} +{{#eq options.library.lang compare="ts"}} | `lib/` | The directory containing the generated output from [tsc](https://typescriptlang.org). | | `src/` | The directory containing the TypeScript source files. | | `index.mts` | Entry point for when this library is loaded via [ESM `import`](https://nodejs.org/api/esm.html#modules-ecmascript-modules) syntax. | diff --git a/pkgs/create-neon/data/templates/Workspace.toml.hbs b/pkgs/create-neon/data/templates/Workspace.toml.hbs index 64a80289c..84b0f21ad 100644 --- a/pkgs/create-neon/data/templates/Workspace.toml.hbs +++ b/pkgs/create-neon/data/templates/Workspace.toml.hbs @@ -1,3 +1,3 @@ [workspace] -members = ["crates/{{package.name}}"] +members = ["crates/{{crate.name}}"] resolver = "2" diff --git a/pkgs/create-neon/data/templates/ci/github/manifest/scripts.json.hbs b/pkgs/create-neon/data/templates/ci/github/manifest/scripts.json.hbs deleted file mode 100644 index 368ed351e..000000000 --- a/pkgs/create-neon/data/templates/ci/github/manifest/scripts.json.hbs +++ /dev/null @@ -1,4 +0,0 @@ -{ - "release": "gh workflow run release.yml -f dryrun=false -f version=patch", - "dryrun": "gh workflow run publish.yml -f dryrun=true" -} diff --git a/pkgs/create-neon/data/templates/manifest/base/default.json.hbs b/pkgs/create-neon/data/templates/manifest/base/default.json.hbs index 1da7e61da..2d02d4e29 100644 --- a/pkgs/create-neon/data/templates/manifest/base/default.json.hbs +++ b/pkgs/create-neon/data/templates/manifest/base/default.json.hbs @@ -1,11 +1,11 @@ { - "name": "{{packageSpec.name}}", - "version": "{{packageSpec.version}}", + "name": "{{options.name}}", + "version": "{{options.version}}", "main": "index.node", "scripts": {}, "devDependencies": { - "@neon-rs/cli": "{{versions.neonCLI}}"{{#eq packageSpec.library.lang compare="ts"}}, + "@neon-rs/cli": "{{versions.neonCLI}}"{{#eq options.library.lang compare="ts"}}, "@tsconfig/node{{versions.tsconfigNode.major}}": "^{{versions.tsconfigNode.semver}}", "@types/node": "^{{versions.typesNode}}", "typescript": "^{{versions.typescript}}"{{/eq}} diff --git a/pkgs/create-neon/data/templates/manifest/base/library.json.hbs b/pkgs/create-neon/data/templates/manifest/base/library.json.hbs index 902289b51..361d581e3 100644 --- a/pkgs/create-neon/data/templates/manifest/base/library.json.hbs +++ b/pkgs/create-neon/data/templates/manifest/base/library.json.hbs @@ -1,8 +1,8 @@ { - "name": "{{packageSpec.name}}", - "version": "{{packageSpec.version}}", -{{#eq packageSpec.library.module compare="esm"}} + "name": "{{options.name}}", + "version": "{{options.version}}", +{{#eq options.library.module compare="esm"}} "exports": { ".": { "import": { @@ -24,16 +24,16 @@ "scripts": {}, "neon": { "type": "library", -{{#eq packageSpec.library.cache.type compare="npm"}} -{{#if packageSpec.library.cache.org}} - "org": "{{packageSpec.library.cache.org}}", +{{#eq options.library.cache.type compare="npm"}} +{{#if options.library.cache.org}} + "org": "{{options.library.cache.org}}", {{/if}} {{/eq}} "platforms": {}, "load": "./src/load.cts" }, "devDependencies": { - "@neon-rs/cli": "^{{versions.neonCLI}}"{{#eq packageSpec.library.lang compare="ts"}}, + "@neon-rs/cli": "^{{versions.neonCLI}}"{{#eq options.library.lang compare="ts"}}, "@tsconfig/node{{versions.tsconfigNode.major}}": "^{{versions.tsconfigNode.semver}}", "@types/node": "^{{versions.typesNode}}", "typescript": "^{{versions.typescript}}"{{/eq}} diff --git a/pkgs/create-neon/data/templates/manifest/scripts.json.hbs b/pkgs/create-neon/data/templates/manifest/scripts.json.hbs deleted file mode 100644 index 9dc21fb6e..000000000 --- a/pkgs/create-neon/data/templates/manifest/scripts.json.hbs +++ /dev/null @@ -1,12 +0,0 @@ -{ - "test": "{{#eq packageSpec.library.lang compare="ts"}}tsc && {{/eq}}cargo test", - "cargo-build": "{{#eq packageSpec.library.lang compare="ts"}}tsc && {{/eq}}cargo build --message-format=json > cargo.log", - "cross-build": "{{#eq packageSpec.library.lang compare="ts"}}tsc && {{/eq}}cross build --message-format=json > cross.log", - "postcargo-build": "neon dist < cargo.log", - "postcross-build": "neon dist -m /target < cross.log", - "debug": "npm run cargo-build --", - "build": "npm run cargo-build -- --release", - "cross": "npm run cross-build -- --release"{{#if packageSpec.library}}, - "prepack": "{{#eq packageSpec.library.lang compare="ts"}}tsc && {{/eq}}neon update", - "version": "neon bump --binaries platforms && git add ."{{/if}} -} diff --git a/pkgs/create-neon/data/templates/tsconfig.json.hbs b/pkgs/create-neon/data/templates/tsconfig.json.hbs index 405e127f5..f991246af 100644 --- a/pkgs/create-neon/data/templates/tsconfig.json.hbs +++ b/pkgs/create-neon/data/templates/tsconfig.json.hbs @@ -1,5 +1,5 @@ { -{{#eq packageSpec.library.lang compare="ts"}} +{{#eq options.library.lang compare="ts"}} "extends": "@tsconfig/node{{versions.tsconfigNode.major}}/tsconfig.json", "compilerOptions": { "module": "node{{versions.tsconfigNode.module}}", diff --git a/pkgs/create-neon/src/bin/create-neon.ts b/pkgs/create-neon/src/bin/create-neon.ts index 3c7eee173..caec20f2e 100644 --- a/pkgs/create-neon/src/bin/create-neon.ts +++ b/pkgs/create-neon/src/bin/create-neon.ts @@ -1,6 +1,5 @@ #!/usr/bin/env node -import * as path from "path"; import commandLineArgs from "command-line-args"; import { printErrorWithUsage } from "../print.js"; import { createNeon } from "../index.js"; @@ -8,32 +7,14 @@ import { Cache } from "../cache.js"; import { NPM } from "../cache/npm.js"; import { CI } from "../ci.js"; import { GitHub } from "../ci/github.js"; -import { Lang, ModuleType } from "../package.js"; +import { Lang, ModuleType } from "../create/creator.js"; import { NodePlatform, PlatformPreset, - assertIsPlatformPreset, isNodePlatform, isPlatformPreset, } from "@neon-rs/manifest/platform"; -const JS_TEMPLATES: Record = { - ".gitignore.hbs": ".gitignore", - "Cargo.toml.hbs": "Cargo.toml", - "README.md.hbs": "README.md", - "lib.rs.hbs": path.join("src", "lib.rs"), -}; - -function tsTemplates(pkg: string): Record { - return { - ".gitignore.hbs": ".gitignore", - "Cargo.toml.hbs": path.join("crates", pkg, "Cargo.toml"), - "Workspace.toml.hbs": "Cargo.toml", - "README.md.hbs": "README.md", - "lib.rs.hbs": path.join("crates", pkg, "src", "lib.rs"), - }; -} - const OPTIONS = [ { name: "app", type: Boolean, defaultValue: false }, { name: "lib", type: Boolean, defaultValue: false }, @@ -67,8 +48,9 @@ try { process.env["npm_configure_yes"] = "true"; } - createNeon(pkg, { - templates: opts.lib ? tsTemplates(pkg) : JS_TEMPLATES, + createNeon({ + name: pkg, + version: "0.1.0", library: opts.lib ? { lang: Lang.TS, @@ -78,7 +60,11 @@ try { platforms, } : null, - app: opts.app ? true : null, + app: opts.app ? true : opts.lib ? false : null, + // Even if the user specifies this with a flag (e.g. `npm init -y neon`), + // `npm init` sets this env var to 'true' before invoking create-neon. + // So this is the most general way to check this configuration option. + interactive: process.env["npm_configure_yes"] !== "true", }); } catch (e) { printErrorWithUsage(e); diff --git a/pkgs/create-neon/src/ci.ts b/pkgs/create-neon/src/ci.ts index de18547bc..84d38cd82 100644 --- a/pkgs/create-neon/src/ci.ts +++ b/pkgs/create-neon/src/ci.ts @@ -2,4 +2,5 @@ export interface CI { readonly type: string; templates(): Record; setup(): void; + scripts(): Record; } diff --git a/pkgs/create-neon/src/ci/github.ts b/pkgs/create-neon/src/ci/github.ts index 56f39cd04..67fc3c71e 100644 --- a/pkgs/create-neon/src/ci/github.ts +++ b/pkgs/create-neon/src/ci/github.ts @@ -29,4 +29,11 @@ export class GitHub implements CI { setup(): void { handlebars.registerHelper("$", githubDelegate); } + + scripts(): Record { + return { + release: "gh workflow run release.yml -f dryrun=false -f version=patch", + dryrun: "gh workflow run publish.yml -f dryrun=true", + }; + } } diff --git a/pkgs/create-neon/src/create/app.ts b/pkgs/create-neon/src/create/app.ts new file mode 100644 index 000000000..cfd3f58d5 --- /dev/null +++ b/pkgs/create-neon/src/create/app.ts @@ -0,0 +1,20 @@ +import { Creator, ProjectOptions } from "./creator.js"; + +export class AppCreator extends Creator { + constructor(options: ProjectOptions) { + super(options); + } + + scripts(): Record { + return { + test: "cargo test", + "cargo-build": "cargo build --message-format=json > cargo.log", + "cross-build": "cross build --message-format=json > cross.log", + "postcargo-build": "neon dist < cargo.log", + "postcross-build": "neon dist -m /target < cross.log", + debug: "npm run cargo-build --", + build: "npm run cargo-build -- --release", + cross: "npm run cross-build -- --release", + }; + } +} diff --git a/pkgs/create-neon/src/create/creator.ts b/pkgs/create-neon/src/create/creator.ts new file mode 100644 index 000000000..3ca9d996e --- /dev/null +++ b/pkgs/create-neon/src/create/creator.ts @@ -0,0 +1,169 @@ +import die from "../die.js"; +import { mktemp } from "../fs.js"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { Context } from "../expand/context.js"; +import { expand, expandTo } from "../expand/index.js"; +import { npmInit } from "../shell.js"; +import { Cache } from "../cache.js"; +import { CI } from "../ci.js"; +import { NodePlatform, PlatformPreset } from "@neon-rs/manifest/platform"; + +export enum Lang { + JS = "js", + DTS = "dts", + TS = "ts", +} + +export enum ModuleType { + ESM = "esm", + CJS = "cjs", +} + +export type LibraryOptions = { + lang: Lang; + module: ModuleType; + cache?: Cache; + ci?: CI | undefined; + platforms?: NodePlatform | PlatformPreset | (NodePlatform | PlatformPreset)[]; +}; + +export type ProjectOptions = { + name: string; + version: string; + library: LibraryOptions | null; + app: boolean | null; + cache?: Cache | undefined; + ci?: CI | undefined; + interactive: boolean; +}; + +function stripNpmNamespace(pkg: string): string { + return /^@[^/]+\/(?.*)/.exec(pkg)?.groups?.stripped || pkg; +} + +export abstract class Creator { + protected _options: ProjectOptions; + protected _temp: string = ""; + protected _tempPkg: string = ""; + + static async for(options: ProjectOptions): Promise { + if (options.library) { + const LibCreator = (await import("./lib.js")).LibCreator; + return new LibCreator(options); + } else { + const AppCreator = (await import("./app.js")).AppCreator; + return new AppCreator(options); + } + } + + constructor(options: ProjectOptions) { + this._options = options; + } + + async create(cx: Context): Promise { + try { + this._temp = await mktemp(); + this._tempPkg = path.join(this._temp, this._options.name); + + await fs.mkdir(this._tempPkg); + } catch (err: any) { + await die( + `Could not create \`${this._options.name}\`: ${err.message}`, + this._temp + ); + } + + await this.prepare(cx); + + const manifest = await npmInit( + cx.options.interactive, + cx.options.interactive ? [] : ["--yes"], + this._tempPkg, + this._temp + ); + + try { + cx.package = { + name: manifest.name, + version: manifest.version, + author: manifest.author, + license: manifest.license, + description: manifest.description, + }; + + const crateName = stripNpmNamespace(manifest.name); + + cx.crate = { + name: crateName, + version: manifest.version, + author: manifest.author, + description: manifest.description, + license: manifest.license, + escaped: { + name: JSON.stringify(crateName), + version: JSON.stringify(manifest.version), + author: manifest.author ? JSON.stringify(manifest.author) : undefined, + description: manifest.description + ? JSON.stringify(manifest.description) + : undefined, + license: manifest.license + ? JSON.stringify(manifest.license) + : undefined, + }, + }; + } catch (err: any) { + await die( + "Could not create `package.json`: " + err.message, + this._tempPkg + ); + } + + await this.createNeonBoilerplate(cx); + + try { + await fs.rename(this._tempPkg, this._options.name); + await fs.rmdir(this._temp); + } catch (err: any) { + await die( + `Could not create \`${this._options.name}\`: ${err.message}`, + this._tempPkg + ); + } + } + + async createNeonBoilerplate(cx: Context): Promise { + const templates = this.templates(cx.package!.name); + for (const source of Object.keys(templates)) { + const target = path.join(this._tempPkg, templates[source]); + await expandTo(source, target, cx); + } + } + + // Write initial values to prevent `npm init` from asking unnecessary questions. + async prepare(cx: Context): Promise { + const template = `manifest/base/${this.baseTemplate()}`; + + const base = JSON.parse(await expand(template, cx)); + base.scripts = this.scripts(); + const filename = path.join(this._tempPkg, "package.json"); + await fs.writeFile(filename, JSON.stringify(base)); + } + + templates(_pkg: string): Record { + return { + ".gitignore.hbs": ".gitignore", + "Cargo.toml.hbs": "Cargo.toml", + "README.md.hbs": "README.md", + "lib.rs.hbs": path.join("src", "lib.rs"), + }; + } + + scripts(): Record { + return {}; + } + + baseTemplate(): string { + return "default.json.hbs"; + } +} diff --git a/pkgs/create-neon/src/create/lib.ts b/pkgs/create-neon/src/create/lib.ts new file mode 100644 index 000000000..8cb36c25c --- /dev/null +++ b/pkgs/create-neon/src/create/lib.ts @@ -0,0 +1,119 @@ +import { Creator, ProjectOptions, LibraryOptions, Lang } from "./creator.js"; +import { Context } from "../expand/context.js"; +import * as path from "node:path"; +import { expandTo } from "../expand/index.js"; +import { LibraryManifest } from "@neon-rs/manifest"; +import { + NodePlatform, + PlatformPreset, + isNodePlatform, +} from "@neon-rs/manifest/platform"; + +const TS_TEMPLATES: Record = { + "tsconfig.json.hbs": "tsconfig.json", + "ts/index.cts.hbs": path.join("src", "index.cts"), + "ts/index.mts.hbs": path.join("src", "index.mts"), + "ts/load.cts.hbs": path.join("src", "load.cts"), +}; + +export class LibCreator extends Creator { + private _libOptions: LibraryOptions; + + constructor(options: ProjectOptions) { + super(options); + this._libOptions = options.library!; + if (this._libOptions.ci) { + this._libOptions.ci.setup(); + } + } + + templates(pkg: string): Record { + return this._libOptions.lang === Lang.TS + ? { + ".gitignore.hbs": ".gitignore", + "Cargo.toml.hbs": path.join("crates", pkg, "Cargo.toml"), + "Workspace.toml.hbs": "Cargo.toml", + "README.md.hbs": "README.md", + "lib.rs.hbs": path.join("crates", pkg, "src", "lib.rs"), + } + : super.templates(pkg); + } + + async createNeonBoilerplate(cx: Context): Promise { + await super.createNeonBoilerplate(cx); + + if (this._libOptions.lang === Lang.TS) { + await this.createTSBoilerplate(cx); + } + + if (this._libOptions.ci) { + await this.createCIBoilerplate(cx); + } + + await this.addPlatforms(cx); + } + + async createTSBoilerplate(cx: Context): Promise { + for (const source of Object.keys(TS_TEMPLATES)) { + const target = path.join(this._tempPkg, TS_TEMPLATES[source]); + await expandTo(source, target, cx); + } + } + + async createCIBoilerplate(cx: Context): Promise { + const templates = this._libOptions.ci!.templates(); + for (const source of Object.keys(templates)) { + const target = path.join(this._tempPkg, templates[source]); + await expandTo(`ci/${this._libOptions.ci!.type}/${source}`, target, cx); + } + } + + async addPlatforms(cx: Context): Promise { + const manifest = await LibraryManifest.load(this._tempPkg); + + const platforms: (NodePlatform | PlatformPreset)[] = Array.isArray( + this._libOptions.platforms + ) + ? this._libOptions.platforms + : !this._libOptions.platforms + ? ["common"] + : [this._libOptions.platforms]; + + for (const platform of platforms) { + if (isNodePlatform(platform)) { + await manifest.addNodePlatform(platform); + } else { + await manifest.addPlatformPreset(platform); + } + } + + await manifest.saveChanges((msg) => {}); + } + + scripts(): Record { + const tscAnd = this._libOptions.lang === Lang.TS ? "tsc &&" : ""; + + let scripts: Record = { + test: `${tscAnd}cargo test`, + "cargo-build": `${tscAnd}cargo build --message-format=json > cargo.log`, + "cross-build": `${tscAnd}cross build --message-format=json > cross.log`, + "postcargo-build": "neon dist < cargo.log", + "postcross-build": "neon dist -m /target < cross.log", + debug: "npm run cargo-build --", + build: "npm run cargo-build -- --release", + cross: "npm run cross-build -- --release", + prepack: `${tscAnd}neon update`, + version: "neon bump --binaries platforms && git add .", + }; + + if (this._libOptions.ci) { + Object.assign(scripts, this._libOptions.ci.scripts()); + } + + return scripts; + } + + baseTemplate(): string { + return "library.json.hbs"; + } +} diff --git a/pkgs/create-neon/src/die.ts b/pkgs/create-neon/src/die.ts index b1b34f0a7..0fc7a42c9 100644 --- a/pkgs/create-neon/src/die.ts +++ b/pkgs/create-neon/src/die.ts @@ -6,7 +6,7 @@ function deleteNeonDir(dir: string): Promise { export default async function die( message: string, - tmpFolderName: string + tmpFolderName?: string | undefined ): Promise { console.error(`❌ ${message}`); if (tmpFolderName) { diff --git a/pkgs/create-neon/src/expand/context.ts b/pkgs/create-neon/src/expand/context.ts new file mode 100644 index 000000000..726971809 --- /dev/null +++ b/pkgs/create-neon/src/expand/context.ts @@ -0,0 +1,30 @@ +import { ProjectOptions } from "../create/creator.js"; +import { VERSIONS, Versions } from "./versions.js"; + +export type ManifestData = { + name: string; + version: string; + description: string | undefined; + author: string | undefined; + license: string | undefined; +}; + +type CrateData = ManifestData & { + // The same manifest data but escaped as string literals + // so they can be embedded in TOML. + escaped: ManifestData; +}; + +export class Context { + options: ProjectOptions; + package: ManifestData | undefined; + crate: CrateData | undefined; + versions: Versions; + + constructor(options: ProjectOptions) { + this.options = options; + this.package = undefined; + this.crate = undefined; + this.versions = VERSIONS; + } +} diff --git a/pkgs/create-neon/src/expand.ts b/pkgs/create-neon/src/expand/index.ts similarity index 55% rename from pkgs/create-neon/src/expand.ts rename to pkgs/create-neon/src/expand/index.ts index 9e1ca8f05..4972e8cb9 100644 --- a/pkgs/create-neon/src/expand.ts +++ b/pkgs/create-neon/src/expand/index.ts @@ -2,40 +2,26 @@ import { promises as fs } from "fs"; import handlebars from "handlebars"; import helpers from "handlebars-helpers"; import * as path from "path"; -import Package, { PackageSpec, Lang } from "./package.js"; -import { Versions } from "./versions.js"; +import { Context } from "./context.js"; const TEMPLATES_DIR = new URL( - path.join("..", "data", "templates", "/"), + path.join("..", "..", "data", "templates", "/"), import.meta.url ); -export interface Metadata { - packageSpec: PackageSpec; - package?: Package | undefined; - versions: Versions; -} - const COMPARISON_HELPERS = helpers("comparison"); handlebars.registerHelper("eq", COMPARISON_HELPERS.eq); -export async function expand( - source: string, - metadata: Metadata -): Promise { +export async function expand(source: string, cx: Context): Promise { let template = await fs.readFile(new URL(source, TEMPLATES_DIR), "utf8"); let compiled = handlebars.compile(template, { noEscape: true }); - return compiled(metadata); + return compiled(cx); } -export async function expandTo( - source: string, - target: string, - metadata: Metadata -) { +export async function expandTo(source: string, target: string, cx: Context) { await fs.mkdir(path.dirname(target), { recursive: true }); - const expanded = await expand(source, metadata); + const expanded = await expand(source, cx); // The 'wx' flag creates the file but fails if it already exists. await fs.writeFile(target, expanded, { flag: "wx" }); } diff --git a/pkgs/create-neon/src/versions.ts b/pkgs/create-neon/src/expand/versions.ts similarity index 96% rename from pkgs/create-neon/src/versions.ts rename to pkgs/create-neon/src/expand/versions.ts index 19e4de458..75a68a12e 100644 --- a/pkgs/create-neon/src/versions.ts +++ b/pkgs/create-neon/src/expand/versions.ts @@ -62,7 +62,7 @@ function assertIsVersions(data: unknown): asserts data is Versions { const dynamicRequire = createRequire(import.meta.url); function load(): Versions { - const data = dynamicRequire("../data/versions.json"); + const data = dynamicRequire("../../data/versions.json"); assertIsVersions(data); return data; } diff --git a/pkgs/create-neon/src/index.ts b/pkgs/create-neon/src/index.ts index 379b0c26f..941d34b2a 100644 --- a/pkgs/create-neon/src/index.ts +++ b/pkgs/create-neon/src/index.ts @@ -1,26 +1,16 @@ -import { promises as fs } from "fs"; -import * as path from "path"; -import die from "./die.js"; -import Package, { - PackageSpec, - LibrarySpec, - Lang, - ModuleType, - LANG_TEMPLATES, -} from "./package.js"; -import { VERSIONS } from "./versions.js"; -import { Metadata, expandTo } from "./expand.js"; -import { LibraryManifest } from "@neon-rs/manifest"; +import { Context } from "./expand/context.js"; import { NodePlatform, PlatformPreset, isNodePlatform, isPlatformPreset, } from "@neon-rs/manifest/platform"; -import { assertCanMkdir, mktemp } from "./fs.js"; import { Dialog, oneOf } from "./shell.js"; import { NPM } from "./cache/npm.js"; import { GitHub } from "./ci/github.js"; +import { Creator, ProjectOptions, Lang, ModuleType } from "./create/creator.js"; +import { assertCanMkdir } from "./fs.js"; +import die from "./die.js"; const CREATE_NEON_PRELUDE: string = ` This utility will walk you through creating a Neon project. @@ -34,14 +24,7 @@ Use \`npm run build\` to build the Neon project from source. Press ^C at any time to quit. `.trim(); -async function askProjectType(packageSpec: PackageSpec) { - // If non-interactive, use the default (--app). - if (packageSpec.yes) { - packageSpec.app = true; - return; - } - - // Otherwise, find out interactively. +async function askProjectType(options: ProjectOptions) { const dialog = new Dialog(); const ty = await dialog.ask({ prompt: "project type", @@ -78,7 +61,7 @@ async function askProjectType(packageSpec: PackageSpec) { ? await dialog.ask({ prompt: "cache org", parse: (v: string): string => v, - default: NPM.inferOrg(packageSpec.name), + default: NPM.inferOrg(options.name), }) : null; @@ -90,134 +73,46 @@ async function askProjectType(packageSpec: PackageSpec) { 'provider should be a supported Neon CI provider ("github" or "none").', }); - packageSpec.library = { + options.library = { lang: Lang.TS, module: ModuleType.ESM, - cache: cache === "npm" ? new NPM(packageSpec.name, org!) : undefined, + cache: cache === "npm" ? new NPM(options.name, org!) : undefined, ci: ci === "github" ? new GitHub() : undefined, platforms: platforms.length === 1 ? platforms[0] : platforms, }; } else { - packageSpec.app = true; + options.app = true; } dialog.end(); } -export type CreateNeonOptions = { - templates: Record; - library: LibrarySpec | null; - app: boolean | null; -}; - -export async function createNeon(name: string, options: CreateNeonOptions) { - const packageSpec: PackageSpec = { - name, - version: "0.1.0", - library: options.library, - app: options.app, - // Even if the user specifies this with a flag (e.g. `npm init -y neon`), - // `npm init` sets this env var to 'true' before invoking create-neon. - // So this is the most general way to check this configuration option. - yes: process.env["npm_configure_yes"] === "true", - }; - - const metadata: Metadata = { - packageSpec, - versions: VERSIONS, - }; - - let tmpFolderName: string = ""; - let tmpPackagePath: string = ""; - +export async function createNeon(options: ProjectOptions): Promise { try { - await assertCanMkdir(name); - - tmpFolderName = await mktemp(); - tmpPackagePath = path.join(tmpFolderName, name); - - await fs.mkdir(tmpPackagePath); + await assertCanMkdir(options.name); } catch (err: any) { - await die(`Could not create \`${name}\`: ${err.message}`, tmpFolderName); + await die(`Could not create \`${options.name}\`: ${err.message}`); } + const cx = new Context(options); + // Print a Neon variation of the `npm init` prelude text. - if (!packageSpec.yes) { + if (options.interactive) { console.log(CREATE_NEON_PRELUDE); } // If neither --lib nor --app was specified, find out. - if (packageSpec.library === null && packageSpec.app === null) { - await askProjectType(packageSpec); - } - - try { - metadata.package = await Package.create( - metadata, - tmpFolderName, - tmpPackagePath - ); - } catch (err: any) { - await die( - "Could not create `package.json`: " + err.message, - tmpPackagePath - ); - } - - if (packageSpec.library && packageSpec.library.ci) { - packageSpec.library.ci.setup(); - } - - for (const source of Object.keys(options.templates)) { - const target = path.join(tmpPackagePath, options.templates[source]); - await expandTo(source, target, metadata); - } - - if (packageSpec.library) { - const templates = LANG_TEMPLATES[packageSpec.library.lang]; - for (const source of Object.keys(templates)) { - const target = path.join(tmpPackagePath, templates[source]); - await expandTo(source, target, metadata); - } - - if (packageSpec.library.ci) { - const templates = packageSpec.library.ci.templates(); - for (const source of Object.keys(templates)) { - const target = path.join(tmpPackagePath, templates[source]); - await expandTo( - `ci/${packageSpec.library.ci.type}/${source}`, - target, - metadata - ); - } + if (options.library === null && options.app === null) { + if (options.interactive) { + await askProjectType(options); + } else { + options.app = true; } - - const manifest = await LibraryManifest.load(tmpPackagePath); - - const platforms: (NodePlatform | PlatformPreset)[] = Array.isArray( - packageSpec.library.platforms - ) - ? packageSpec.library.platforms - : !packageSpec.library.platforms - ? ["common"] - : [packageSpec.library.platforms]; - - for (const platform of platforms) { - if (isNodePlatform(platform)) { - await manifest.addNodePlatform(platform); - } else { - await manifest.addPlatformPreset(platform); - } - } - - await manifest.saveChanges((msg) => {}); } - try { - await fs.rename(tmpPackagePath, name); - await fs.rmdir(tmpFolderName); - } catch (err: any) { - await die(`Could not create \`${name}\`: ${err.message}`, tmpFolderName); - } + const creator = await Creator.for(options); + await creator.create(cx); - console.log(`✨ Created Neon project \`${name}\`. Happy 🦀 hacking! ✨`); + console.log( + `✨ Created Neon project \`${options.name}\`. Happy 🦀 hacking! ✨` + ); } diff --git a/pkgs/create-neon/src/package.ts b/pkgs/create-neon/src/package.ts deleted file mode 100644 index 43f77102c..000000000 --- a/pkgs/create-neon/src/package.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { promises as fs } from "fs"; -import * as path from "path"; -import { npmInit } from "./shell.js"; -import { VERSIONS } from "./versions.js"; -import { Cache } from "./cache.js"; -import { CI } from "./ci.js"; -import { Metadata, expand, expandTo } from "./expand.js"; -import { NodePlatform, PlatformPreset } from "@neon-rs/manifest/platform"; - -export enum Lang { - JS = "js", - DTS = "dts", - TS = "ts", -} - -export const LANG_TEMPLATES: Record> = { - [Lang.JS]: {}, - [Lang.DTS]: {}, - [Lang.TS]: { - "tsconfig.json.hbs": "tsconfig.json", - "ts/index.cts.hbs": path.join("src", "index.cts"), - "ts/index.mts.hbs": path.join("src", "index.mts"), - "ts/load.cts.hbs": path.join("src", "load.cts"), - }, -}; - -export enum ModuleType { - ESM = "esm", - CJS = "cjs", -} - -export type LibrarySpec = { - lang: Lang; - module: ModuleType; - cache?: Cache; - ci?: CI | undefined; - platforms?: NodePlatform | PlatformPreset | (NodePlatform | PlatformPreset)[]; -}; - -export type PackageSpec = { - name: string; - version: string; - library: LibrarySpec | null; - app: boolean | null; - cache?: Cache | undefined; - ci?: CI | undefined; - yes: boolean | undefined; -}; - -const KEYS = [ - "name", - "version", - "description", - "main", - "scripts", - "author", - "license", -]; - -function sort(json: any): any { - // First copy the keys in the order specified in KEYS. - let next = KEYS.filter((key) => json.hasOwnProperty(key)) - .map((key) => [key, json[key]]) - .reduce((acc, [key, val]) => Object.assign(acc, { [key]: val }), {}); - - // Then copy any remaining keys in the original order. - return Object.assign(next, json); -} - -export default class Package { - name: string; - version: string; - author: string; - quotedAuthor: string; - license: string; - description: string; - quotedDescription: string; - - static async create( - metadata: Metadata, - tmp: string, - dir: string - ): Promise { - const baseTemplate = metadata.packageSpec.library - ? "manifest/base/library.json.hbs" - : "manifest/base/default.json.hbs"; - - // 1. Load the base contents of the manifest from the base template. - const seed = JSON.parse(await expand(baseTemplate, metadata)); - - // 2. Mixin the scripts from the scripts template. - seed.scripts = JSON.parse( - await expand("manifest/scripts.json.hbs", metadata) - ); - - // 3. Mixin any scripts from the CI scripts template. - if (metadata.packageSpec.library && metadata.packageSpec.library.ci) { - const mixinTemplate = `ci/${metadata.packageSpec.library.ci.type}/manifest/scripts.json.hbs`; - Object.assign( - seed.scripts, - JSON.parse(await expand(mixinTemplate, metadata)) - ); - } - - const filename = path.join(dir, "package.json"); - - // 1. Write initial values to prevent `npm init` from asking unnecessary questions. - await fs.writeFile(filename, JSON.stringify(seed)); - - // 2. Call `npm init` to ask the user remaining questions. - await npmInit( - !metadata.packageSpec.yes, - metadata.packageSpec.yes ? ["--yes"] : [], - dir, - tmp - ); - - // 3. Sort the values in idiomatic `npm init` order. - const sorted = sort(JSON.parse(await fs.readFile(filename, "utf8"))); - - // 4. Save the result to package.json. - await fs.writeFile(filename, JSON.stringify(sorted, undefined, 2) + "\n"); - - return new Package(sorted); - } - - constructor(json: any) { - this.name = json.name; - this.version = json.version; - this.author = json.author; - this.quotedAuthor = JSON.stringify(json.author); - this.license = json.license; - this.description = json.description; - this.quotedDescription = JSON.stringify(json.description); - } -} diff --git a/pkgs/create-neon/src/shell.ts b/pkgs/create-neon/src/shell.ts index 6ea40cad2..c05054593 100644 --- a/pkgs/create-neon/src/shell.ts +++ b/pkgs/create-neon/src/shell.ts @@ -2,6 +2,8 @@ import { ChildProcess, spawn } from "node:child_process"; import { PassThrough, Readable, Writable } from "node:stream"; import { StringDecoder } from "node:string_decoder"; import readline from "node:readline/promises"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; export function readChunks(input: Readable): Readable { let output = new PassThrough({ objectMode: true }); @@ -16,12 +18,17 @@ export function readChunks(input: Readable): Readable { return output; } +type NpmInitExit = { + code: number | null; + signal: string | null; +}; + // A child process representing a modified `npm init` invocation: // - If interactive, the initial prelude of stdout text is suppressed // so we can present a modified prelude for create-neon. // - The process is being run in a temp subdirectory, so any output that // includes the temp directory in a path is transformed to remove it. -class NpmInit { +class NpmInitProcess { private _regexp: RegExp; private _child: ChildProcess; @@ -35,13 +42,13 @@ class NpmInit { this.filterStdout({ interactive }).then(() => {}); } - exit(): Promise { - let resolve: (code: number | null) => void; - const result: Promise = new Promise((res) => { + exit(): Promise { + let resolve: (exit: NpmInitExit) => void; + const result: Promise = new Promise((res) => { resolve = res; }); - this._child.on("exit", (code) => { - resolve(code); + this._child.on("exit", (code, signal) => { + resolve({ code, signal }); }); return result; } @@ -79,13 +86,49 @@ class NpmInit { } } -export function npmInit( +// Standard order of package.json keys generated by `npm init`. +const NPM_INIT_KEYS = [ + "name", + "version", + "description", + "main", + "scripts", + "author", + "license", +]; + +function sort(json: any): any { + // First copy the keys in the order specified in NPM_INIT_KEYS. + let next = NPM_INIT_KEYS.filter((key) => json.hasOwnProperty(key)) + .map((key) => [key, json[key]]) + .reduce((acc, [key, val]) => Object.assign(acc, { [key]: val }), {}); + + // Then copy any remaining keys in the original order. + return Object.assign(next, json); +} + +export async function npmInit( interactive: boolean, args: string[], cwd: string, tmp: string -): Promise { - return new NpmInit(interactive, args, cwd, tmp).exit(); +): Promise { + const child = new NpmInitProcess(interactive, args, cwd, tmp); + const { code, signal } = await child.exit(); + + if (code === null) { + process.kill(process.pid, signal!); + } else if (code !== 0) { + process.exit(code); + } + + const filename = path.join(cwd, "package.json"); + + const sorted = sort(JSON.parse(await fs.readFile(filename, "utf8"))); + + await fs.writeFile(filename, JSON.stringify(sorted, undefined, 2) + "\n"); + + return sorted; } export type Parser = (v: string) => T;