Repository: ioBroker/create-adapter
Pull request #712 (2021-03-07 16:43:19)

Migrate existing adapter projects
**PR Checklist:**

-   [x] Provide a meaningful description to this PR or mention which issues this fixes.
-   [x] Add tests for your change. This includes negative tests (i.e. inputs that need to fail) as well as baseline tests (i.e. how should the directory structure look like?).
-   [x] Run the test suite with `npm test`
-   [ ] ~~If there are baseline changes, review them and make a separate commit for them with the comment "accept baselines" if they are desired changes~~
-   [x] Ensure the project builds with `npm run build`
-   [ ] ~~If you added a required option, also add it to the template creation (`.github/create_templates.ts`)~~
-   [ ] ~~Add a detailed migration description to `docs/updates` explaining what the user needs to do when manually updating an existing project~~
-   [x] Add your changes to `CHANGELOG.md` (referencing the migration description and this PR or the issue you fixed)

**Description:**  
Allow to 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). It will try to pre-fill all answers, but the user still has to acknowledge all answers.


AlCalzone: Your tests are failing.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5fdcce..7f749a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/README.md b/README.md
index d87416a..849f909 100644
--- a/README.md
+++ b/README.md
@@ -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/`
 
diff --git a/src/cli.ts b/src/cli.ts
index e5cd2f6..7b131f5 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -9,6 +9,7 @@ import {
 	testCondition,
 	writeFiles,
 } from "./lib/createAdapter";
+import { MigrationContext } from "./lib/migrationContext";
 import {
 	Answers,
 	isQuestionGroup,
@@ -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",
@@ -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);
@@ -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)) {
@@ -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
diff --git a/src/lib/migrationContext.ts b/src/lib/migrationContext.ts
new file mode 100644
index 0000000..a31557e
--- /dev/null
+++ b/src/lib/migrationContext.ts
@@ -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;
+	}
+}
diff --git a/src/lib/questions.ts b/src/lib/questions.ts
index dcfd3a7..d5e3efb 100644
--- a/src/lib/questions.ts
+++ b/src/lib/questions.ts
@@ -15,6 +15,8 @@ import {
 	transformKeywords,
 } from "./actionsAndTransformers";
 import { testCondition } from "./createAdapter";
+import { licenses } from "./licenses";
+import { MigrationContext } from "./migrationContext";
 import { getOwnVersion } from "./tools";
 
 // This is being used to simulate wrong options for conditions on the type level
@@ -35,6 +37,15 @@ export type Condition = { name: string } & (
 interface QuestionMeta {
 	/** One or more conditions that need(s) to be fulfilled for this question to be asked */
 	condition?: Condition | Condition[];
+	migrate?: (
+		context: MigrationContext,
+		answers: Record<string, any>,
+		question: Question,
+	) =>
+		| Promise<AnswerValue | AnswerValue[] | undefined>
+		| AnswerValue
+		| AnswerValue[]
+		| undefined;
 	resultTransform?: (
 		val: AnswerValue | AnswerValue[],
 	) =>
@@ -109,12 +120,16 @@ export const questionsAndText: (
 				message: "Please enter the name of your project:",
 				resultTransform: transformAdapterName,
 				action: checkAdapterName,
+				migrate: (ctx) => ctx.ioPackageJson.common?.name,
 			},
 			{
 				type: "input",
 				name: "title",
 				message: "Which title should be shown in the admin UI?",
 				action: checkTitle,
+				migrate: (ctx) =>
+					ctx.ioPackageJson.common?.titleLang?.en ||
+					ctx.ioPackageJson.common?.title,
 			},
 			{
 				type: "input",
@@ -123,6 +138,9 @@ export const questionsAndText: (
 				hint: "(optional)",
 				optional: true,
 				resultTransform: transformDescription,
+				migrate: (ctx) =>
+					ctx.ioPackageJson.common?.desc?.en ||
+					ctx.ioPackageJson.common?.desc,
 			},
 			{
 				type: "input",
@@ -132,6 +150,12 @@ export const questionsAndText: (
 				hint: "(optional)",
 				optional: true,
 				resultTransform: transformKeywords,
+				migrate: (ctx) =>
+					(
+						ctx.ioPackageJson.common?.keywords ||
+						ctx.packageJson.common?.keywords ||
+						[]
+					).join(","),
 			},
 			{
 				type: "input",
@@ -141,6 +165,11 @@ export const questionsAndText: (
 				hint: "(optional)",
 				optional: true,
 				resultTransform: transformContributors,
+				migrate: (ctx) =>
+					(ctx.packageJson.contributors || [])
+						.map((c: Record<string, string>) => c.name)
+						.filter((name: string) => !!name)
+						.join(","),
 			},
 			{
 				condition: { name: "cli", value: false },
@@ -167,6 +196,7 @@ export const questionsAndText: (
 					{ message: "I want to specify everything!", value: "yes" },
 				],
 				optional: true,
+				migrate: () => "yes", // always force expert mode for migrate
 			},
 			styledMultiselect({
 				name: "features",
@@ -177,6 +207,11 @@ export const questionsAndText: (
 					{ message: "Visualization", value: "vis" },
 				],
 				action: checkMinSelections.bind(undefined, "feature", 1),
+				migrate: async (ctx) =>
+					[
+						(await ctx.directoryExists("admin")) ? "adapter" : null,
+						(await ctx.directoryExists("widgets")) ? "vis" : null,
+					].filter((f) => !!f) as string[],
 			}),
 			styledMultiselect({
 				condition: { name: "features", contains: "adapter" },
@@ -190,6 +225,17 @@ export const questionsAndText: (
 					{ message: "An extra tab", value: "tab" },
 					{ message: "Custom options for states", value: "custom" },
 				],
+				migrate: async (ctx) =>
+					[
+						(await ctx.fileExists("admin/tab.html")) ||
+						(await ctx.fileExists("admin/tab_m.html"))
+							? "tab"
+							: null,
+						(await ctx.fileExists("admin/custom.html")) ||
+						(await ctx.fileExists("admin/custom_m.html"))
+							? "custom"
+							: null,
+					].filter((f) => !!f) as string[],
 			}),
 			{
 				condition: { name: "features", contains: "adapter" },
@@ -323,6 +369,7 @@ export const questionsAndText: (
 						value: "weather",
 					},
 				],
+				migrate: (ctx) => ctx.ioPackageJson.common?.type,
 			},
 			{
 				condition: { name: "features", doesNotContain: "adapter" },
@@ -333,6 +380,7 @@ export const questionsAndText: (
 					{ message: "Icons for VIS", value: "visualization-icons" },
 					{ message: "VIS widgets", value: "visualization-widgets" },
 				],
+				migrate: (ctx) => ctx.ioPackageJson.common?.type,
 			},
 			{
 				condition: { name: "features", contains: "adapter" },
@@ -358,6 +406,7 @@ export const questionsAndText: (
 					},
 					{ message: "never", value: "none" },
 				],
+				migrate: (ctx) => ctx.ioPackageJson.common?.mode,
 			},
 			{
 				condition: { name: "startMode", value: "schedule" },
@@ -368,6 +417,8 @@ export const questionsAndText: (
 					"Should the adapter also be started when the configuration is changed?",
 				initial: "no",
 				choices: ["yes", "no"],
+				migrate: (ctx) =>
+					ctx.ioPackageJson.common?.allowInit ? "yes" : "no",
 			},
 			{
 				condition: { name: "features", contains: "adapter" },
@@ -382,6 +433,7 @@ export const questionsAndText: (
 						value: "local",
 					},
 				],
+				migrate: (ctx) => ctx.ioPackageJson.common?.connectionType,
 			},
 			{
 				condition: { name: "features", contains: "adapter" },
@@ -406,6 +458,7 @@ export const questionsAndText: (
 						value: "assumption",
 					},
 				],
+				migrate: (ctx) => ctx.ioPackageJson.common?.dataSource,
 			},
 			{
 				condition: { name: "features", contains: "adapter" },
@@ -416,6 +469,12 @@ export const questionsAndText: (
 				hint: "(To some device or some service)",
 				initial: "no",
 				choices: ["yes", "no"],
+				migrate: (ctx) =>
+					ctx.ioPackageJson.instanceObjects?.some(
+						(o: any) => o._id === "info.connection",
+					)
+						? "yes"
+						: "no",
 			},
 			{
 				condition: [
@@ -435,6 +494,14 @@ export const questionsAndText: (
 				message:
 					"Which language do you want to use to code the adapter?",
 				choices: ["JavaScript", "TypeScript"],
+				migrate: async (ctx) =>
+					(await ctx.hasFilesWithExtension(
+						"src",
+						".ts",
+						(f) => !f.endsWith(".d.ts"),
+					))
+						? "TypeScript"
+						: "JavaScript",
 			},
 			{
 				condition: [{ name: "features", contains: "adapter" }],
@@ -443,6 +510,19 @@ export const questionsAndText: (
 				message: "Use React for the Admin UI?",
 				initial: "no",
 				choices: ["yes", "no"],
+				migrate: async (ctx) =>
+					(await ctx.hasFilesWithExtension(
+						"admin/src",
+						".jsx",
+						(f) => !f.endsWith("tab.jsx"),
+					)) ||
+					(await ctx.hasFilesWithExtension(
+						"admin/src",
+						".tsx",
+						(f) => !f.endsWith("tab.tsx"),
+					))
+						? "yes"
+						: "no",
 			},
 			{
 				condition: [{ name: "adminFeatures", contains: "tab" }],
@@ -451,6 +531,11 @@ export const questionsAndText: (
 				message: "Use React for the tab UI?",
 				initial: "no",
 				choices: ["yes", "no"],
+				migrate: async (ctx) =>
+					(await ctx.fileExists("admin/src/tab.jsx")) ||
+					(await ctx.fileExists("admin/src/tab.tsx"))
+						? "yes"
+						: "no",
 			},
 			styledMultiselect({
 				condition: { name: "language", value: "JavaScript" },
@@ -466,6 +551,16 @@ export const questionsAndText: (
 							"(Requires VSCode and Docker, starts a fresh ioBroker in a Docker container with only your adapter installed)",
 					},
 				],
+				migrate: async (ctx) =>
+					[
+						ctx.hasDevDependency("eslint") ? "ESLint" : null,
+						ctx.hasDevDependency("typescript")
+							? "type checking"
+							: null,
+						(await ctx.directoryExists(".devcontainer"))
+							? "devcontainer"
+							: null,
+					].filter((f) => !!f) as string[],
 			}),
 			styledMultiselect({
 				condition: { name: "language", value: "TypeScript" },
@@ -487,6 +582,15 @@ export const questionsAndText: (
 					},
 				],
 				action: checkTypeScriptTools,
+				migrate: async (ctx) =>
+					[
+						ctx.hasDevDependency("eslint") ? "ESLint" : null,
+						ctx.hasDevDependency("prettier") ? "Prettier" : null,
+						ctx.hasDevDependency("nyc") ? "code coverage" : null,
+						(await ctx.directoryExists(".devcontainer"))
+							? "devcontainer"
+							: null,
+					].filter((f) => !!f) as string[],
 			}),
 
 			{
@@ -496,6 +600,8 @@ export const questionsAndText: (
 				message: "Do you prefer tab or space indentation?",
 				initial: "Tab",
 				choices: ["Tab", "Space (4)"],
+				migrate: async (ctx) =>
+					(await ctx.analyzeCode("\t", "  ")) ? "Tab" : "Space (4)",
 			},
 			{
 				condition: { name: "features", contains: "adapter" },
@@ -504,6 +610,8 @@ export const questionsAndText: (
 				message: "Do you prefer double or single quotes?",
 				initial: "double",
 				choices: ["double", "single"],
+				migrate: async (ctx) =>
+					(await ctx.analyzeCode('"', "'")) ? "double" : "single",
 			},
 			{
 				condition: { name: "features", contains: "adapter" },
@@ -524,6 +632,10 @@ export const questionsAndText: (
 						value: "no",
 					},
 				],
+				migrate: async (ctx) =>
+					(await ctx.getMainFileContent()).match(/^[ \t]*class/gm)
+						? "yes"
+						: "no",
 			},
 		],
 	},
@@ -535,6 +647,7 @@ export const questionsAndText: (
 				name: "authorName",
 				message: "Please enter your name (or nickname):",
 				action: checkAuthorName,
+				migrate: (ctx) => ctx.packageJson.author?.name,
 			},
 			{
 				type: "input",
@@ -542,12 +655,18 @@ export const questionsAndText: (
 				message: "What's your name/org on GitHub?",
 				initial: ((answers: Answers) => answers.authorName) as any,
 				action: checkAuthorName,
+				migrate: (ctx) =>
+					ctx.ioPackageJson.common?.extIcon?.replace(
+						/^.+?\.com\/([^\/]+)\/.+$/,
+						"$1",
+					),
 			},
 			{
 				type: "input",
 				name: "authorEmail",
 				message: "What's your email address?",
 				action: checkEmail,
+				migrate: (ctx) => ctx.packageJson.author?.email,
 			},
 			{
 				type: "select",
@@ -564,6 +683,10 @@ export const questionsAndText: (
 						hint: "(requires you to setup SSH keys)",
 					},
 				],
+				migrate: (ctx) =>
+					ctx.packageJson.repository?.url?.match(/^git@/)
+						? "SSH"
+						: "HTTPS",
 			},
 			{
 				condition: { name: "cli", value: true },
@@ -573,6 +696,7 @@ export const questionsAndText: (
 				message: "Initialize the GitHub repo automatically?",
 				initial: "no",
 				choices: ["yes", "no"],
+				migrate: () => "no",
 			},
 			{
 				type: "select",
@@ -589,6 +713,10 @@ export const questionsAndText: (
 					"MIT License",
 					"The Unlicense",
 				],
+				migrate: (ctx) =>
+					Object.keys(licenses).find(
+						(k) => licenses[k].id === ctx.packageJson.license,
+					),
 			},
 			{
 				type: "select",
@@ -606,6 +734,11 @@ export const questionsAndText: (
 						value: "travis",
 					},
 				],
+				migrate: async (ctx) =>
+					(await ctx.fileExists(".travis.yml")) &&
+					!(await ctx.directoryExists(".github/workflows"))
+						? "travis"
+						: "gh-actions",
 			},
 			{
 				type: "select",
@@ -616,6 +749,10 @@ export const questionsAndText: (
 				hint: "(recommended)",
 				initial: "no",
 				choices: ["yes", "no"],
+				migrate: async (ctx) =>
+					(await ctx.fileExists(".github/dependabot.yml"))
+						? "yes"
+						: "no",
 			},
 		],
 	},


Test patch:
diff --git a/src/lib/migrationContext.test.ts b/src/lib/migrationContext.test.ts
new file mode 100644
index 0000000..c7ea34d
--- /dev/null
+++ b/src/lib/migrationContext.test.ts
@@ -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;
+		});
+	}
+});