Skip to content
This repository was archived by the owner on Apr 13, 2025. It is now read-only.

Commit c0cd7d9

Browse files
authored
feat(generate): Add support for generating bundles with dev installs (#62)
* Add npm util to find all packages inside a directory * Add support for generating bundles with dev installs * Fix tests * Clean code * Test whether bundles generated with dev installs fetch the packages using tarballs * Update readme
1 parent 20014e7 commit c0cd7d9

File tree

11 files changed

+136
-68
lines changed

11 files changed

+136
-68
lines changed

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ Uses your installed nodecg-io version and services, meaning you need to have the
4848
These generated bundles are only meant as a starting point, you may probably do more things like creating a git repository for your bundle,
4949
add a licence, or add other tools like linters.
5050

51-
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.
51+
If you are using a released version of nodecg-io (aka. a production install) the nodecg-io packages get fetched directly from npm.
52+
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).
5253

5354
## A note about versioning
5455

@@ -59,9 +60,7 @@ The following table show which versions of the CLI are compatible with which nod
5960
| CLI versions | nodecg-io versions |
6061
| ------------ | ------------------ |
6162
| `0.1` | `0.1` |
62-
| `0.2` | `0.2`, `0.1` |
63-
64-
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.
63+
| `0.3`, `0.2` | `0.2`, `0.1` |
6564

6665
## Developer workflow
6766

src/generate/extension.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import CodeBlockWriter from "code-block-writer";
22
import { getServiceClientName } from "../nodecgIOVersions";
3-
import { ProductionInstallation } from "../utils/installation";
3+
import { Installation } from "../utils/installation";
44
import { CodeLanguage, GenerationOptions } from "./prompt";
55
import { writeBundleFile } from "./utils";
66

@@ -33,12 +33,15 @@ function getServiceNames(serviceBaseName: string, nodecgIOVersion: string): Serv
3333
};
3434
}
3535

