diff --git a/README.md b/README.md index 0b8491c..7d72335 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ Uses your installed nodecg-io version and services, meaning you need to have the These generated bundles are only meant as a starting point, you may probably do more things like creating a git repository for your bundle, add a licence, or add other tools like linters. -Also, this command currently only works with installs of released versions and not with development installs. This is because all bundles using nodecg-io depend on `nodecg-io-core` and if you use typescript each used service as well. For development installs these are not published on npm, and you would need some way of linking the packages locally. +If you are using a released version of nodecg-io (aka. a production install) the nodecg-io packages get fetched directly from npm. +If you are using a development version of nodecg-io these get fetched as tarballs from the [nodecg-io-publish repository](https://github.com/codeoverflow-org/nodecg-io-publish). ## A note about versioning @@ -59,9 +60,7 @@ The following table show which versions of the CLI are compatible with which nod | CLI versions | nodecg-io versions | | ------------ | ------------------ | | `0.1` | `0.1` | -| `0.2` | `0.2`, `0.1` | - -Currently, they are the same, but we will follow [semver2](https://semver.org/) using [semantic-release](https://semantic-release.gitbook.io/semantic-release/) and the versions will diverge at some point. +| `0.3`, `0.2` | `0.2`, `0.1` | ## Developer workflow diff --git a/src/generate/extension.ts b/src/generate/extension.ts index d9a56c6..1a817e2 100644 --- a/src/generate/extension.ts +++ b/src/generate/extension.ts @@ -1,6 +1,6 @@ import CodeBlockWriter from "code-block-writer"; import { getServiceClientName } from "../nodecgIOVersions"; -import { ProductionInstallation } from "../utils/installation"; +import { Installation } from "../utils/installation"; import { CodeLanguage, GenerationOptions } from "./prompt"; import { writeBundleFile } from "./utils"; @@ -33,12 +33,15 @@ function getServiceNames(serviceBaseName: string, nodecgIOVersion: string): Serv }; } -export async function genExtension(opts: GenerationOptions, install: ProductionInstallation): Promise { - // Generate further information for each service which is needed to generate the bundle extension. - const services = opts.services.map((svc) => getServiceNames(svc, install.version)); +export async function genExtension(opts: GenerationOptions, install: Installation): Promise { + // Generate all variants of the service names if were doing it from a production install. + // We can't generate the imports and stuff if we currently have a development install because + // the service names for each version are hardcoded and unknown for a development version. + const services = install.dev === false ? opts.services.map((svc) => getServiceNames(svc, install.version)) : []; const writer = new CodeBlockWriter(); + // imports genImport(writer, "requireService", opts.corePackage.name, opts.language); if (opts.language === "typescript") { diff --git a/src/generate/index.ts b/src/generate/index.ts index ec5c2d6..8ac6168 100644 --- a/src/generate/index.ts +++ b/src/generate/index.ts @@ -2,7 +2,7 @@ import { CommandModule } from "yargs"; import * as fs from "fs"; import { logger } from "../utils/log"; import { directoryExists } from "../utils/fs"; -import { Installation, ProductionInstallation, readInstallInfo } from "../utils/installation"; +import { Installation, readInstallInfo } from "../utils/installation"; import { corePackages } from "../nodecgIOVersions"; import { GenerationOptions, promptGenerationOpts } from "./prompt"; import { runNpmBuild, runNpmInstall } from "../utils/npm"; @@ -10,7 +10,7 @@ import { genExtension } from "./extension"; import { findNodeCGDirectory, getNodeCGIODirectory } from "../utils/nodecgInstallation"; import { genDashboard, genGraphic } from "./panel"; import { genTsConfig } from "./tsConfig"; -import { writeBundleFile, yellowGenerateCommand, yellowInstallCommand } from "./utils"; +import { writeBundleFile, yellowInstallCommand } from "./utils"; import { genPackageJson } from "./packageJson"; export const generateModule: CommandModule = { @@ -40,19 +40,17 @@ export const generateModule: CommandModule = { }; /** - * Ensures that a installation can be used to generate bundles, meaning nodecg-io is actually installed, - * is not a dev install and has some services installed that can be used. + * Ensures that a installation can be used to generate bundles, meaning nodecg-io is actually installed + * including at least one service that can be used for generating a bundle. * Throws an error if the installation cannot be used to generate a bundle with an explanation. */ -export function ensureValidInstallation(install: Installation | undefined): install is ProductionInstallation { +export function ensureValidInstallation(install: Installation | undefined): install is Installation { if (install === undefined) { throw new Error( "nodecg-io is not installed to your local nodecg install.\n" + `Please install it first using this command: ${yellowInstallCommand}`, ); - } else if (install.dev) { - throw new Error(`You cannot use ${yellowGenerateCommand} together with a development installation.`); - } else if (install.packages.length <= corePackages.length) { + } else if (install.dev === false && install.packages.length <= corePackages.length) { // just has core packages without any services installed. throw new Error( `You first need to have at least one service installed to generate a bundle.\n` + @@ -63,7 +61,7 @@ export function ensureValidInstallation(install: Installation | undefined): inst return true; } -export async function generateBundle(opts: GenerationOptions, install: ProductionInstallation): Promise { +export async function generateBundle(opts: GenerationOptions, install: Installation): Promise { // Create dir if necessary if (!(await directoryExists(opts.bundlePath))) { await fs.promises.mkdir(opts.bundlePath); @@ -80,7 +78,7 @@ export async function generateBundle(opts: GenerationOptions, install: Productio } // All of these calls only generate files if they are set accordingly in the GenerationOptions - await genPackageJson(opts); + await genPackageJson(opts, install); await genTsConfig(opts); await genGitIgnore(opts); await genExtension(opts, install); diff --git a/src/generate/packageJson.ts b/src/generate/packageJson.ts index 1cde948..52fed8c 100644 --- a/src/generate/packageJson.ts +++ b/src/generate/packageJson.ts @@ -4,6 +4,10 @@ import { getLatestPackageVersion } from "../utils/npm"; import { genNodeCGDashboardConfig, genNodeCGGraphicConfig } from "./panel"; import { SemVer } from "semver"; import { writeBundleFile } from "./utils"; +import { Installation } from "../utils/installation"; + +// Loaction where the development tarballs are hosted. +export const developmentPublishRootUrl = "https://codeoverflow-org.github.io/nodecg-io-publish/"; /** * A dependency on a npm package. First field is the package name and the second field is the version. @@ -13,25 +17,25 @@ type Dependency = [string, string]; /** * Generates the whole package.json file for the bundle. * - * @param nodecgDir the directory in which nodecg is installed * @param opts the options that the user chose for the bundle. + * @param install the nodecg-io installation that will used to get the versions of the various packages. */ -export async function genPackageJson(opts: GenerationOptions): Promise { - const serviceDeps: Dependency[] = opts.servicePackages.map((pkg) => [pkg.name, addSemverCaret(pkg.version)]); +export async function genPackageJson(opts: GenerationOptions, install: Installation): Promise { + const serviceDeps = opts.servicePackages.map((pkg) => getNodecgIODependency(pkg.name, pkg.version, install)); const content = { name: opts.bundleName, version: opts.version.version, private: true, nodecg: { - compatibleRange: addSemverCaret("1.4.0"), - bundleDependencies: Object.fromEntries(serviceDeps), + compatibleRange: "^1.4.0", + bundleDependencies: Object.fromEntries(opts.servicePackages.map((pkg) => [pkg.name, `^${pkg.version}`])), graphics: genNodeCGGraphicConfig(opts), dashboardPanels: genNodeCGDashboardConfig(opts), }, // These scripts are for compiling TS and thus are only needed when generating a TS bundle scripts: genScripts(opts), - dependencies: Object.fromEntries(await genDependencies(opts, serviceDeps)), + dependencies: Object.fromEntries(await genDependencies(opts, serviceDeps, install)), }; await writeBundleFile(content, opts.bundlePath, "package.json"); @@ -45,8 +49,8 @@ export async function genPackageJson(opts: GenerationOptions): Promise { * @param nodecgDir the directory in which nodecg is installed * @return the dependencies for a bundle with the given options. */ -async function genDependencies(opts: GenerationOptions, serviceDeps: Dependency[]) { - const core = [opts.corePackage.name, addSemverCaret(opts.corePackage.version)]; +async function genDependencies(opts: GenerationOptions, serviceDeps: Dependency[], install: Installation) { + const core = getNodecgIODependency(opts.corePackage.name, opts.corePackage.version, install); if (opts.language === "typescript") { // For typescript we need core, all services (for typings) and special packages like ts itself or node typings. @@ -73,9 +77,9 @@ async function genTypeScriptDependencies(opts: GenerationOptions): Promise { +export async function promptGenerationOpts(nodecgDir: string, install: Installation): Promise { const defaultBundleDir = path.join(nodecgDir, "bundles"); // if we are already in a bundle directory we use the name of the directory as a bundle name and the corresponding bundle dir const inBundleDir = path.dirname(process.cwd()) === defaultBundleDir; const bundleName = inBundleDir ? path.basename(process.cwd()) : undefined; const bundleDir = inBundleDir ? path.dirname(process.cwd()) : defaultBundleDir; + const installedPackages = install.dev ? await findNpmPackages(getNodeCGIODirectory(nodecgDir)) : install.packages; + const opts: PromptedGenerationOptions = await inquirer.prompt([ { type: "input", @@ -74,13 +74,20 @@ export async function promptGenerationOpts( validate: validateVersion, filter: (ver) => new semver.SemVer(ver), }, - { - type: "checkbox", - name: "services", - message: `Which services would you like to use? (they must be installed through ${yellowInstallCommand} first)`, - choices: getServicesFromInstall(install, install.version), - validate: validateServiceSelection, - }, + !install.dev + ? { + type: "checkbox", + name: "services", + message: `Which services would you like to use? (they must be installed through ${yellowInstallCommand} first)`, + choices: getServicesFromInstall(installedPackages, install.version), + validate: validateServiceSelection, + } + : { + type: "input", + name: "services", + message: `Which services would you like to use? (comma separated)`, + filter: (servicesString) => servicesString.split(","), + }, { type: "list", name: "language", @@ -99,7 +106,7 @@ export async function promptGenerationOpts( }, ]); - return computeGenOptsFields(opts, install); + return computeGenOptsFields(opts, install, installedPackages); } // region prompt validation @@ -151,9 +158,10 @@ function validateServiceSelection(services: string[]): true | string { */ export function computeGenOptsFields( opts: PromptedGenerationOptions, - install: ProductionInstallation, + install: Installation, + installedPackages: NpmPackage[], ): GenerationOptions { - const corePkg = install.packages.find((pkg) => pkg.name === corePackage); + const corePkg = installedPackages.find((pkg) => pkg.name === corePackage); if (corePkg === undefined) { throw new Error("Core package in installation info could not be found."); } @@ -162,7 +170,7 @@ export function computeGenOptsFields( ...opts, corePackage: corePkg, servicePackages: opts.services.map((svc) => { - const svcPackage = install.packages.find((pkg) => pkg.name.endsWith(svc)); + const svcPackage = installedPackages.find((pkg) => pkg.name.endsWith(svc)); if (svcPackage === undefined) { throw new Error(`Service ${svc} has no corresponding package in the passed installation.`); diff --git a/src/generate/utils.ts b/src/generate/utils.ts index 4fe8340..7745a45 100644 --- a/src/generate/utils.ts +++ b/src/generate/utils.ts @@ -6,7 +6,6 @@ import * as chalk from "chalk"; // Colored commands for logging purposes. export const yellowInstallCommand = chalk.yellow("nodecg-io install"); -export const yellowGenerateCommand = chalk.yellow("nodecg-io generate"); /** * Writes a file for a bundle. diff --git a/src/install/prompt.ts b/src/install/prompt.ts index a84355d..c5ea21b 100644 --- a/src/install/prompt.ts +++ b/src/install/prompt.ts @@ -1,4 +1,4 @@ -import { Installation, ProductionInstallation } from "../utils/installation"; +import { Installation } from "../utils/installation"; import * as inquirer from "inquirer"; import { getHighestPatchVersion, getMinorVersions, NpmPackage } from "../utils/npm"; import * as semver from "semver"; @@ -66,7 +66,7 @@ export async function promptForInstallInfo(currentInstall: Installation | undefi when: (x: PromptInput) => x.version !== developmentVersion, default: (x: PromptInput) => { if (!currentProd) return; - return getServicesFromInstall(currentProd, x.version); + return getServicesFromInstall(currentProd.packages, x.version); }, }, ]); @@ -166,15 +166,15 @@ function getPackageSymlinks(version: string, pkgName: string) { } /** - * Returns the list of installed services of a production installation. - * @param install the installation info for which you want the list of installed services. + * Returns the list of installed services of a nodecg-io installation. + * @param installedPackages a array with all packages that are installed * @param targetVersion the version of nodecg-io that is installed * @returns the list of installed services (package names without the nodecg-io- prefix) */ -export function getServicesFromInstall(install: ProductionInstallation, targetVersion: string): string[] { +export function getServicesFromInstall(installedPackages: NpmPackage[], targetVersion: string): string[] { const availableServices = getServicesForVersion(targetVersion); - const svcPackages = install.packages + const svcPackages = installedPackages // Exclude core packages, they are not a optional service, they are always required .filter((pkg) => !corePackages.find((corePkg) => pkg.name === corePkg)) .map((pkg) => pkg.name.replace("nodecg-io-", "")) diff --git a/src/utils/npm.ts b/src/utils/npm.ts index 5357418..7cf01b7 100644 --- a/src/utils/npm.ts +++ b/src/utils/npm.ts @@ -236,6 +236,48 @@ export function getSubPackages(allPackages: NpmPackage[], rootPkg: NpmPackage): return allPackages.filter((pkg) => pkg !== rootPkg && pkg.path.startsWith(rootPkg.path)); } +/** + * Recursively finds npm packages using {@link findNpmPackages} in the given directory. + */ +export async function findNpmPackages(basePath: string): Promise { + // If there is a package in this directory, get it + const pkg = await getNpmPackageFromPath(basePath); + + // Enumerate sub directories and get any packages in these too + const subDirs = await fs.promises.readdir(basePath, { withFileTypes: true }); + const subPackages = await Promise.all( + subDirs + .filter((f) => f.isDirectory()) + .map((f) => f.name) + .filter((dir) => dir !== "node_modules") // dependencies, not interesting to us. Also waaaay to big to check, lol + .map((subDir) => findNpmPackages(path.join(basePath, subDir))), + ); + + return [pkg, ...subPackages.flat()].filter((p): p is NpmPackage => p !== undefined); +} + +/** + * Gets the npm package that is located in the directory of the passed path. + * @param basePath the root directory of the package where the package.json resides in + * @returns if a package.json was found and the package is public, the npm package. Otherwise undefined + */ +async function getNpmPackageFromPath(basePath: string): Promise { + const packageJsonPath = `${basePath}/package.json`; + try { + const packageJson = await fs.promises.readFile(packageJsonPath, "utf8"); + const pkg = JSON.parse(packageJson); + if (pkg.private) return undefined; + + return { + name: pkg.name, + version: pkg.version, + path: basePath, + }; + } catch (e) { + return undefined; + } +} + /** * Gets version of the installed npm by running "npm --version". * @returns the npm version or undefined if npm is not installed/not in $PATH. diff --git a/test/generate/index.ts b/test/generate/index.ts index 3352f73..21e76d1 100644 --- a/test/generate/index.ts +++ b/test/generate/index.ts @@ -8,6 +8,8 @@ import * as npm from "../../src/utils/npm"; import { ensureValidInstallation, generateBundle } from "../../src/generate"; import { computeGenOptsFields, GenerationOptions } from "../../src/generate/prompt"; import { defaultOpts, defaultOptsPrompt, jsOpts } from "./opts.util"; +import { developmentPublishRootUrl } from "../../src/generate/packageJson"; +import { Installation } from "../../src/utils/installation"; const nodecgPackageJsonPath = path.join(fsRoot, "package.json"); const packageJsonPath = path.join(defaultOpts.bundlePath, "package.json"); @@ -33,10 +35,6 @@ describe("ensureValidInstallation", () => { expect(() => ensureValidInstallation(undefined)).toThrow("not installed"); }); - test("should throw when passing a development installation", () => { - expect(() => ensureValidInstallation(validDevInstall)).toThrow("development installation"); - }); - test("should throw when passing install with no services", () => { expect(() => ensureValidInstallation({ ...validProdInstall, packages: [corePkg] })).toThrow( "at least one service", @@ -78,8 +76,11 @@ describe("generateBundle", () => { describe("genPackageJson", () => { // We don't have a good type for a package.json and this is only testing code so this should be fine. // eslint-disable-next-line @typescript-eslint/no-explicit-any - async function genPackageJSON(opts: GenerationOptions = defaultOpts): Promise { - await generateBundle(opts, validProdInstall); + async function genPackageJSON( + opts: GenerationOptions = defaultOpts, + install: Installation = validProdInstall, + ): Promise { + await generateBundle(opts, install); const packageJsonStr = await vol.promises.readFile(packageJsonPath); if (!packageJsonStr) throw new Error("package.json does not exist"); return JSON.parse(packageJsonStr.toString()); @@ -112,8 +113,18 @@ describe("genPackageJson", () => { expect(e).toEqual(expect.arrayContaining([["nodecg", `^1.2.3`]])); }); + test("should get dependencies using tarballs if a development install is used", async () => { + const deps = (await genPackageJSON(defaultOpts, validDevInstall))["dependencies"]; + + expect(deps["nodecg-io-core"]).toContain(developmentPublishRootUrl); + }); + test("should use nodecg-types for 0.2 or higher", async () => { - const opts = computeGenOptsFields(defaultOptsPrompt, { ...validProdInstall, version: "0.2" }); + const opts = computeGenOptsFields( + defaultOptsPrompt, + { ...validProdInstall, version: "0.2" }, + validProdInstall.packages, + ); const deps = (await genPackageJSON(opts))["dependencies"]; const e = Object.entries(deps); expect(e).toEqual(expect.arrayContaining([[twitchChatPkg.name, `^${twitchChatPkg.version}`]])); diff --git a/test/generate/opts.util.ts b/test/generate/opts.util.ts index 728187b..2efcfbd 100644 --- a/test/generate/opts.util.ts +++ b/test/generate/opts.util.ts @@ -14,5 +14,5 @@ export const defaultOptsPrompt: PromptedGenerationOptions = { dashboard: false, }; -export const defaultOpts = computeGenOptsFields(defaultOptsPrompt, validProdInstall); +export const defaultOpts = computeGenOptsFields(defaultOptsPrompt, validProdInstall, validProdInstall.packages); export const jsOpts: GenerationOptions = { ...defaultOpts, language: "javascript" }; diff --git a/test/install/prompt.ts b/test/install/prompt.ts index 0315633..f647f13 100644 --- a/test/install/prompt.ts +++ b/test/install/prompt.ts @@ -83,7 +83,7 @@ describe("getServicesFromInstall", () => { }, ], }; - const services = getServicesFromInstall(install, "0.1"); + const services = getServicesFromInstall(install.packages, "0.1"); test("should not return core packages", () => { expect(services.includes("core")).toBeFalsy();