Skip to content

Commit

Permalink
Migrate existing adapter projects (#712)
Browse files Browse the repository at this point in the history
  • Loading branch information
UncleSamSwiss authored Mar 9, 2021
1 parent 63b1d1c commit c46c9eb
Show file tree
Hide file tree
Showing 6 changed files with 457 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* (UncleSamSwiss) Add support for tab in Admin to use React (#674)
* (AlCalzone) Restore compatibility with `eslint-config-prettier` v8 (#709) · [Migration guide](docs/updates/20210301_prettier_config.md)
* (AlCalzone) Replaced the now deprecated compact mode check using `module.parent` with one that uses `require.main` (#653) · [Migration guide](docs/updates/20201201_require_main.md)
* (UncleSamSwiss) Add support for migrating an existing adapter project and pre-fill the answers (#712)

## 1.31.0 (2020-11-29)
* (crycode-de) Added better types for the `I18n.t` function based on words in `i18n/en.json` for TypeScript React UI (#630) · [Migration guide](docs/updates/20201107_typed_i18n_t.md)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ The following CLI options are available:
- `--target=/path/to/dir` - Specify which directory the adapter files should be created in (instead of the current dir). Shortcut: `-t`
- `--skipAdapterExistenceCheck` - Don't check if an adapter with the same name already exists on `npm`. Shortcut: `-x`
- `--replay=/path/to/file` - Re-run the adapter creator with the answers of a previous run (the given file needs to be the `.create-adapter.json` in the root of the previously generated directory). Shortcut: `-r`
- `--migrate=/path/to/dir` - Run the adapter creator with the answers pre-filled from an existing adapter directory (the given path needs to point to the adapter base directory where `io-package.json` is found). Shortcut: `-m`

All CLI options can also be [provided as environment variables](https://yargs.js.org/docs/#api-reference-envprefix) by prepending `CREATE_ADAPTER_`. Example: `CREATE_ADAPTER_TARGET=/tmp/iobroker/create-adapter/`

Expand Down
40 changes: 38 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
testCondition,
writeFiles,
} from "./lib/createAdapter";
import { MigrationContext } from "./lib/migrationContext";
import {
Answers,
isQuestionGroup,
Expand Down Expand Up @@ -43,6 +44,12 @@ const argv = yargs
type: "string",
desc: "Replay answers from the given .create-adapter.json file",
},
migrate: {
alias: "m",
type: "string",
desc:
"Use answers from an existing adapter directory (must be the base directory of an adapter where you find io-package.json)",
},
noInstall: {
alias: "n",
type: "boolean",
Expand All @@ -62,12 +69,14 @@ const argv = yargs
const rootDir = path.resolve(argv.target || process.cwd());

const creatorOptions = {
skipAdapterExistenceCheck: !!argv.skipAdapterExistenceCheck,
skipAdapterExistenceCheck:
!!argv.skipAdapterExistenceCheck || !!argv.migrate,
};

/** Asks a series of questions on the CLI */
async function ask(): Promise<Answers> {
let answers: Record<string, any> = { cli: true };
let migrationContext: MigrationContext | undefined = undefined;

if (!!argv.replay) {
const replayFile = path.resolve(argv.replay);
Expand All @@ -76,12 +85,39 @@ async function ask(): Promise<Answers> {
answers.replay = replayFile;
}

if (!!argv.migrate) {
try {
const migrationDir = path.resolve(argv.migrate);
migrationContext = new MigrationContext(migrationDir);
console.log(`Migrating from ${migrationDir}`);
await migrationContext.load();
} catch (error) {
console.error(error);
throw new Error(
"Please ensure that --migrate points to a valid adapter directory",
);
}
if (await migrationContext.fileExists(".create-adapter.json")) {
// it's just not worth trying to figure out things if the adapter was already created with create-adapter
throw new Error(
"Use --replay instead of --migrate for an adapter created with a recent version of create-adapter.",
);
}
}

async function askQuestion(q: Question): Promise<void> {
if (testCondition(q.condition, answers)) {
// Make properties dependent on previous answers
if (typeof q.initial === "function") {
q.initial = q.initial(answers);
}
if (migrationContext && q.migrate) {
let migrated = q.migrate(migrationContext, answers, q);
if (migrated instanceof Promise) {
migrated = await migrated;
}
q.initial = migrated;
}
while (true) {
let answer: Record<string, any>;
if (answers.hasOwnProperty(q.name as string)) {
Expand All @@ -93,7 +129,7 @@ async function ask(): Promise<Answers> {
q.expert &&
q.initial !== undefined
) {
// In expert mode, prefill the default answer for expert questions
// In non-expert mode, prefill the default answer for expert questions
answer = { [q.name as string]: q.initial };
} else {
// Ask the user for an answer
Expand Down
172 changes: 172 additions & 0 deletions src/lib/migrationContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { expect } from "chai";
import { MigrationContext } from "./migrationContext";
import path = require("path");

describe("directoryExists()", () => {
it("should return true if the directory exists", async () => {
const context = new MigrationContext(__dirname);
expect(await context.directoryExists(".")).to.be.true;
expect(await context.directoryExists("../..")).to.be.true;
expect(await context.directoryExists("../../test")).to.be.true;
});

it("should return false if the directory doesn't exists", async () => {
const context = new MigrationContext(__dirname);
expect(await context.directoryExists("foo")).to.be.false;
expect(await context.directoryExists("../bar")).to.be.false;
});

it("should return false if it isn't a directory", async () => {
const context = new MigrationContext(__dirname);
expect(await context.directoryExists("../../package.json")).to.be.false;
expect(await context.directoryExists("../cli.ts")).to.be.false;
});
});

describe("fileExists()", () => {
it("should return true if the file exists", async () => {
const context = new MigrationContext(__dirname);
expect(await context.fileExists("../../package.json")).to.be.true;
expect(await context.fileExists("../cli.ts")).to.be.true;
});

it("should return false if the file doesn't exists", async () => {
const context = new MigrationContext(__dirname);
expect(await context.fileExists("../foo.ts")).to.be.false;
expect(await context.fileExists("../../bar.txt")).to.be.false;
});

it("should return false if it isn't a file", async () => {
const context = new MigrationContext(__dirname);
expect(await context.fileExists("../..")).to.be.false;
expect(await context.fileExists("../../test")).to.be.false;
});
});

describe("hasFilesWithExtension()", () => {
it("should return true if files exist", async () => {
const context = new MigrationContext(__dirname);
expect(await context.hasFilesWithExtension("../..", ".json")).to.be
.true;
expect(await context.hasFilesWithExtension("..", ".ts")).to.be.true;
expect(
await context.hasFilesWithExtension(
"..",
".ts",
(f) => !f.endsWith("cli.ts"),
),
).to.be.true;
});

it("should return false if no files exist", async () => {
const context = new MigrationContext(__dirname);
expect(await context.hasFilesWithExtension("..", ".xls")).to.be.false;
expect(await context.hasFilesWithExtension("..", ".dts")).to.be.false;
expect(
await context.hasFilesWithExtension(
"..",
".ts",
(f) => !f.includes("i"),
),
).to.be.false;
});

it("should return false if the directory doesn't exist", async () => {
const context = new MigrationContext(__dirname);
expect(await context.hasFilesWithExtension("foo", ".json")).to.be.false;
expect(await context.hasFilesWithExtension("../bar", ".ts")).to.be
.false;
});
});

describe("hasDevDependency()", () => {
it("should return true if the dependency exists", () => {
const context = new MigrationContext(__dirname);
context.packageJson = {
devDependencies: {
gulp: "^3.9.1",
mocha: "^4.1.0",
chai: "^4.1.2",
},
};
expect(context.hasDevDependency("gulp")).to.be.true;
expect(context.hasDevDependency("mocha")).to.be.true;
expect(context.hasDevDependency("chai")).to.be.true;
});

it("should return false if the dependency doesn't exists", () => {
const context = new MigrationContext(__dirname);
context.packageJson = {
devDependencies: {
gulp: "^3.9.1",
mocha: "^4.1.0",
chai: "^4.1.2",
},
};
expect(context.hasDevDependency("coffee")).to.be.false;
expect(context.hasDevDependency("chia")).to.be.false;
});
});

describe("getMainFileContent()", () => {
if (!process.env.CI) {
// not working in GH action as we don't compile the JS code there
it("should return the contents of the TS file if a main JS file is found with a corresponding TS file", async () => {
const baseDir = path.resolve(__dirname, "../..");
const context = new MigrationContext(baseDir);
context.packageJson = {
main: "build/src/cli.js",
};
expect(await context.getMainFileContent()).not.to.be.empty;
expect(await context.getMainFileContent()).to.contain(
"import * as yargs",
);
});
}

it("should return the contents of the main file if a main file is found with no corresponding TS file", async () => {
const baseDir = path.resolve(__dirname, "../..");
const context = new MigrationContext(baseDir);
context.packageJson = {
main: "bin/create-adapter.js",
};
expect(await context.getMainFileContent()).not.to.be.empty;
expect(await context.getMainFileContent()).to.contain(
"#!/usr/bin/env node",
);
});

it("should return an empty string if no main file is found", async () => {
const baseDir = path.resolve(__dirname, "../..");
const context = new MigrationContext(baseDir);
context.packageJson = {
main: "foo/bar.js",
};
expect(await context.getMainFileContent()).to.be.empty;
});
});

// not working in GH action - but you can still use this test locally
describe("analyzeCode()", () => {
if (!process.env.CI) {
// not working in GH action as we don't compile the JS code there
it("should return true the first string occurs more than the second", async () => {
const baseDir = path.resolve(__dirname, "../..");
const context = new MigrationContext(baseDir);
context.packageJson = {
main: "build/src/cli.js",
};
expect(await context.analyzeCode("\t", " ")).to.be.true;
expect(await context.analyzeCode('"', "'")).to.be.true;
});
it("should return false the first string occurs less often than the second", async () => {
const baseDir = path.resolve(__dirname, "../..");
const context = new MigrationContext(baseDir);
context.packageJson = {
main: "build/src/cli.js",
};
expect(await context.analyzeCode(" ", "\t")).to.be.false;
expect(await context.analyzeCode("'", '"')).to.be.false;
});
}
});
108 changes: 108 additions & 0 deletions src/lib/migrationContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { existsSync, readdir, readFile, readJson, stat } from "fs-extra";
import path = require("path");

export class MigrationContext {
public packageJson: any;
public ioPackageJson: any;

constructor(private readonly baseDir: string) {}

public async load(): Promise<void> {
this.packageJson = await this.readJsonFile("package.json");
this.ioPackageJson = await this.readJsonFile("io-package.json");
}

public async readJsonFile<T>(fileName: string): Promise<T> {
return (await readJson(path.join(this.baseDir, fileName))) as T;
}

public async directoryExists(dirName: string): Promise<boolean> {
const fullPath = path.join(this.baseDir, dirName);
return existsSync(fullPath) && (await stat(fullPath)).isDirectory();
}

public async fileExists(dirName: string): Promise<boolean> {
const fullPath = path.join(this.baseDir, dirName);
return existsSync(fullPath) && (await stat(fullPath)).isFile();
}

public async hasFilesWithExtension(
dirName: string,
extension: string,
filter?: (fileName: string) => boolean,
): Promise<boolean> {
return (
(await this.directoryExists(dirName)) &&
(await readdir(path.join(this.baseDir, dirName))).some(
(f) =>
(!filter || filter(f)) &&
f.toLowerCase().endsWith(extension.toLowerCase()),
)
);
}

public hasDevDependency(packageName: string): boolean {
return this.packageJson.devDependencies?.hasOwnProperty(packageName);
}

public async getMainFileContent(): Promise<string> {
if (
!this.packageJson.main ||
!this.packageJson.main.endsWith(".js") ||
!(await this.fileExists(this.packageJson.main))
) {
// we don't have a main JavaScript file, it will be impossible to find code
return "";
}

try {
const tsMains = [
path.join(
this.baseDir,
"src",
this.packageJson.main.replace(/\.js$/, ".ts"),
),
path.join(
this.baseDir,
this.packageJson.main
.replace(/\.js$/, ".ts")
.replace(/^dist([\\/])/, "src$1"),
),
path.join(
this.baseDir,
this.packageJson.main
.replace(/\.js$/, ".ts")
.replace(/^build([\\/])/, "src$1"),
),
path.join(
this.baseDir,
this.packageJson.main
.replace(/\.js$/, ".ts")
.replace(/^(build|dist)[\\/]/, ""),
),
];
for (let i = 0; i < tsMains.length; i++) {
const tsMain = tsMains[i];
if (existsSync(tsMain)) {
// most probably TypeScript
return readFile(tsMain, { encoding: "utf8" });
}
}

return readFile(path.join(this.baseDir, this.packageJson.main), {
encoding: "utf8",
});
} catch {
// we don't want this to crash, so just return an empty string
return "";
}
}

public async analyzeCode(either: string, or: string): Promise<boolean> {
const content = await this.getMainFileContent();
const eitherCount = (content.match(new RegExp(either, "g")) || [])
.length;
const orCount = (content.match(new RegExp(or, "g")) || []).length;
return eitherCount >= orCount;
}
}
Loading

0 comments on commit c46c9eb

Please sign in to comment.