36-
export async function genExtension(opts: GenerationOptions, install: ProductionInstallation): Promise<void> {
37-
// Generate further information for each service which is needed to generate the bundle extension.
38-
const services = opts.services.map((svc) => getServiceNames(svc, install.version));
36+
export async function genExtension(opts: GenerationOptions, install: Installation): Promise<void> {
37+
// Generate all variants of the service names if were doing it from a production install.
38+
// We can't generate the imports and stuff if we currently have a development install because
39+
// the service names for each version are hardcoded and unknown for a development version.
40+
const services = install.dev === false ? opts.services.map((svc) => getServiceNames(svc, install.version)) : [];
3941

4042
const writer = new CodeBlockWriter();
4143

44+
// imports
4245
genImport(writer, "requireService", opts.corePackage.name, opts.language);
4346

4447
if (opts.language === "typescript") {

src/generate/index.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ import { CommandModule } from "yargs";
22
import * as fs from "fs";
33
import { logger } from "../utils/log";
44
import { directoryExists } from "../utils/fs";
5-
import { Installation, ProductionInstallation, readInstallInfo } from "../utils/installation";
5+
import { Installation, readInstallInfo } from "../utils/installation";
66
import { corePackages } from "../nodecgIOVersions";
77
import { GenerationOptions, promptGenerationOpts } from "./prompt";
88
import { runNpmBuild, runNpmInstall } from "../utils/npm";
99
import { genExtension } from "./extension";
1010
import { findNodeCGDirectory, getNodeCGIODirectory } from "../utils/nodecgInstallation";
1111
import { genDashboard, genGraphic } from "./panel";
1212
import { genTsConfig } from "./tsConfig";
13-
import { writeBundleFile, yellowGenerateCommand, yellowInstallCommand } from "./utils";
13+
import { writeBundleFile, yellowInstallCommand } from "./utils";
1414
import { genPackageJson } from "./packageJson";
1515

1616
export const generateModule: CommandModule = {
@@ -40,19 +40,17 @@ export const generateModule: CommandModule = {
4040
};
4141

4242
/**
43-
* Ensures that a installation can be used to generate bundles, meaning nodecg-io is actually installed,
44-
* is not a dev install and has some services installed that can be used.
43+
* Ensures that a installation can be used to generate bundles, meaning nodecg-io is actually installed
44+
* including at least one service that can be used for generating a bundle.
4545
* Throws an error if the installation cannot be used to generate a bundle with an explanation.
4646
*/
47-
export function ensureValidInstallation(install: Installation | undefined): install is ProductionInstallation {
47+
export function ensureValidInstallation(install: Installation | undefined): install is Installation {
4848
if (install === undefined) {
4949
throw new Error(
5050
"nodecg-io is not installed to your local nodecg install.\n" +
5151
`Please install it first using this command: ${yellowInstallCommand}`,
5252
);
53-
} else if (install.dev) {
54-
throw new Error(`You cannot use ${yellowGenerateCommand} together with a development installation.`);
55-
} else if (install.packages.length <= corePackages.length) {
53+
} else if (install.dev === false && install.packages.length <= corePackages.length) {
5654
// just has core packages without any services installed.
5755
throw new Error(
5856
`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
6361
return true;
6462
}
6563

66-
export async function generateBundle(opts: GenerationOptions, install: ProductionInstallation): Promise<void> {
64+
export async function generateBundle(opts: GenerationOptions, install: Installation): Promise<void> {
6765
// Create dir if necessary
6866
if (!(await directoryExists(opts.bundlePath))) {
6967
await fs.promises.mkdir(opts.bundlePath);
@@ -80,7 +78,7 @@ export async function generateBundle(opts: GenerationOptions, install: Productio
8078
}
8179

8280
// All of these calls only generate files if they are set accordingly in the GenerationOptions
83-
await genPackageJson(opts);
81+
await genPackageJson(opts, install);
8482
await genTsConfig(opts);
8583
await genGitIgnore(opts);
8684
await genExtension(opts, install);

src/generate/packageJson.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { getLatestPackageVersion } from "../utils/npm";
44
import { genNodeCGDashboardConfig, genNodeCGGraphicConfig } from "./panel";
55
import { SemVer } from "semver";
66
import { writeBundleFile } from "./utils";
7+
import { Installation } from "../utils/installation";
8+
9+
// Loaction where the development tarballs are hosted.
10+
export const developmentPublishRootUrl = "https://codeoverflow-org.github.io/nodecg-io-publish/";
711

812
/**
913
* 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];
1317
/**
1418
* Generates the whole package.json file for the bundle.
1519
*
16-
* @param nodecgDir the directory in which nodecg is installed
1720
* @param opts the options that the user chose for the bundle.
21+
* @param install the nodecg-io installation that will used to get the versions of the various packages.
1822
*/
19-
export async function genPackageJson(opts: GenerationOptions): Promise<void> {
20-
const serviceDeps: Dependency[] = opts.servicePackages.map((pkg) => [pkg.name, addSemverCaret(pkg.version)]);
23+
export async function genPackageJson(opts: GenerationOptions, install: Installation): Promise<void> {
24+
const serviceDeps = opts.servicePackages.map((pkg) => getNodecgIODependency(pkg.name, pkg.version, install));
2125

2226
const content = {
2327
name: opts.bundleName,
2428
version: opts.version.version,
2529
private: true,
2630
nodecg: {
27-
compatibleRange: addSemverCaret("1.4.0"),
28-
bundleDependencies: Object.fromEntries(serviceDeps),
31+
compatibleRange: "^1.4.0",
32+
bundleDependencies: Object.fromEntries(opts.servicePackages.map((pkg) => [pkg.name, `^${pkg.version}`])),
2933
graphics: genNodeCGGraphicConfig(opts),
3034
dashboardPanels: genNodeCGDashboardConfig(opts),
3135
},
3236
// These scripts are for compiling TS and thus are only needed when generating a TS bundle
3337
scripts: genScripts(opts),
34-
dependencies: Object.fromEntries(await genDependencies(opts, serviceDeps)),
38+
dependencies: Object.fromEntries(await genDependencies(opts, serviceDeps, install)),
3539
};
3640

3741
await writeBundleFile(content, opts.bundlePath, "package.json");
@@ -45,8 +49,8 @@ export async function genPackageJson(opts: GenerationOptions): Promise<void> {
4549
* @param nodecgDir the directory in which nodecg is installed
4650
* @return the dependencies for a bundle with the given options.
4751
*/
48-
async function genDependencies(opts: GenerationOptions, serviceDeps: Dependency[]) {
49-
const core = [opts.corePackage.name, addSemverCaret(opts.corePackage.version)];
52+
async function genDependencies(opts: GenerationOptions, serviceDeps: Dependency[], install: Installation) {
53+
const core = getNodecgIODependency(opts.corePackage.name, opts.corePackage.version, install);
5054

5155
if (opts.language === "typescript") {
5256
// 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<Depen
7377
]);
7478

7579
return [
76-
[opts.nodeeCGTypingsPackage, addSemverCaret(nodecgVersion)],
77-
["@types/node", addSemverCaret(latestNodeTypes)],
78-
["typescript", addSemverCaret(latestTypeScript)],
80+
[opts.nodeeCGTypingsPackage, `^${nodecgVersion}`],
81+
["@types/node", `^${latestNodeTypes}`],
82+
["typescript", `^${latestTypeScript}`],
7983
];
8084
}
8185

@@ -86,6 +90,7 @@ async function genTypeScriptDependencies(opts: GenerationOptions): Promise<Depen
8690
*/
8791
function genScripts(opts: GenerationOptions) {
8892
if (opts.language !== "typescript") {
93+
// For JS we don't need any scripts to build anythiing.
8994
return undefined;
9095
}
9196

@@ -98,11 +103,14 @@ function genScripts(opts: GenerationOptions) {
98103
}
99104

100105
/**
101-
* Adds the semver caret operator to a given version to allow or minor and patch updates by npm.
102-
*
103-
* @param version the base version
104-
* @return the version with the semver caret operator in front.
106+
* Builds the npm dependency for the package with the passed name and version.
107+
* If this is a production install it will be from the npm registry and
108+
* if it is a development install it will be from a tarball of the nodecg-io-publish repository.
105109
*/
106-
function addSemverCaret(version: string | SemVer): string {
107-
return `^${version}`;
110+
function getNodecgIODependency(packageName: string, version: string | SemVer, install: Installation): Dependency {
111+
if (install.dev) {
112+
return [packageName, `${developmentPublishRootUrl}${packageName}-${version}.tgz`];
113+
} else {
114+
return [packageName, `^${version}`];
115+
}
108116
}

src/generate/prompt.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import * as semver from "semver";
22
import * as inquirer from "inquirer";
33
import * as path from "path";
44
import { directoryExists } from "../utils/fs";
5-
import { ProductionInstallation } from "../utils/installation";
5+
import { Installation } from "../utils/installation";
66
import { getServicesFromInstall } from "../install/prompt";
77
import { yellowInstallCommand } from "./utils";
8-
import { NpmPackage } from "../utils/npm";
8+
import { findNpmPackages, NpmPackage } from "../utils/npm";
99
import { corePackage } from "../nodecgIOVersions";
10+
import { getNodeCGIODirectory } from "../utils/nodecgInstallation";
1011

1112
/**
1213
* Describes all options for bundle generation a user has answered with inside the inquirer prompt
@@ -36,16 +37,15 @@ export type CodeLanguage = "typescript" | "javascript";
3637

3738
const kebabCaseRegex = /^([a-z][a-z0-9]*)(-[a-z0-9]+)*$/;
3839

39-
export async function promptGenerationOpts(
40-
nodecgDir: string,
41-
install: ProductionInstallation,
42-
): Promise<GenerationOptions> {
40+
export async function promptGenerationOpts(nodecgDir: string, install: Installation): Promise<GenerationOptions> {
4341
const defaultBundleDir = path.join(nodecgDir, "bundles");
4442
// if we are already in a bundle directory we use the name of the directory as a bundle name and the corresponding bundle dir
4543
const inBundleDir = path.dirname(process.cwd()) === defaultBundleDir;
4644
const bundleName = inBundleDir ? path.basename(process.cwd()) : undefined;
4745
const bundleDir = inBundleDir ? path.dirname(process.cwd()) : defaultBundleDir;
4846

47+
const installedPackages = install.dev ? await findNpmPackages(getNodeCGIODirectory(nodecgDir)) : install.packages;
48+
4949
const opts: PromptedGenerationOptions = await inquirer.prompt([
5050
{
5151
type: "input",
@@ -74,13 +74,20 @@ export async function promptGenerationOpts(
7474
validate: validateVersion,
7575
filter: (ver) => new semver.SemVer(ver),
7676
},
77-
{
78-
type: "checkbox",
79-
name: "services",
80-
message: `Which services would you like to use? (they must be installed through ${yellowInstallCommand} first)`,
81-
choices: getServicesFromInstall(install, install.version),
82-
validate: validateServiceSelection,
83-
},
77+
!install.dev
78+
? {
79+
type: "checkbox",
80+
name: "services",
81+
message: `Which services would you like to use? (they must be installed through ${yellowInstallCommand} first)`,
82+
choices: getServicesFromInstall(installedPackages, install.version),
83+
validate: validateServiceSelection,
84+
}
85+
: {
86+
type: "input",
87+
name: "services",
88+
message: `Which services would you like to use? (comma separated)`,
89+
filter: (servicesString) => servicesString.split(","),
90+
},
8491
{
8592
type: "list",
8693
name: "language",
@@ -99,7 +106,7 @@ export async function promptGenerationOpts(
99106
},
100107
]);
101108

102-
return computeGenOptsFields(opts, install);
109+
return computeGenOptsFields(opts, install, installedPackages);
103110
}
104111

105112
// region prompt validation
@@ -151,9 +158,10 @@ function validateServiceSelection(services: string[]): true | string {
151158
*/
152159
export function computeGenOptsFields(
153160
opts: PromptedGenerationOptions,
154-
install: ProductionInstallation,
161+
install: Installation,
162+
installedPackages: NpmPackage[],
155163
): GenerationOptions {
156-
const corePkg = install.packages.find((pkg) => pkg.name === corePackage);
164+
const corePkg = installedPackages.find((pkg) => pkg.name === corePackage);
157165
if (corePkg === undefined) {
158166
throw new Error("Core package in installation info could not be found.");
159167
}
@@ -162,7 +170,7 @@ export function computeGenOptsFields(
162170
...opts,
163171
corePackage: corePkg,
164172
servicePackages: opts.services.map((svc) => {
165-
const svcPackage = install.packages.find((pkg) => pkg.name.endsWith(svc));
173+
const svcPackage = installedPackages.find((pkg) => pkg.name.endsWith(svc));
166174

167175
if (svcPackage === undefined) {
168176
throw new Error(`Service ${svc} has no corresponding package in the passed installation.`);

src/generate/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import * as chalk from "chalk";
66

77
// Colored commands for logging purposes.
88
export const yellowInstallCommand = chalk.yellow("nodecg-io install");
9-
export const yellowGenerateCommand = chalk.yellow("nodecg-io generate");
109

1110
/**
1211
* Writes a file for a bundle.

src/install/prompt.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Installation, ProductionInstallation } from "../utils/installation";
1+
import { Installation } from "../utils/installation";
22
import * as inquirer from "inquirer";
33
import { getHighestPatchVersion, getMinorVersions, NpmPackage } from "../utils/npm";
44
import * as semver from "semver";
@@ -85,7 +85,7 @@ export async function promptForInstallInfo(
8585
when: (x: PromptInput) => x.version !== developmentVersion,
8686
default: (x: PromptInput) => {
8787
if (!currentProd) return;
88-
return getServicesFromInstall(currentProd, x.version);
88+
return getServicesFromInstall(currentProd.packages, x.version);
8989
},
9090
},
9191
]);
@@ -185,15 +185,15 @@ function getPackageSymlinks(version: string, pkgName: string) {
185185
}
186186

187187
/**
188-
* Returns the list of installed services of a production installation.
189-
* @param install the installation info for which you want the list of installed services.
188+
* Returns the list of installed services of a nodecg-io installation.
189+
* @param installedPackages a array with all packages that are installed
190190
* @param targetVersion the version of nodecg-io that is installed
191191
* @returns the list of installed services (package names without the nodecg-io- prefix)
192192
*/
193-
export function getServicesFromInstall(install: ProductionInstallation, targetVersion: string): string[] {
193+
export function getServicesFromInstall(installedPackages: NpmPackage[], targetVersion: string): string[] {
194194
const availableServices = getServicesForVersion(targetVersion);
195195

196-
const svcPackages = install.packages
196+
const svcPackages = installedPackages
197197
// Exclude core packages, they are not a optional service, they are always required
198198
.filter((pkg) => !corePackages.find((corePkg) => pkg.name === corePkg))
199199
.map((pkg) => pkg.name.replace("nodecg-io-", ""))

src/utils/npm.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,48 @@ export function getSubPackages(allPackages: NpmPackage[], rootPkg: NpmPackage):
236236
return allPackages.filter((pkg) => pkg !== rootPkg && pkg.path.startsWith(rootPkg.path));
237237
}
238238

239+
/**
240+
* Recursively finds npm packages using {@link findNpmPackages} in the given directory.
241+
*/
242+
export async function findNpmPackages(basePath: string): Promise<NpmPackage[]> {
243+
// If there is a package in this directory, get it
244+
const pkg = await getNpmPackageFromPath(basePath);
245+
246+
// Enumerate sub directories and get any packages in these too
247+
const subDirs = await fs.promises.readdir(basePath, { withFileTypes: true });
248+
const subPackages = await Promise.all(
249+
subDirs
250+
.filter((f) => f.isDirectory())
251+
.map((f) => f.name)
252+
.filter((dir) => dir !== "node_modules") // dependencies, not interesting to us. Also waaaay to big to check, lol
253+
.map((subDir) => findNpmPackages(path.join(basePath, subDir))),
254+
);
255+
256+
return [pkg, ...subPackages.flat()].filter((p): p is NpmPackage => p !== undefined);
257+
}
258+
259+
/**
260+
* Gets the npm package that is located in the directory of the passed path.
261+
* @param basePath the root directory of the package where the package.json resides in
262+
* @returns if a package.json was found and the package is public, the npm package. Otherwise undefined
263+
*/
264+
async function getNpmPackageFromPath(basePath: string): Promise<NpmPackage | undefined> {
265+
const packageJsonPath = `${basePath}/package.json`;
266+
try {
267+
const packageJson = await fs.promises.readFile(packageJsonPath, "utf8");
268+
const pkg = JSON.parse(packageJson);
269+
if (pkg.private) return undefined;
270+
271+
return {
272+
name: pkg.name,
273+
version: pkg.version,
274+
path: basePath,
275+
};
276+
} catch (e) {
277+
return undefined;
278+
}
279+
}
280+
239281
/**
240282
* Gets version of the installed npm by running "npm --version".
241283
* @returns the npm version or undefined if npm is not installed/not in $PATH.

0 commit comments

Comments
 (0)