nx/packages/tao/src/commands/migrate.ts

497 lines
14 KiB
TypeScript

import { gt, lte } from 'semver';
import { handleErrors, convertToCamelCase } from '../shared/params';
import { logger } from '../shared/logger';
import minimist = require('minimist');
import { commandName } from '../shared/print-help';
import { virtualFs, normalize, logging } from '@angular-devkit/core';
import { NodeJsSyncHost } from '@angular-devkit/core/node';
import { HostTree } from '@angular-devkit/schematics';
import { dirSync } from 'tmp';
import { readFileSync, writeFileSync, statSync } from 'fs';
import { NodeModulesEngineHost } from '@angular-devkit/schematics/tools';
import { BaseWorkflow } from '@angular-devkit/schematics/src/workflow';
import * as stripJsonComments from 'strip-json-comments';
import * as path from 'path';
import * as core from '@angular-devkit/core/node';
import { execSync } from 'child_process';
export type MigrationsJson = {
version: string;
schematics?: { [name: string]: { version: string } };
packageJsonUpdates?: {
[name: string]: {
version: string;
packages: {
[p: string]: {
version: string;
ifPackageInstalled?: string;
alwaysAddToPackageJson?: boolean;
};
};
};
};
};
export class Migrator {
private readonly versions: (p: string) => string;
private readonly fetch: (p: string, v: string) => Promise<MigrationsJson>;
private readonly from: { [p: string]: string };
private readonly to: { [p: string]: string };
constructor(opts: {
versions: (p: string) => string;
fetch: (p: string, v: string) => Promise<MigrationsJson>;
from: { [p: string]: string };
to: { [p: string]: string };
}) {
this.versions = opts.versions;
this.fetch = opts.fetch;
this.from = opts.from;
this.to = opts.to;
}
async updatePackageJson(targetPackage: string, targetVersion: string) {
const packageJson = await this._updatePackageJson(
targetPackage,
{ version: targetVersion, alwaysAddToPackageJson: false },
{}
);
const migrations = await this._createMigrateJson(packageJson);
return { packageJson, migrations };
}
private async _createMigrateJson(versions: {
[k: string]: { version: string; alwaysAddToPackageJson: boolean };
}) {
const migrations = await Promise.all(
Object.keys(versions).map(async c => {
const currentVersion = this.versions(c);
if (currentVersion === null) return [];
const target = versions[c];
const migrationsJson = await this.fetch(c, target.version);
if (!migrationsJson.schematics) return [];
return Object.keys(migrationsJson.schematics)
.filter(
r =>
gt(migrationsJson.schematics[r].version, currentVersion) &
lte(migrationsJson.schematics[r].version, target.version)
)
.map(r => ({
...migrationsJson.schematics[r],
package: c,
name: r
}));
})
);
return migrations.reduce((m, c) => [...m, ...c], []);
}
private async _updatePackageJson(
targetPackage: string,
target: { version: string; alwaysAddToPackageJson: boolean },
collectedVersions: {
[k: string]: { version: string; alwaysAddToPackageJson: boolean };
}
) {
let targetVersion = target.version;
if (this.to[targetPackage]) {
targetVersion = this.to[targetPackage];
}
let migrationsJson;
try {
migrationsJson = await this.fetch(targetPackage, targetVersion);
targetVersion = migrationsJson.version;
} catch (e) {
if (e.message.indexOf('No matching version') > -1) {
throw new Error(
`${
e.message
}\nRun migrate with --to="package1@version1,package2@version2"`
);
} else {
throw e;
}
}
const packages = this.collapsePackages(
targetPackage,
targetVersion,
migrationsJson
);
const childCalls = await Promise.all(
Object.keys(packages)
.filter(r => {
return (
!collectedVersions[r] ||
gt(packages[r].version, collectedVersions[r].version)
);
})
.map(u =>
this._updatePackageJson(u, packages[u], {
...collectedVersions,
[targetPackage]: target
})
)
);
return childCalls.reduce(
(m, c) => {
Object.keys(c).forEach(r => {
if (!m[r] || gt(c[r].version, m[r].version)) {
m[r] = c[r];
}
});
return m;
},
{
[targetPackage]: {
version: migrationsJson.version,
alwaysAddToPackageJson: target.alwaysAddToPackageJson || false
}
}
);
}
private collapsePackages(
packageName: string,
targetVersion: string,
m: MigrationsJson | null
) {
// this should be used to know what version to include
// we should use from everywhere we use versions
if (!m.packageJsonUpdates) return {};
return Object.keys(m.packageJsonUpdates)
.filter(r => {
return (
gt(m.packageJsonUpdates[r].version, this.versions(packageName)) &&
lte(m.packageJsonUpdates[r].version, targetVersion)
);
})
.map(r => m.packageJsonUpdates[r].packages)
.map(packages => {
if (!packages) return {};
return Object.keys(packages)
.filter(
p =>
!packages[p].ifPackageInstalled ||
this.versions(packages[p].ifPackageInstalled)
)
.reduce(
(m, c) => ({
...m,
[c]: {
version: packages[c].version,
alwaysAddToPackageJson: packages[c].alwaysAddToPackageJson
}
}),
{}
);
})
.reduce((m, c) => ({ ...m, ...c }), {});
}
}
type GenerateMigrations = {
type: 'generateMigrations';
targetPackage: string;
targetVersion: string;
from: { [k: string]: string };
to: { [k: string]: string };
};
type RunMigrations = { type: 'runMigrations'; runMigrations: string };
function parseMigrationsOptions(
args: string[]
): GenerateMigrations | RunMigrations {
const options = convertToCamelCase(
minimist(args, {
string: ['runMigrations', 'from', 'to'],
alias: {
runMigrations: 'run-migrations'
}
})
);
if (!options.runMigrations) {
if (!args[0]) {
throw new Error(
`Specify the package name (e.g., ${commandName} migrate mypackage@1.2.3)`
);
}
let from = {};
if (options.from) {
options.from.split(',').forEach(p => {
const split = p.lastIndexOf('@');
from[p.substring(0, split)] = p.substring(split + 1);
});
}
let to = {};
if (options.to) {
options.to.split(',').forEach(p => {
const split = p.lastIndexOf('@');
to[p.substring(0, split)] = p.substring(split + 1);
});
}
let targetPackage;
let targetVersion;
if (args[0].lastIndexOf('@') > 0) {
const i = args[0].lastIndexOf('@');
targetPackage = args[0].substring(0, i);
targetVersion = args[0].substring(i + 1);
} else {
targetPackage = args[0];
targetVersion = 'latest';
}
return {
type: 'generateMigrations',
targetPackage,
targetVersion,
from,
to
};
} else {
return { type: 'runMigrations', runMigrations: options.runMigrations };
}
}
function versions(root: string) {
return (packageName: string) => {
try {
const content = readFileSync(
path.join(root, `./node_modules/${packageName}/package.json`)
);
return JSON.parse(stripJsonComments(content.toString()))['version'];
} catch (e) {
return null;
}
};
}
// testing-fetch-start
async function fetch(
packageName: string,
packageVersion: string
): Promise<MigrationsJson> {
const dir = dirSync().name;
execSync(`npm install ${packageName}@${packageVersion} --prefix=${dir}`, {
stdio: []
});
const json = JSON.parse(
stripJsonComments(
readFileSync(
path.join(dir, 'node_modules', packageName, 'package.json')
).toString()
)
);
let migrationsFile = json['nx-migrations'] || json['ng-update'];
// migrationsFile is an object
if (migrationsFile.migration) {
migrationsFile = migrationsFile.migration;
}
// packageVersion can be a tag, resolvedVersion works with semver
const resolvedVersion = json.version;
if (migrationsFile) {
const json = JSON.parse(
stripJsonComments(
readFileSync(
path.join(dir, 'node_modules', packageName, migrationsFile)
).toString()
)
);
return {
version: resolvedVersion,
schematics: json.schematics,
packageJsonUpdates: json.packageJsonUpdates
};
} else {
return { version: resolvedVersion };
}
}
// testing-fetch-end
function createMigrationsFile(root: string, migrations: any[]) {
writeFileSync(
path.join(root, 'migrations.json'),
JSON.stringify({ migrations }, null, 2)
);
}
function updatePackageJson(
root: string,
packageJson: {
[p: string]: { version: string; alwaysAddToPackageJson: boolean };
}
) {
const packageJsonPath = path.join(root, 'package.json');
const json = JSON.parse(
stripJsonComments(readFileSync(packageJsonPath).toString())
);
Object.keys(packageJson).forEach(p => {
if (json.devDependencies && json.devDependencies[p]) {
json.devDependencies[p] = packageJson[p].version;
} else if (json.dependencies && json.dependencies[p]) {
json.dependencies[p] = packageJson[p].version;
} else if (packageJson[p].alwaysAddToPackageJson) {
if (!json.dependencies) json.dependencies = {};
json.dependencies[p] = packageJson[p].version;
}
});
writeFileSync(packageJsonPath, JSON.stringify(json, null, 2));
}
async function generateMigrationsJsonAndUpdatePackageJson(
logger: logging.Logger,
root: string,
opts: {
targetPackage: string;
targetVersion: string;
from: { [p: string]: string };
to: { [p: string]: string };
}
) {
const migrator = new Migrator({
versions: versions(root),
fetch,
from: opts.from,
to: opts.to
});
const { migrations, packageJson } = await migrator.updatePackageJson(
opts.targetPackage,
opts.targetVersion
);
updatePackageJson(root, packageJson);
if (migrations.length > 0) {
createMigrationsFile(root, migrations);
logger.info(`The migrate command has run successfully.`);
logger.info(`- package.json has been updated`);
logger.info(`- migrations.json has been generated`);
logger.info(`Next steps:`);
logger.info(
`- Make sure package.json changes make sense and then run 'npm install' or 'yarn'`
);
logger.info(`- Run 'nx migrate --run-migrations=migrations.json'`);
} else {
logger.info(`The migrate command has run successfully.`);
logger.info(`- package.json has been updated`);
logger.info(
`- there are no migrations to run, so migrations.json has not been created.`
);
}
}
class MigrationEngineHost extends NodeModulesEngineHost {
constructor() {
super();
}
protected _resolveCollectionPath(name: string): string {
let collectionPath: string | undefined = undefined;
if (name.replace(/\\/g, '/').split('/').length > (name[0] == '@' ? 2 : 1)) {
try {
collectionPath = this._resolvePath(name, process.cwd());
} catch {}
}
if (!collectionPath) {
let packageJsonPath = this._resolvePackageJson(name, process.cwd());
if (!core.fs.isFile(packageJsonPath)) {
packageJsonPath = path.join(packageJsonPath, 'package.json');
}
let pkgJsonSchematics = require(packageJsonPath)['nx-migrations'];
if (!pkgJsonSchematics || typeof pkgJsonSchematics != 'string') {
pkgJsonSchematics = require(packageJsonPath)['ng-update'];
if (!pkgJsonSchematics) {
throw new Error(`Could find migrations in package: "${name}"`);
}
}
collectionPath = this._resolvePath(
pkgJsonSchematics,
path.dirname(packageJsonPath)
);
}
try {
if (collectionPath) {
JSON.parse(stripJsonComments(readFileSync(collectionPath).toString()));
return collectionPath;
}
} catch (e) {
throw new Error(`Invalid migration file in package: "${name}"`);
}
throw new Error(`Collection cannot be resolved: "${name}"`);
}
}
class MigrationsWorkflow extends BaseWorkflow {
constructor(host: virtualFs.Host) {
super({
host,
engineHost: new MigrationEngineHost(),
force: true,
dryRun: false
});
}
}
async function runMigrations(
logger: logging.Logger,
root: string,
opts: { runMigrations: string }
) {
const migrationsFile = JSON.parse(
stripJsonComments(
readFileSync(path.join(root, opts.runMigrations)).toString()
)
);
const host = new virtualFs.ScopedHost(new NodeJsSyncHost(), normalize(root));
const workflow = new MigrationsWorkflow(host);
let p = Promise.resolve(null);
migrationsFile.migrations.forEach(m => {
logger.info(`Running migration ${m.package}:${m.name}`);
p = p.then(() => {
return workflow
.execute({
collection: m.package,
schematic: m.name,
options: {},
debug: false,
logger
})
.toPromise()
.then(() => {
logger.info(`Successfully finished ${m.package}:${m.name}`);
logger.info(
`---------------------------------------------------------`
);
});
});
});
await p;
}
export async function migrate(root: string, args: string[]) {
return handleErrors(logger, async () => {
const opts = parseMigrationsOptions(args);
if (opts.type === 'generateMigrations') {
await generateMigrationsJsonAndUpdatePackageJson(logger, root, opts);
} else {
await runMigrations(logger, root, opts);
}
});
}