diff --git a/CHANGELOG.md b/CHANGELOG.md index d5fdcce8..7f749a65 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 d87416af..849f909b 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 e5cd2f65..7b131f5d 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 { let answers: Record = { 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.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 { 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; if (answers.hasOwnProperty(q.name as string)) { @@ -93,7 +129,7 @@ async function ask(): Promise { 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.test.ts b/src/lib/migrationContext.test.ts new file mode 100644 index 00000000..c7ea34dc --- /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; + }); + } +}); diff --git a/src/lib/migrationContext.ts b/src/lib/migrationContext.ts new file mode 100644 index 00000000..a31557e8 --- /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 { + this.packageJson = await this.readJsonFile("package.json"); + this.ioPackageJson = await this.readJsonFile("io-package.json"); + } + + public async readJsonFile(fileName: string): Promise { + return (await readJson(path.join(this.baseDir, fileName))) as T; + } + + public async directoryExists(dirName: string): Promise { + const fullPath = path.join(this.baseDir, dirName); + return existsSync(fullPath) && (await stat(fullPath)).isDirectory(); + } + + public async fileExists(dirName: string): Promise { + 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 { + 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 { + 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 { + 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 dcfd3a7c..d5e3efb2 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, + question: Question, + ) => + | Promise + | 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) => 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", }, ], },