diff --git a/pkgs/create-neon/data/templates/.gitignore.hbs b/pkgs/create-neon/data/templates/.gitignore.hbs index 6ca71fb5f..45413ba08 100644 --- a/pkgs/create-neon/data/templates/.gitignore.hbs +++ b/pkgs/create-neon/data/templates/.gitignore.hbs @@ -3,3 +3,8 @@ index.node **/node_modules **/.DS_Store npm-debug.log* +{{#if package.lang.isTypeScript}} +lib +{{/if}} +cargo.log +cross.log diff --git a/pkgs/create-neon/data/templates/.npmignore.hbs b/pkgs/create-neon/data/templates/.npmignore.hbs new file mode 100644 index 000000000..5a00bba56 --- /dev/null +++ b/pkgs/create-neon/data/templates/.npmignore.hbs @@ -0,0 +1,3 @@ +{{#if package.isLibrary}} +platforms +{{/if}} diff --git a/pkgs/create-neon/data/templates/README.md.hbs b/pkgs/create-neon/data/templates/README.md.hbs index e1ac24ae7..3dbffaa7c 100644 --- a/pkgs/create-neon/data/templates/README.md.hbs +++ b/pkgs/create-neon/data/templates/README.md.hbs @@ -6,34 +6,24 @@ {{/if}} This project was bootstrapped by [create-neon](https://www.npmjs.com/package/create-neon). -## Installing {{package.name}} - -Installing {{package.name}} requires a [supported version of Node and Rust](https://github.com/neon-bindings/neon#platform-support). - -You can install the project with npm. In the project directory, run: - -```sh -$ npm install -``` - -This fully installs the project, including installing any dependencies and running the build. - ## Building {{package.name}} -If you have already installed the project and only want to run the build, run: +Building {{package.name}} requires a [supported version of Node and Rust](https://github.com/neon-bindings/neon#platform-support). + +To run the build, run: ```sh $ npm run build ``` -This command uses the [cargo-cp-artifact](https://github.com/neon-bindings/cargo-cp-artifact) utility to run the Rust build and copy the built library into `./index.node`. +This command uses the [@neon-rs/cli](https://github.com/neon-rs/cli) utility to assemble the binary Node addon from the output of `cargo`. ## Exploring {{package.name}} After building {{package.name}}, you can explore its exports at the Node REPL: ```sh -$ npm install +$ npm run build $ node > require('.').hello() "hello node" @@ -43,27 +33,29 @@ $ node In the project directory, you can run: +{{#unless package.isLibrary}} ### `npm install` Installs the project, including running `npm run build`. -### `npm build` +{{/unless}} +### `npm run build` -Builds the Node addon (`index.node`) from source. +Builds the Node addon (`index.node`) from source, generating a release build with `cargo --release`. -Additional [`cargo build`](https://doc.rust-lang.org/cargo/commands/cargo-build.html) arguments may be passed to `npm build` and `npm build-*` commands. For example, to enable a [cargo feature](https://doc.rust-lang.org/cargo/reference/features.html): +Additional [`cargo build`](https://doc.rust-lang.org/cargo/commands/cargo-build.html) arguments may be passed to `npm run build` and similar commands. For example, to enable a [cargo feature](https://doc.rust-lang.org/cargo/reference/features.html): ``` npm run build -- --feature=beetle ``` -#### `npm build-debug` +#### `npm run debug` -Alias for `npm build`. +Similar to `npm run build` but generates a debug build with `cargo`. -#### `npm build-release` +#### `npm run cross` -Same as [`npm build`](#npm-build) but, builds the module with the [`release`](https://doc.rust-lang.org/cargo/reference/profiles.html#release) profile. Release builds will compile slower, but run faster. +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. ### `npm test` @@ -77,7 +69,17 @@ The directory structure of this project is: {{package.name}}/ ├── Cargo.toml ├── README.md +{{#if package.isLibrary}} +├── lib/ +{{#if package.lang.isTypeScript}} +├── ts/ +| ├── index.mts +| └── index.cts +{{/if}} +├── platforms/ +{{else}} ├── index.node +{{/if}} ├── package.json ├── src/ | └── lib.rs @@ -92,12 +94,39 @@ The Cargo [manifest file](https://doc.rust-lang.org/cargo/reference/manifest.htm This file. +{{#if package.isLibrary}} +### lib/ + +{{#if package.lang.isTypeScript}} +The directory containing the generated output from [tsc](https://typescriptlang.org). + +### ts/ + +The directory containing the TypeScript source files. + +### ts/index.mts + +Entry point for when this library is loaded via [ESM `import`](https://nodejs.org/api/esm.html#modules-ecmascript-modules) syntax. + +### ts/index.cts + +Entry point for when this library is loaded via [CJS `require`](https://nodejs.org/api/modules.html#requireid). +{{else}} +The directory containing the JavaScript source files. + +{{/if}} +### platforms/ + +The directory containing distributions of the binary addon backend for each platform supported by this library. + +{{else}} ### index.node The Node addon—i.e., a binary Node module—generated by building the project. This is the main module for this package, as dictated by the `"main"` key in `package.json`. Under the hood, a [Node addon](https://nodejs.org/api/addons.html) is a [dynamically-linked shared object](https://en.wikipedia.org/wiki/Library_(computing)#Shared_libraries). The `"build"` script produces this file by copying it from within the `target/` directory, which is where the Rust build produces the shared object. +{{/if}} ### package.json The npm [manifest file](https://docs.npmjs.com/cli/v7/configuring-npm/package-json), which informs the `npm` command. diff --git a/pkgs/create-neon/data/templates/ci/github/publish.yml.hbs b/pkgs/create-neon/data/templates/ci/github/publish.yml.hbs new file mode 100644 index 000000000..ff315ea4a --- /dev/null +++ b/pkgs/create-neon/data/templates/ci/github/publish.yml.hbs @@ -0,0 +1,268 @@ +name: Publish + +run-name: | + {{#$}} + (github.event_name == 'workflow_dispatch' && inputs.dryrun && 'Dry run') || + (github.event_name == 'workflow_dispatch' && + format('Tag and release: {0}', + (inputs.releaseType == 'custom' && inputs.custom) || inputs.releaseType) + ) || + format('Publish: {0}', github.event.head_commit.message) + {{/$}} + +env: + NODE_VERSION: 18.x + NEON_PLATFORMS_DIR: platforms + ACTIONS_USER: github-actions + ACTIONS_EMAIL: github-actions@github.com + +on: + push: + tags: + - v* + workflow_dispatch: + inputs: + dryrun: + description: 'Dry run (no npm publish)' + required: false + type: boolean + default: true + releaseType: + description: 'Release type (or custom to specify)' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + - prepatch + - preminor + - premajor + - prerelease + - custom + custom: + description: 'Custom version' + required: false + default: '' + +jobs: + setup: + name: Setup + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + action: {{#$}} steps.action.outputs.type {{/$}} + branch: {{#$}} steps.branch.outputs.branch {{/$}} + macOS: {{#$}} steps.matrix.outputs.macOS {{/$}} + Windows: {{#$}} steps.matrix.outputs.Windows {{/$}} + Linux: {{#$}} steps.matrix.outputs.Linux {{/$}} + steps: + - name: Validate Workflow Inputs + if: {{#$}} inputs.releaseType == 'custom' && inputs.custom == '' {{/$}} + shell: bash + run: | + echo '::error::Missing release version number' + exit 1 + - id: action + name: Determine Action Type + shell: bash + run: | + case "{{#$}} github.event_name {{/$}},{{#$}} inputs.dryrun {{/$}}" in + workflow_dispatch,true) type=dryrun ;; + workflow_dispatch,*) type=tag ;; + *) type=publish ;; + esac + echo "type=$type" + echo "type=$type" >> "$GITHUB_OUTPUT" + - name: Validate Secrets + env: + NPM_TOKEN: {{#$}} secrets.NPM_TOKEN {{/$}} + TAG_TOKEN: {{#$}} secrets.TAG_TOKEN {{/$}} + shell: bash + run: | + action={{#$}} steps.action.outputs.type {{/$}} + if [[ $action = publish ]] && [[ -z $NPM_TOKEN ]]; then + echo "::error::Secret NPM_TOKEN is not defined for this GitHub repo." + echo "::error::To publish to npm, this action requires:" + echo "::error:: • an npm access token;" + echo "::error:: • with Read-Write access to this project's npm packages;" + echo "::error:: • stored as a repo secret named NPM_TOKEN." + echo "::error::See https://docs.npmjs.com/about-access-tokens for info about creating npm tokens." + echo "::error::See https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions for info about how to store GitHub repo secrets." + exit 1 + fi + if [[ $action = tag ]] && [[ -z $TAG_TOKEN ]]; then + echo "::error::Secret TAG_TOKEN is not defined for this GitHub repo." + echo "::error::To push a release tag, this action requires:" + echo "::error:: • a GitHub Personal Access Token;" + echo "::error:: • with Read-Write access to this repo;" + echo "::error:: • stored as a repo secret named TAG_TOKEN." + echo "::error::See https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens for info about creating GitHub Personal Access Tokens." + echo "::error::See https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions for info about how to store GitHub repo secrets." + exit 1 + fi + - name: Checkout Code + uses: actions/checkout@{{versions.actions.checkout}} + with: + token: {{#$}} secrets.TAG_TOKEN || github.token {{/$}} + - name: Install Node + uses: actions/setup-node@{{versions.actions.setupNode}} + with: + node-version: {{#$}} env.NODE_VERSION {{/$}} + cache: npm + - name: Compute Default Branch + id: branch + shell: bash + run: | + branch=$(git remote show origin | grep 'HEAD branch' | awk '{ print $3; }') + echo $branch + echo "branch=$branch" >> "$GITHUB_OUTPUT" + - name: Install Dependencies + shell: bash + run: npm ci + - name: Install cargo-messages + shell: bash + run: npm ci + working-directory: ./pkgs/cargo-messages + - name: Trigger Release (manual only) + if: {{#$}} steps.action.outputs.type == 'tag' {{/$}} + shell: bash + run: | + git checkout {{#$}} steps.branch.outputs.branch {{/$}} + git config --global user.name {{#$}} env.ACTIONS_USER {{/$}} + git config --global user.email {{#$}} env.ACTIONS_EMAIL {{/$}} + npm run release -- '{{#$}} (inputs.releaseType == 'custom' && inputs.custom) || inputs.releaseType {{/$}}' + - name: Compute Platform Matrix + id: matrix + shell: bash + run: | + npx @neon-rs/cli ci github + echo "macOS=$(npx @neon-rs/cli ci github | jq -rc .macOS)" >> "$GITHUB_OUTPUT" + echo "Windows=$(npx @neon-rs/cli ci github | jq -rc .Windows)" >> "$GITHUB_OUTPUT" + echo "Linux=$(npx @neon-rs/cli ci github | jq -rc .Linux)" >> "$GITHUB_OUTPUT" + + macos-builds: + name: Builds (macOS) + if: {{#$}} needs.setup.outputs.action != 'tag' {{/$}} + needs: [setup] + strategy: + matrix: + platform: {{#$}} fromJSON(needs.setup.outputs.macOS) {{/$}} + runs-on: macos-latest + permissions: + contents: write + steps: + - name: Checkout Code + uses: actions/checkout@{{versions.actions.checkout}} + with: + fetch-depth: 0 + - name: Install Dependencies + shell: bash + run: npm ci + - name: Bump Version (dry-run only) + if: {{#$}} needs.setup.outputs.action == 'dryrun' {{/$}} + shell: bash + run: | + git checkout {{#$}} needs.setup.outputs.branch {{/$}} + git config --global user.name {{#$}} env.ACTIONS_USER {{/$}} + git config --global user.email {{#$}} env.ACTIONS_EMAIL {{/$}} + npm version '{{#$}} (inputs.releaseType == 'custom' && inputs.custom) || inputs.releaseType {{/$}}' -m "[dryrun] v%s" + - name: Build + uses: neon-actions/build@{{versions.actions.neonBuild}} + with: + node-version: {{#$}} env.NODE_VERSION {{/$}} + platform: {{#$}} matrix.platform {{/$}} + github-release: {{#$}} needs.setup.outputs.action == 'publish' {{/$}} + + windows-builds: + name: Builds (Windows) + if: {{#$}} needs.setup.outputs.action != 'tag' {{/$}} + needs: [setup] + strategy: + matrix: + platform: {{#$}} fromJSON(needs.setup.outputs.macOS) {{/$}} + runs-on: windows-latest + permissions: + contents: write + steps: + - name: Checkout Code + uses: actions/checkout@{{versions.actions.checkout}} + with: + fetch-depth: 0 + - name: Install Dependencies + shell: bash + run: npm ci + - name: Bump Version (dry-run only) + if: {{#$}} needs.setup.outputs.action == 'dryrun' {{/$}} + shell: bash + run: | + git checkout {{#$}} needs.setup.outputs.branch {{/$}} + git config --global user.name {{#$}} env.ACTIONS_USER {{/$}} + git config --global user.email {{#$}} env.ACTIONS_EMAIL {{/$}} + npm version '{{#$}} (inputs.releaseType == 'custom' && inputs.custom) || inputs.releaseType {{/$}}' -m "[dryrun] v%s" + - name: Build + uses: neon-actions/build@{{versions.actions.neonBuild}} + with: + node-version: {{#$}} env.NODE_VERSION {{/$}} + platform: {{#$}} matrix.platform {{/$}} + github-release: {{#$}} needs.setup.outputs.action == 'publish' {{/$}} + + other-builds: + name: Builds (other platforms) + if: {{#$}} needs.setup.outputs.action != 'tag' {{/$}} + needs: [setup] + strategy: + matrix: + platform: {{#$}} fromJSON(needs.setup.outputs.Linux) {{/$}} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout Code + uses: actions/checkout@{{versions.actions.checkout}} + with: + fetch-depth: 0 + - name: Install Dependencies + shell: bash + run: npm ci + - name: Bump Version (dry-run only) + if: {{#$}} needs.setup.outputs.action == 'dryrun' {{/$}} + shell: bash + run: | + git checkout {{#$}} needs.setup.outputs.branch {{/$}} + git config --global user.name {{#$}} env.ACTIONS_USER {{/$}} + git config --global user.email {{#$}} env.ACTIONS_EMAIL {{/$}} + npm version '{{#$}} (inputs.releaseType == 'custom' && inputs.custom) || inputs.releaseType {{/$}}' -m "[dryrun] v%s" + - name: Build + uses: neon-actions/build@{{versions.actions.neonBuild}} + with: + node-version: {{#$}} env.NODE_VERSION {{/$}} + use-cross: true + platform: {{#$}} matrix.platform {{/$}} + github-release: {{#$}} needs.setup.outputs.action == 'publish' {{/$}} + + publish: + name: Publish + if: {{#$}} needs.setup.outputs.action == 'publish' {{/$}} + needs: [macos-builds, windows-builds, other-builds] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout Code + uses: actions/checkout@{{versions.actions.checkout}} + with: + fetch-depth: 0 + - name: Install Dependencies + shell: bash + run: npm ci + - name: Publish + uses: neon-actions/publish@{{versions.actions.neonPublish}} + env: + NODE_AUTH_TOKEN: {{#$}} secrets.NPM_TOKEN {{/$}} + with: + node-version: {{#$}} env.NODE_VERSION {{/$}} + fetch-binaries: "*.tgz" + github-release: true diff --git a/pkgs/create-neon/data/templates/ci/github/test.yml.hbs b/pkgs/create-neon/data/templates/ci/github/test.yml.hbs new file mode 100644 index 000000000..ab23c5d09 --- /dev/null +++ b/pkgs/create-neon/data/templates/ci/github/test.yml.hbs @@ -0,0 +1,49 @@ +name: Test + +run-name: | + {{#$}} + format('Test ({0}): {1}', + (github.event_name == 'pull_request' && 'PR') || 'push', + github.event.head_commit.message + ) + {{/$}} + +env: + NODE_VERSION: {{versions.node}}.x + NEON_PLATFORMS_DIR: platforms + +on: + push: + # Prevent duplicate runs of this workflow on internal PRs. + branches: + - main + pull_request: + types: [opened, synchronize, reopened, labeled] + branches: + - main + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@{{versions.actions.checkout}} + with: + fetch-depth: 0 + - name: Install Node + uses: actions/setup-node@{{versions.actions.setupNode}} + with: + node-version: {{#$}}env.NODE_VERSION{{/$}} + cache: npm + - name: Install Rust + uses: actions-rs/toolchain@{{versions.actions.setupRust}} + - name: Install Dependencies + shell: bash + run: npm ci --verbose + - name: Build + shell: bash + run: npm run debug + - name: Test + shell: bash + run: npm test diff --git a/pkgs/create-neon/data/templates/package.json.hbs b/pkgs/create-neon/data/templates/package.json.hbs new file mode 100644 index 000000000..bfa83b364 --- /dev/null +++ b/pkgs/create-neon/data/templates/package.json.hbs @@ -0,0 +1,63 @@ + +{ + "name": "{{package.name}}", + "version": "{{package.version}}", +{{#if package.isLibrary}} +{{#if package.module.isESM}} + "exports": { + ".": { + "import": { + "types": "./lib/index.d.mts", + "default": "./lib/index.mjs" + }, + "require": { + "types": "./lib/index.d.cts", + "default": "./lib/index.cjs" + } + } + }, +{{/if}} + "types": "./lib/index.d.cts", + "main": "./lib/index.cjs", + "files": [ + "lib/**/*.cjs", + "lib/**/*.d.cts", + "lib/**/*.mjs", + "lib/**/*.d.mts", + "lib/**/*.js", + "lib/**/*.d.ts" + ], + "neon": { + "type": "library", + "org": "{{package.org}}", + "platforms": {}, + "load": "./ts/load.cts" + }, +{{#else}} + "main": "index.node", +{{/if}} + "scripts": { + "test": "{{#if package.lang.isTypeScript}}tsc && {{/if}}cargo test", + "cargo-build": "{{#if package.lang.isTypeScript}}tsc && {{/if}}cargo build --message-format=json > cargo.log", + "cross-build": "{{#if package.lang.isTypeScript}}tsc && {{/if}}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 package.isLibrary}}, + "prepack": "neon update-platforms", + "version": "neon bump --binaries platforms && git add .", + "release": "npm version -m 'v%s'", + "postrelease": "git push --follow-tags", + "dryrun": "gh workflow run publish.yml -f dryrun=true"{{/if}} + }, + "devDependencies": { + "@neon-rs/cli": "{{versions.neonCLI}}"{{#if package.lang.isTypeScript}}, + "@tsconfig/node{{versions.tsconfigNode.major}}": "^{{versions.tsconfigNode.semver}}", + "@types/node": "^{{versions.typesNode}}", + "typescript": "^{{versions.typescript}}"{{/if}} + }{{#if package.isLibrary}}, + "dependencies": { + "@neon-rs/load": "{{versions.neonLoad}}" + }{{/if}} +} diff --git a/pkgs/create-neon/data/templates/ts/index.cts.hbs b/pkgs/create-neon/data/templates/ts/index.cts.hbs new file mode 100644 index 000000000..92d36d576 --- /dev/null +++ b/pkgs/create-neon/data/templates/ts/index.cts.hbs @@ -0,0 +1,7 @@ +const addon = require('./load.cjs'); + +export type Greeting = "hello node"; + +export function hello(): Greeting { + return addon.hello(); +} diff --git a/pkgs/create-neon/data/templates/ts/index.mts.hbs b/pkgs/create-neon/data/templates/ts/index.mts.hbs new file mode 100644 index 000000000..8777f29df --- /dev/null +++ b/pkgs/create-neon/data/templates/ts/index.mts.hbs @@ -0,0 +1 @@ +export { hello, Greeting } from './index.cjs'; diff --git a/pkgs/create-neon/data/templates/ts/load.cts.hbs b/pkgs/create-neon/data/templates/ts/load.cts.hbs new file mode 100644 index 000000000..962910b43 --- /dev/null +++ b/pkgs/create-neon/data/templates/ts/load.cts.hbs @@ -0,0 +1 @@ +module.exports = require('@neon-rs/load').proxy({}); diff --git a/pkgs/create-neon/data/templates/tsconfig.json.hbs b/pkgs/create-neon/data/templates/tsconfig.json.hbs new file mode 100644 index 000000000..9866d086d --- /dev/null +++ b/pkgs/create-neon/data/templates/tsconfig.json.hbs @@ -0,0 +1,13 @@ +{ +{{#if package.lang.isTypeScript}} + "extends": "@tsconfig/node{{versions.tsconfigNode.major}}/tsconfig.json", + "compilerOptions": { + "module": "node{{versions.tsconfigNode.module}}", + "declaration": true, + "outDir": "lib", + }, + "exclude": ["lib"] +{{else}} + "extends": "@tsconfig/node{{versions.tsconfigNode.major}}/tsconfig.json" +{{/if}} +} diff --git a/pkgs/create-neon/data/versions.json b/pkgs/create-neon/data/versions.json index e3fba49aa..f89023490 100644 --- a/pkgs/create-neon/data/versions.json +++ b/pkgs/create-neon/data/versions.json @@ -1,4 +1,20 @@ { "neon": "1", - "cargo-cp-artifact": "0.1" + "neonCLI": "0.0.185", + "neonLoad": "0.0.185", + "typescript": "5", + "typesNode": "20", + "tsconfigNode": { + "major": "18", + "semver": "18", + "module": "16" + }, + "node": "18", + "actions": { + "checkout": "v3", + "setupNode": "v3", + "setupRust": "v1", + "neonBuild": "v0.9", + "neonPublish": "v0.4.1" + } } diff --git a/pkgs/create-neon/src/bin/create-neon.ts b/pkgs/create-neon/src/bin/create-neon.ts index 6c935a6d2..98344e1f8 100644 --- a/pkgs/create-neon/src/bin/create-neon.ts +++ b/pkgs/create-neon/src/bin/create-neon.ts @@ -1,20 +1,13 @@ #!/usr/bin/env node -import { promises as fs } from 'fs'; import * as path from 'path'; -import die from '../die.js'; -import Package from '../package.js'; -import { VERSIONS } from '../versions.js'; -import expand from '../expand.js'; -import chalk from 'chalk'; - -function pink(text: string): string { - return chalk.bold.hex('#e75480')(text); -} - -function blue(text: string): string { - return chalk.bold.cyanBright(text); -} +import commandLineArgs from 'command-line-args'; +import { printErrorWithUsage } from '../print.js'; +import { createNeon } from '../index.js'; +import { Cache } from '../cache.js'; +import { NPM } from '../cache/npm.js'; +import { CI } from '../ci.js'; +import { GitHub } from '../ci/github.js'; const TEMPLATES: Record = { ".gitignore.hbs": ".gitignore", @@ -23,58 +16,74 @@ const TEMPLATES: Record = { "lib.rs.hbs": path.join("src", "lib.rs"), }; -async function main(name: string) { - let tmpFolderName: string = ""; +const OPTIONS = [ + { name: 'lib', type: Boolean, defaultValue: false }, + { name: 'bins', type: String, defaultValue: 'none' }, + { name: 'platform', type: String, multiple: true, defaultValue: [] }, + { name: 'ci', alias: 'c', type: String, defaultValue: 'github' } +]; - try { - // pretty lightweight way to check both that folder doesn't exist and - // that the user has write permissions. - await fs.mkdir(name); - await fs.rmdir(name); +try { + const opts = commandLineArgs(OPTIONS, { stopAtFirstUnknown: true }); - tmpFolderName = await fs.mkdtemp(`${name}-`); - } catch (err: any) { - await die(`Could not create \`${name}\`: ${err.message}`, tmpFolderName); + if (!opts._unknown || opts._unknown.length === 0) { + throw new Error('No package name given'); } - let pkg: Package | undefined; - - try { - pkg = await Package.create(name, tmpFolderName); - await fs.mkdir(path.join(tmpFolderName, "src")); - } catch (err: any) { - await die("Could not create `package.json`: " + err.message, tmpFolderName); - } - if (pkg) { - for (let source of Object.keys(TEMPLATES)) { - let target = path.join(tmpFolderName, TEMPLATES[source]); - await expand(source, target, { - package: pkg, - versions: VERSIONS, - }); - } + if (opts._unknown.length > 1) { + throw new Error(`unexpected argument (${opts._unknown[1]})`); } - try { - await fs.rename(tmpFolderName, name); - } catch (err: any) { - await die(`Could not create \`${name}\`: ${err.message}`, tmpFolderName); + const [pkg] = opts._unknown; + const platforms = parsePlatforms(opts.platform); + const cache = parseCache(opts.lib, opts.bins, pkg); + const ci = parseCI(opts.ci); + + createNeon(pkg, { + templates: TEMPLATES, + library: opts.lib, + cache, + ci, + platforms + }); +} catch (e) { + printErrorWithUsage(e); + process.exit(1); +} + +function parsePlatforms(platforms: string[]): string | string[] | undefined { + if (platforms.length === 0) { + return undefined; + } else if (platforms.length === 1) { + return platforms[0]; + } else { + return platforms; } - console.log(`✨ Created Neon project \`${name}\`. Happy 🦀 hacking! ✨`); } -if (process.argv.length < 3) { - console.error( - `✨ ${pink('create-neon:')} Create a new Neon project with zero configuration. ✨` - ); - console.error(); - console.error(`${blue('Usage:')} npm init neon name`); - console.error(); - console.error( - " name The name of your Neon project, placed in a new directory of the same name." - ); - console.error(); - process.exit(1); +function parseCI(ci: string): CI | undefined { + switch (ci) { + case 'none': return undefined; + case 'github': return new GitHub(); + default: + throw new Error(`Unrecognized CI system ${ci}, expected 'github' or 'none'`); + } } -main(process.argv[2]); +function parseCache(lib: boolean, bins: string, pkg: string): Cache | undefined { + const defaultOrg = '@' + pkg; + + if (bins === 'none') { + return lib ? new NPM(defaultOrg) : undefined; + } + + if (bins === 'npm') { + return new NPM(defaultOrg); + } + + if (bins.startsWith('npm:')) { + return new NPM(bins.substring(4)); + } + + throw new Error(`Unrecognized binaries cache ${bins}, expected 'npm[:org]' or 'none'`) +} diff --git a/pkgs/create-neon/src/cache.ts b/pkgs/create-neon/src/cache.ts new file mode 100644 index 000000000..2a8dfe91c --- /dev/null +++ b/pkgs/create-neon/src/cache.ts @@ -0,0 +1,3 @@ +export interface Cache { + +} diff --git a/pkgs/create-neon/src/cache/npm.ts b/pkgs/create-neon/src/cache/npm.ts new file mode 100644 index 000000000..8d6f6dc12 --- /dev/null +++ b/pkgs/create-neon/src/cache/npm.ts @@ -0,0 +1,9 @@ +import { Cache } from '../cache.js'; + +export class NPM implements Cache { + private _org: string | null; + + constructor(org: string | null) { + this._org = org; + } +} diff --git a/pkgs/create-neon/src/ci.ts b/pkgs/create-neon/src/ci.ts new file mode 100644 index 000000000..9103a0077 --- /dev/null +++ b/pkgs/create-neon/src/ci.ts @@ -0,0 +1,3 @@ +export interface CI { + templates(): Record; +} diff --git a/pkgs/create-neon/src/ci/github.ts b/pkgs/create-neon/src/ci/github.ts new file mode 100644 index 000000000..b304c8227 --- /dev/null +++ b/pkgs/create-neon/src/ci/github.ts @@ -0,0 +1,15 @@ +import { CI } from '../ci.js'; +import path from 'node:path'; + +const TEMPLATES: Record = { + "publish.yml.hbs": path.join(".github", "workflows", "publish.yml"), + "test.yml.hbs": path.join(".github", "workflows", "test.yml") +}; + +export class GitHub implements CI { + constructor() { } + + templates(): Record { + return TEMPLATES; + } +} diff --git a/pkgs/create-neon/src/expand.ts b/pkgs/create-neon/src/expand.ts index da4026b90..c252772ed 100644 --- a/pkgs/create-neon/src/expand.ts +++ b/pkgs/create-neon/src/expand.ts @@ -11,6 +11,12 @@ export interface Metadata { versions: Versions; } +function ghaDelegate(this: any, options: handlebars.HelperOptions): handlebars.SafeString { + return new handlebars.SafeString("${{" + options.fn(this) +"}}"); +} + +handlebars.registerHelper('$', ghaDelegate); + export default async function expand( source: string, target: string, diff --git a/pkgs/create-neon/src/index.ts b/pkgs/create-neon/src/index.ts new file mode 100644 index 000000000..706fa6776 --- /dev/null +++ b/pkgs/create-neon/src/index.ts @@ -0,0 +1,59 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import die from './die.js'; +import Package from './package.js'; +import { VERSIONS } from './versions.js'; +import expand from './expand.js'; +import { Cache } from './cache.js'; +import { CI } from './ci.js'; + +export type CreateNeonOptions = { + templates: Record, + library?: boolean, + cache?: Cache, + ci?: CI, + platforms?: string | string[] +}; + +export async function createNeon(name: string, options: CreateNeonOptions) { + options.library ??= false; + options.platforms ??= 'common'; + + let tmpFolderName: string = ""; + + try { + // pretty lightweight way to check both that folder doesn't exist and + // that the user has write permissions. + await fs.mkdir(name); + await fs.rmdir(name); + + tmpFolderName = await fs.mkdtemp(`${name}-`); + } catch (err: any) { + await die(`Could not create \`${name}\`: ${err.message}`, tmpFolderName); + } + + let pkg: Package | undefined; + + try { + pkg = await Package.create(name, tmpFolderName); + await fs.mkdir(path.join(tmpFolderName, "src")); + } catch (err: any) { + await die("Could not create `package.json`: " + err.message, tmpFolderName); + } + if (pkg) { + for (let source of Object.keys(options.templates)) { + let target = path.join(tmpFolderName, options.templates[source]); + await expand(source, target, { + package: pkg, + versions: VERSIONS, + }); + } + } + + try { + await fs.rename(tmpFolderName, name); + } catch (err: any) { + await die(`Could not create \`${name}\`: ${err.message}`, tmpFolderName); + } + console.log(`✨ Created Neon project \`${name}\`. Happy 🦀 hacking! ✨`); +} diff --git a/pkgs/create-neon/src/print.ts b/pkgs/create-neon/src/print.ts new file mode 100644 index 000000000..79dc8f8b8 --- /dev/null +++ b/pkgs/create-neon/src/print.ts @@ -0,0 +1,79 @@ +import commandLineUsage from 'command-line-usage'; +import chalk from 'chalk'; + +function pink(text: string): string { + return chalk.bold.hex('#e75480')(text); +} + +function green(text: string): string { + return chalk.bold.greenBright(text); +} + +function blue(text: string): string { + return chalk.bold.cyanBright(text); +} + +function yellow(text: string): string { + return chalk.bold.yellowBright(text); +} + +function bold(text: string): string { + return chalk.bold(text); +} + +function mainUsage(): string { + const sections = [ + { + content: `✨ ${pink('create-neon:')} create a new Neon project with zero configuration ✨`, + raw: true + }, + { + header: green('Examples:'), + content: [ + `${blue('$')} ${bold('npm init neon my-package')}`, + '', + 'Create a Neon project `my-package`.', + '', + `${blue('$')} ${bold('npm init neon --lib my-lib')}`, + '', + 'Create a Neon library `my-lib`, pre-configured to publish pre-builds for common Node target platforms as binary packages under the `@my-lib` org. The generated project includes GitHub CI/CD actions for testing and publishing.', + '', + `${blue('$')} ${bold('npm init neon --lib my-library --target desktop')}`, + '', + 'Similar but configured to target just common Node desktop platforms.' + ] + }, + { + header: blue('Usage:'), + content: `${blue('$')} npm init neon [--lib] [--bins ] [--ci ] [--target ]* ` + }, + { + header: yellow('Options:'), + content: [ + { name: '--lib', summary: 'Configure package as a library. (Implied defaults: `--bins npm` and `--ci github`)' }, + { name: '--bins npm[:@]', summary: 'Configure for pre-built binaries published to npm. (Default org: )' }, + { name: '--bins none', summary: 'Do not configure for pre-built binaries. (Default)' }, + { name: '--target ', summary: 'May be used to specify one or more targets for pre-built binaries. (Default: common)' }, + { name: '--ci github', summary: 'Generate CI/CD configuration for GitHub Actions. (Default)' }, + { name: '--ci none', summary: 'Do not generate CI/CD configuration.' }, + { name: '', summary: 'Package name.' } + ] + } + ]; + + return commandLineUsage(sections).trim(); +} + +export function printMainUsage() { + console.error(mainUsage()); +} + +export function printErrorWithUsage(e: any) { + console.error(mainUsage()); + console.error(); + printError(e); +} + +export function printError(e: any) { + console.error(chalk.bold.red("error:") + " " + ((e instanceof Error) ? e.message : String(e))); +}