From 7661e45e3c5f9fe62f93b48cd3eb5dd886f76937 Mon Sep 17 00:00:00 2001 From: David Herman Date: Sun, 12 May 2024 10:44:49 -0700 Subject: [PATCH] require user to specify the Neon project type: - either by passing --app or --lib - or by interactive dialog via stdin --- pkgs/create-neon/dev/expect.ts | 17 +-- pkgs/create-neon/package.json | 1 + pkgs/create-neon/src/bin/create-neon.ts | 40 ++++-- pkgs/create-neon/src/cache/npm.ts | 11 +- pkgs/create-neon/src/fs.ts | 28 ++++ pkgs/create-neon/src/index.ts | 155 ++++++++++++++++----- pkgs/create-neon/src/package.ts | 18 +-- pkgs/create-neon/src/shell.ts | 171 ++++++++++++++++++------ pkgs/create-neon/test/create-neon.ts | 69 +++++++++- 9 files changed, 394 insertions(+), 116 deletions(-) create mode 100644 pkgs/create-neon/src/fs.ts diff --git a/pkgs/create-neon/dev/expect.ts b/pkgs/create-neon/dev/expect.ts index 9cd96b572..6f928f1a2 100644 --- a/pkgs/create-neon/dev/expect.ts +++ b/pkgs/create-neon/dev/expect.ts @@ -1,20 +1,7 @@ import { ChildProcess } from "child_process"; -import { PassThrough, Readable, Writable } from "stream"; -import { StringDecoder } from "string_decoder"; +import { Readable, Writable } from "stream"; import readStream from "stream-to-string"; - -function readChunks(input: Readable): Readable { - let output = new PassThrough({ objectMode: true }); - let decoder = new StringDecoder("utf8"); - input.on("data", (data) => { - output.write(decoder.write(data)); - }); - input.on("close", () => { - output.write(decoder.end()); - output.end(); - }); - return output; -} +import { readChunks } from "../src/shell.js"; function splitLines(s: string): string[] { return s.split(/([^\n]*\r?\n)/).filter((x) => x); diff --git a/pkgs/create-neon/package.json b/pkgs/create-neon/package.json index 5815167f4..f342d4bbc 100644 --- a/pkgs/create-neon/package.json +++ b/pkgs/create-neon/package.json @@ -22,6 +22,7 @@ "prepublishOnly": "npm run build", "pretest": "npm run build", "test": "mocha", + "manual-interactive-test": "npm run build && rm -rf create-neon-manual-test-project && node ./dist/src/bin/create-neon.js create-neon-manual-test-project", "manual-test": "npm run build && rm -rf create-neon-manual-test-project && node ./dist/src/bin/create-neon.js --lib --yes create-neon-manual-test-project" }, "repository": { diff --git a/pkgs/create-neon/src/bin/create-neon.ts b/pkgs/create-neon/src/bin/create-neon.ts index 0c27bd59f..2e1d9a31b 100644 --- a/pkgs/create-neon/src/bin/create-neon.ts +++ b/pkgs/create-neon/src/bin/create-neon.ts @@ -10,8 +10,10 @@ import { CI } from "../ci.js"; import { GitHub } from "../ci/github.js"; import { Lang, ModuleType } from "../package.js"; import { + NodePlatform, PlatformPreset, assertIsPlatformPreset, + isNodePlatform, isPlatformPreset, } from "@neon-rs/manifest/platform"; @@ -33,6 +35,7 @@ function tsTemplates(pkg: string): Record { } const OPTIONS = [ + { name: "app", type: Boolean, defaultValue: false }, { name: "lib", type: Boolean, defaultValue: false }, { name: "bins", type: String, defaultValue: "none" }, { name: "platform", type: String, multiple: true, defaultValue: ["common"] }, @@ -43,6 +46,10 @@ const OPTIONS = [ try { const opts = commandLineArgs(OPTIONS, { stopAtFirstUnknown: true }); + if (opts.app && opts.lib) { + throw new Error("Cannot choose both --app and --lib"); + } + if (!opts._unknown || opts._unknown.length === 0) { throw new Error("No package name given"); } @@ -55,7 +62,10 @@ try { const platforms = parsePlatforms(opts.platform); const cache = parseCache(opts.lib, opts.bins, pkg); const ci = parseCI(opts.ci); - const yes = !!opts.yes; + + if (opts.yes) { + process.env['npm_configure_yes'] = 'true'; + } createNeon(pkg, { templates: opts.lib ? tsTemplates(pkg) : JS_TEMPLATES, @@ -68,7 +78,7 @@ try { platforms, } : null, - yes, + app: opts.app ? true : null }); } catch (e) { printErrorWithUsage(e); @@ -77,17 +87,21 @@ try { function parsePlatforms( platforms: string[] -): PlatformPreset | PlatformPreset[] | undefined { +): NodePlatform | PlatformPreset | (NodePlatform | PlatformPreset)[] | undefined { if (platforms.length === 0) { return undefined; } else if (platforms.length === 1) { - const preset = platforms[0]; - assertIsPlatformPreset(preset); - return preset; + const platform = platforms[0]; + if (isNodePlatform(platform) || isPlatformPreset(platform)) { + return platform; + } + throw new TypeError(`expected platform or preset, got ${platform}`); } else { - return platforms.map((preset) => { - assertIsPlatformPreset(preset); - return preset; + return platforms.map((platform) => { + if (isNodePlatform(platform) || isPlatformPreset(platform)) { + return platform; + } + throw new TypeError(`expected platform or preset, got ${platform}`); }); } } @@ -110,18 +124,16 @@ function parseCache( bins: string, pkg: string ): Cache | undefined { - const defaultOrg = "@" + pkg; - if (bins === "none") { - return lib ? new NPM(defaultOrg) : undefined; + return lib ? new NPM(pkg) : undefined; } if (bins === "npm") { - return new NPM(defaultOrg); + return new NPM(pkg); } if (bins.startsWith("npm:")) { - return new NPM(bins.substring(4)); + return new NPM(pkg, bins.substring(4)); } throw new Error( diff --git a/pkgs/create-neon/src/cache/npm.ts b/pkgs/create-neon/src/cache/npm.ts index fe69c65dc..e93e08c5f 100644 --- a/pkgs/create-neon/src/cache/npm.ts +++ b/pkgs/create-neon/src/cache/npm.ts @@ -1,11 +1,16 @@ import { Cache } from "../cache.js"; export class NPM implements Cache { - readonly org: string | null; + readonly org: string; readonly type: string = "npm"; - constructor(org: string | null) { - this.org = org; + constructor(pkg: string, org?: string) { + this.org = org || NPM.inferOrg(pkg); + } + + static inferOrg(pkg: string): string { + const m = pkg.match(/^@([^/]+)\/([^/]+)/); + return "@" + (m ? m[1] : pkg); } } diff --git a/pkgs/create-neon/src/fs.ts b/pkgs/create-neon/src/fs.ts new file mode 100644 index 000000000..349ef2f2f --- /dev/null +++ b/pkgs/create-neon/src/fs.ts @@ -0,0 +1,28 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { existsSync, rmSync } from 'node:fs'; + +export async function assertCanMkdir(dir: string) { + // pretty lightweight way to check both that folder doesn't exist and + // that the user has write permissions. + await fs.mkdir(dir); + await fs.rmdir(dir); +} + +export async function mktemp(): Promise { + const tmpFolderName = await fs.mkdtemp("neon-"); + const tmpFolderAbsPath = path.join(process.cwd(), tmpFolderName); + function cleanupTmp() { + try { + if (existsSync(tmpFolderAbsPath)) { + rmSync(tmpFolderAbsPath, { recursive: true }); + } + } catch (e) { + console.error(`warning: could not delete ${tmpFolderName}: ${e}`); + } + } + process.on('exit', cleanupTmp); + process.on('SIGINT', cleanupTmp); + process.on('uncaughtException', cleanupTmp); + return tmpFolderName; +} diff --git a/pkgs/create-neon/src/index.ts b/pkgs/create-neon/src/index.ts index 853733715..54a7a9f06 100644 --- a/pkgs/create-neon/src/index.ts +++ b/pkgs/create-neon/src/index.ts @@ -1,6 +1,6 @@ -import { promises as fs } from "fs"; -import * as path from "path"; -import die from "./die.js"; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import die from './die.js'; import Package, { PackageSpec, LibrarySpec, @@ -11,12 +11,91 @@ import Package, { import { VERSIONS } from "./versions.js"; import { Metadata, expandTo } from "./expand.js"; import { LibraryManifest } from "@neon-rs/manifest"; -import { PlatformPreset } from "@neon-rs/manifest/platform"; +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'; + +const CREATE_NEON_PRELUDE: string = ` +This utility will walk you through creating a Neon project. +It only covers the most common items, and tries to guess sensible defaults. + +Use \`npm install \` afterwards to install a package and +save it as a dependency in the package.json file. + +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. + const dialog = new Dialog(); + const ty = await dialog.ask({ + prompt: 'project type', + parse: oneOf({ app: 'app' as const, lib: 'lib' as const }), + default: 'app' as const, + error: "type should be a valid Neon project type (\"app\" or \"lib\")." + }); + + if (ty === 'lib') { + const platforms: (NodePlatform | PlatformPreset)[] = await dialog.ask({ + prompt: 'target platforms', + parse: (v: string): (NodePlatform | PlatformPreset)[] => { + const a = v.split(',').map(s => s.trim()); + if (a.some(elt => !isNodePlatform(elt) && !isPlatformPreset(elt))) { + throw new Error("parse error"); + } + return a as (NodePlatform | PlatformPreset)[]; + }, + default: ['common'], + error: "platforms should be a comma-separated list of platforms or platform presets." + }); + + const cache = await dialog.ask({ + prompt: 'binary cache', + parse: oneOf({ npm: 'npm' as const, none: undefined }), + default: 'npm' as const, + error: "cache should be a supported Neon binary cache type (\"npm\" or \"none\")." + }); + + const org = cache === 'npm' ? await dialog.ask({ + prompt: 'cache org', + parse: (v: string): string => v, + default: NPM.inferOrg(packageSpec.name) + }) : null; + + const ci = await dialog.ask({ + prompt: 'ci provider', + parse: oneOf({ npm: 'github' as const, none: undefined }), + default: 'github' as const, + error: "provider should be a supported Neon CI provider (\"github\" or \"none\")." + }); + + packageSpec.library = { + lang: Lang.TS, + module: ModuleType.ESM, + cache: cache === 'npm' ? new NPM(packageSpec.name, org!) : undefined, + ci: ci === 'github' ? new GitHub() : undefined, + platforms: (platforms.length === 1) ? platforms[0] : platforms + }; + } else { + packageSpec.app = true; + } + dialog.end(); +} export type CreateNeonOptions = { templates: Record; library: LibrarySpec | null; - yes: boolean | undefined; + app: boolean | null; }; export async function createNeon(name: string, options: CreateNeonOptions) { @@ -24,7 +103,11 @@ export async function createNeon(name: string, options: CreateNeonOptions) { name, version: "0.1.0", library: options.library, - yes: options.yes, + 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 = { @@ -36,32 +119,38 @@ export async function createNeon(name: string, options: CreateNeonOptions) { let tmpPackagePath: 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); + await assertCanMkdir(name); - tmpFolderName = await fs.mkdtemp(`neon-`); + tmpFolderName = await mktemp(); tmpPackagePath = path.join(tmpFolderName, name); + await fs.mkdir(tmpPackagePath); } catch (err: any) { await die(`Could not create \`${name}\`: ${err.message}`, tmpFolderName); } - let pkg: Package | undefined; + // Print a Neon variation of the `npm init` prelude text. + if (!packageSpec.yes) { + 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 { - pkg = await Package.create(metadata, tmpPackagePath); - metadata.package = pkg; + metadata.package = await Package.create(metadata, tmpFolderName, tmpPackagePath); } catch (err: any) { await die( "Could not create `package.json`: " + err.message, tmpPackagePath ); } - if (pkg) { - if (options.library && options.library.ci) { - options.library.ci.setup(); + + if (metadata.package) { + if (packageSpec.library && packageSpec.library.ci) { + packageSpec.library.ci.setup(); } for (const source of Object.keys(options.templates)) { @@ -70,19 +159,19 @@ export async function createNeon(name: string, options: CreateNeonOptions) { } } - if (options.library) { - const templates = LANG_TEMPLATES[options.library.lang]; + 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 (options.library.ci) { - const templates = options.library.ci.templates(); + 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/${options.library.ci.type}/${source}`, + `ci/${packageSpec.library.ci.type}/${source}`, target, metadata ); @@ -91,16 +180,20 @@ export async function createNeon(name: string, options: CreateNeonOptions) { const manifest = await LibraryManifest.load(tmpPackagePath); - const platformPresets: PlatformPreset[] = Array.isArray( - options.library.platforms + const platforms: (NodePlatform | PlatformPreset)[] = Array.isArray( + packageSpec.library.platforms ) - ? options.library.platforms - : !options.library.platforms - ? ["common"] - : [options.library.platforms]; - - for (const preset of platformPresets) { - await manifest.addPlatformPreset(preset); + ? 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) => {}); diff --git a/pkgs/create-neon/src/package.ts b/pkgs/create-neon/src/package.ts index 55fef31fa..bb63903e4 100644 --- a/pkgs/create-neon/src/package.ts +++ b/pkgs/create-neon/src/package.ts @@ -1,11 +1,11 @@ import { promises as fs } from "fs"; import * as path from "path"; -import shell from "./shell.js"; +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 { PlatformPreset } from "@neon-rs/manifest/platform"; +import { NodePlatform, PlatformPreset } from "@neon-rs/manifest/platform"; export enum Lang { JS = "js", @@ -34,13 +34,14 @@ export type LibrarySpec = { module: ModuleType; cache?: Cache; ci?: CI | undefined; - platforms?: PlatformPreset | PlatformPreset[]; + 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; @@ -75,7 +76,7 @@ export default class Package { description: string; quotedDescription: string; - static async create(metadata: Metadata, dir: string): Promise { + 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"; @@ -103,10 +104,11 @@ export default class Package { await fs.writeFile(filename, JSON.stringify(seed)); // 2. Call `npm init` to ask the user remaining questions. - await shell( - "npm", - ["init", ...(metadata.packageSpec.yes ? ["--yes"] : [])], - dir + await npmInit( + !metadata.packageSpec.yes, + metadata.packageSpec.yes ? ["--yes"] : [], + dir, + tmp ); // 3. Sort the values in idiomatic `npm init` order. diff --git a/pkgs/create-neon/src/shell.ts b/pkgs/create-neon/src/shell.ts index ec061ea44..999285630 100644 --- a/pkgs/create-neon/src/shell.ts +++ b/pkgs/create-neon/src/shell.ts @@ -1,54 +1,139 @@ -import { spawn } from "child_process"; -import { promises as fs } from "fs"; -import path from "path"; - -/** - * Transparently shell out to an executable with a list of arguments. - * All stdio is inherited directly from the current process. - */ -export default function shell( - cmd: string, - args: string[], - cwd: string -): Promise { - let child = spawn(cmd, args, { stdio: "inherit", shell: true, cwd }); - - let resolve: (result: undefined) => void; - let reject: (error: Error) => void; +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'; - let result: Promise = new Promise((res, rej) => { - resolve = res; - reject = rej; +export function readChunks(input: Readable): Readable { + let output = new PassThrough({ objectMode: true }); + let decoder = new StringDecoder('utf8'); + input.on('data', (data) => { + output.write(decoder.write(data)); }); + input.on('close', () => { + output.write(decoder.end()); + output.end(); + }); + return output; +} + +class NpmInit { + private _tmp: string; + private _regexp: RegExp; + private _child: ChildProcess; + + constructor(interactive: boolean, args: string[], cwd: string, tmp: string) { + this._tmp = tmp; + this._regexp = new RegExp(tmp + "."); + this._child = spawn('npm', ['init', ...args], { + stdio: ['inherit', 'pipe', 'inherit'], + shell: true, + cwd + }); + this.filterStdout({ interactive }).then(() => {}); + } + + exit(): Promise { + let resolve: (code: number | null) => void; + const result: Promise = new Promise((res) => { + resolve = res; + }); + this._child.on('exit', (code) => { + resolve(code); + }); + return result; + } + + async filterStdout(opts: { interactive: boolean }) { + // We'll suppress the `npm init` interactive prelude text, + // in favor of printing our own create-neon version of the text. + let inPrelude = opts.interactive; + + for await (const chunk of readChunks(this._child.stdout!)) { + const lines = (chunk as string).split(/\r?\n/); + if (opts.interactive && inPrelude) { + // The first interactive prompt marks the end of the prelude. + const i = lines.findIndex(line => line.match(/^[a-z ]+:/)); + + // No prompt? We're still in the prelude so ignore and continue. + if (i === -1) { + continue; + } + + // Ignore the prelude lines up to the first interactive prompt. + lines.splice(0, i); + inPrelude = false; + } + + // Print out all the lines. + lines.forEach((line, i) => { + // Remove the temp dir from any paths printed out by `npm init`. + process.stdout.write(line.replace(this._regexp, "")); + if (i < lines.length - 1) { + process.stdout.write('\n'); + } + }); + } + } +} + +export function npmInit( + interactive: boolean, + args: string[], + cwd: string, + tmp: string +): Promise { + return (new NpmInit(interactive, args, cwd, tmp)).exit(); +} - child.on("exit", async (code) => { - if (code == null) { - reject(Error(`error code: ${code}`)); +export type Parser = (v: string) => T; + +export function oneOf(opts: T): Parser { + return (v: string) => { + for (const key in opts) { + if (v === key) { + return opts[key]; + } } - if (code !== 0) { - //This will catch answering no and many other failures - reject(Error(`error code: ${code}`)); + throw new Error('parse error'); + }; +} + +export interface Question { + prompt: string, + parse: Parser, + default: T, + error?: string +}; + +export class Dialog { + private _rl: readline.Interface | undefined; + + constructor() { + this._rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + } + + private rl(): readline.Interface { + if (!this._rl) { + throw new Error("dialog already ended"); } + return this._rl; + } + + end() { + this.rl().close(); + this._rl = undefined; + } - if (code === 0) { + async ask(opts: Question): Promise { + while (true) { try { - let data = await fs.readFile(path.join(cwd, "package.json"), "utf8"); - //Testing whether npm init was successful. - //It will catch Ctrl+C and many other failures - let { description, author, license } = JSON.parse(data); - if ([description, author, license].includes(undefined)) { - reject(Error(`error code: ${code}`)); + const answer = (await this.rl().question(`neon ${opts.prompt}: (${String(opts.default)}) `)).trim(); + return answer === "" ? opts.default : opts.parse(answer); + } catch (_ignored) { + if (opts.error) { + console.log(`Sorry, ${opts.error}`); } - } catch (e: any) { - reject(e); } } - - resolve(undefined); - }); - - child.on("error", async (error) => { - reject(error); - }); - return result; + } } diff --git a/pkgs/create-neon/test/create-neon.ts b/pkgs/create-neon/test/create-neon.ts index 71d8d8f5c..d587000d2 100644 --- a/pkgs/create-neon/test/create-neon.ts +++ b/pkgs/create-neon/test/create-neon.ts @@ -49,7 +49,7 @@ describe("Project creation", () => { it("succeeds with all default answers", async () => { try { - await expect(spawn(NODE, [CREATE_NEON, PROJECT]), { + await expect(spawn(NODE, [CREATE_NEON, "--app", PROJECT]), { "package name:": "", "version:": "", "description:": "", @@ -89,7 +89,7 @@ describe("Project creation", () => { it("handles quotation marks in author and description", async () => { try { - await expect(spawn(NODE, [CREATE_NEON, PROJECT]), { + await expect(spawn(NODE, [CREATE_NEON, "--app", PROJECT]), { "package name:": "", "version:": "", "description:": 'the "hello world" of examples', @@ -124,4 +124,69 @@ describe("Project creation", () => { '"Dave Herman" ', ]); }); + + it("asks Neon project type if not specified", async () => { + try { + await expect(spawn(NODE, [CREATE_NEON, PROJECT]), { + "neon project type": "", + "package name:": "", + "version:": "", + "description:": "", + "git repository:": "", + "keywords:": "", + "author:": "", + "license:": "", + "Is this OK?": "", + }); + } catch (error: any) { + assert.fail("create-neon unexpectedly failed: " + error.message); + } + + JSON.parse( + await fs.readFile(path.join(PROJECT, "package.json"), { + encoding: "utf8", + }) + ); + + TOML.parse( + await fs.readFile(path.join(PROJECT, "Cargo.toml"), { encoding: "utf8" }) + ); + }); + + it("asks Neon lib questions interactively", async () => { + try { + await expect(spawn(NODE, [CREATE_NEON, PROJECT]), { + "neon project type": "lib", + "neon target platforms": "", + "neon binary cache": "", + "neon cache org": "", + "neon ci provider": "", + "package name:": "", + "version:": "", + "description:": "", + "git repository:": "", + "keywords:": "", + "author:": "", + "license:": "", + "Is this OK?": "", + }); + } catch (error: any) { + assert.fail("create-neon unexpectedly failed: " + error.message); + } + + let json = JSON.parse( + await fs.readFile(path.join(PROJECT, "package.json"), { + encoding: "utf8", + }) + ); + + assert.strictEqual(json.neon.type, "library"); + assert.strictEqual(json.neon.org, "@create-neon-test-project"); + assert.deepEqual(json.neon.platforms, ["common"]); + assert.strictEqual(json.neon.load, "./src/load.cts"); + + TOML.parse( + await fs.readFile(path.join(PROJECT, "Cargo.toml"), { encoding: "utf8" }) + ); + }); });