497 lines
14 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
}
|