UNPKG

@iobroker/create-adapter

Version:

Command line utility to create customized ioBroker adapters

914 lines 37.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.questions = exports.questionGroups = void 0; exports.testCondition = testCondition; exports.checkAnswers = checkAnswers; exports.formatAnswers = formatAnswers; exports.validateAnswers = validateAnswers; exports.getDefaultAnswer = getDefaultAnswer; exports.getIconName = getIconName; const typeguards_1 = require("alcalzone-shared/typeguards"); const ansi_colors_1 = require("ansi-colors"); const actionsAndTransformers_1 = require("./actionsAndTransformers"); const licenses_1 = require("./licenses"); // This is being used to simulate wrong options for conditions on the type level const __misused = Symbol.for("__misused"); /** * * @param condition * @param answers */ function testCondition(condition, answers) { if (condition == undefined) { return true; } function testSingleCondition(cond) { if ("value" in cond) { return answers[cond.name] === cond.value; } else if ("contains" in cond) { return answers[cond.name] && answers[cond.name].indexOf(cond.contains) > -1; } else if ("doesNotContain" in cond) { return !answers[cond.name] || answers[cond.name].indexOf(cond.doesNotContain) === -1; } return false; } if ((0, typeguards_1.isArray)(condition)) { return condition.every(cond => testSingleCondition(cond)); } return testSingleCondition(condition); } function styledMultiselect(ms) { return Object.assign({}, ms, { type: "multiselect", hint: (0, ansi_colors_1.gray)("(<space> to select, <return> to submit)"), symbols: { indicator: { on: (0, ansi_colors_1.green)("■"), off: ansi_colors_1.dim.gray("□"), }, }, }); } /** All questions and the corresponding text lines */ exports.questionGroups = [ { title: "Basics", headline: "Let's get started with a few questions about your project!", questions: [ { type: "input", name: "adapterName", label: "Adapter Name", message: "Please enter the name of your project:", resultTransform: actionsAndTransformers_1.transformAdapterName, action: actionsAndTransformers_1.checkAdapterName, migrate: ctx => ctx.ioPackageJson.common?.name, }, { type: "input", name: "title", label: "Title", message: "Which title should be shown in the admin UI?", action: actionsAndTransformers_1.checkTitle, migrate: ctx => ctx.ioPackageJson.common?.titleLang?.en || ctx.ioPackageJson.common?.title, }, { type: "input", name: "description", label: "Description", message: "Please enter a short description:", hint: "(optional)", optional: true, resultTransform: actionsAndTransformers_1.transformDescription, migrate: ctx => ctx.ioPackageJson.common?.desc?.en || ctx.ioPackageJson.common?.desc, }, { type: "input", name: "keywords", label: "Keywords", message: "Enter some keywords (separated by commas) to describe your project:", hint: "(optional)", optional: true, resultTransform: actionsAndTransformers_1.transformKeywords, migrate: ctx => (ctx.ioPackageJson.common?.keywords || ctx.packageJson.common?.keywords || []).join(","), }, { type: "input", name: "contributors", label: "Contributors", message: "If you have any contributors, please enter their names (seperated by commas):", hint: "(optional)", optional: true, resultTransform: actionsAndTransformers_1.transformContributors, migrate: ctx => (ctx.packageJson.contributors || []) .map((c) => c.name) .filter((name) => !!name) .join(","), }, { condition: { name: "cli", value: false }, type: "web_upload", name: "icon", label: "Adapter Icon", message: "Upload your adapter icon", hint: "(optional)", optional: true, }, ], }, { title: "Technical", headline: "Nice! Let's get technical...", questions: [ { type: "select", name: "expert", label: "Expert Mode", message: "How detailed do you want to configure your project?", choices: [ { message: "Just ask me the most important stuff!", value: "no", }, { message: "I want to specify everything!", value: "yes" }, ], optional: true, migrate: () => "yes", // always force expert mode for migrate }, styledMultiselect({ name: "features", label: "Features", message: "Which features should your project contain?", initial: [0], choices: [ { message: "Adapter", value: "adapter" }, { message: "Visualization", value: "vis" }, ], action: actionsAndTransformers_1.checkMinSelections.bind(undefined, "feature", 1), migrate: async (ctx) => [ (await ctx.directoryExists("admin")) ? "adapter" : null, (await ctx.directoryExists("widgets")) ? "vis" : null, ].filter(f => !!f), }), styledMultiselect({ condition: { name: "features", contains: "adapter" }, name: "adminFeatures", label: "Admin Features", expert: true, message: "Which additional features should be available in the admin?", hint: "(optional)", initial: [], choices: [ { 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")) || (await ctx.fileExists("admin/jsonCustom.json")) ? "custom" : null, ].filter(f => !!f), }), { condition: { name: "features", contains: "adapter" }, type: "select", name: "type", label: "Adapter Type", message: "Which category does your adapter fall into?", choices: [ { message: "Alarm / security (Home, car, boat, ...)", value: "alarm", }, { message: "Calendars (also schedules, etc., ...)", value: "date-and-time", }, { message: "Cars / Vehicles (trip information, vehicle status, aux. heating, ...)", value: "vehicle", }, { message: "Climate control (A/C, Heaters, air filters, ...)", value: "climate-control", }, { message: "Communication protocols (MQTT, ...)", value: "protocols", }, { message: "Data storage (SQL/NoSQL, file storage, logging, ...)", value: "storage", }, { message: "Data transmission (for other services via REST api, websockets, ...)", value: "communication", }, { message: "Garden (Mowers, watering, ...)", value: "garden", }, { message: "General purpose (like admin, web, discovery, ...)", value: "general", }, { message: "Geo positioning (transmission and receipt of position data)", value: "geoposition", }, { message: "Hardware (low-level, multi-purpose)", value: "hardware", }, { message: "Health (Fitness sensors, weight, pulse, ...)", value: "health", }, { message: "Household devices (Vacuums, kitchen, ...)", value: "household", }, { message: "Lighting control", value: "lighting" }, { message: "Logic (Scripts, rules, parsers, scenes, ...)", value: "logic", }, { message: "Messaging (E-Mail, Telegram, WhatsApp, ...)", value: "messaging", }, { message: "Meters for energy, electricity, ...", value: "energy", }, { message: "Meters for water, gas, oil, ...", value: "metering", }, { message: "Miscellaneous data (Import/export of contacts, gasoline prices, ...)", value: "misc-data", }, { message: "Miscellaneous utilities (Data import/emport, backup, ...)", value: "utility", }, { message: "Multimedia (TV, audio, remote controls, ...)", value: "multimedia", }, { message: "Network infrastructure (Hardware, printers, phones, ...)", value: "infrastructure", }, { message: "Network utilities (Ping, UPnP, network discovery, ...)", value: "network", }, { message: "Smart home systems (3rd party, hardware and software)", value: "iot-systems", }, { message: "Visualizations (VIS, MaterialUI, mobile views, ...)", value: "visualization", }, // visualization-icons and visualization-widgets are a separate question for // VIS projects { message: "Weather (Forecast, air quality, statistics, ...)", value: "weather", }, ], migrate: ctx => ctx.ioPackageJson.common?.type, }, { condition: { name: "features", contains: "vis" }, type: "select", name: "type", label: "VIS Type", message: "Which kind of visualization is this?", choices: [ { message: "Icons for VIS", value: "visualization-icons" }, { message: "VIS widgets", value: "visualization-widgets" }, ], migrate: ctx => ctx.ioPackageJson.common?.type, }, { condition: { name: "features", contains: "vis" }, type: "select", name: "widgetIsMainFunction", label: "Widget Function", message: "What is the function of the widget?", initial: "main", choices: [ { message: "The widget is the main adapter functionality", value: "main", }, { message: "The adapter also works without the visualization of the widget", value: "additional", }, ], migrate: () => "main", // Default to main as it was historically }, { condition: { name: "features", contains: "adapter" }, type: "select", name: "startMode", label: "Start Mode", expert: true, message: "When should the adapter be started?", initial: "daemon", choices: [ { message: "always", hint: ansi_colors_1.dim.gray("(recommended for most adapters)"), value: "daemon", }, { message: "depending on a schedule", value: "schedule" }, { message: "only once after changing the instance object", value: "once", }, { message: "never", value: "none" }, ], migrate: ctx => ctx.ioPackageJson.common?.mode, }, { condition: { name: "startMode", value: "schedule" }, type: "select", name: "scheduleStartOnChange", label: "Schedule", expert: true, message: "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" }, type: "select", name: "connectionType", label: "Connection Type", optional: true, // We cannot assume this when creating templates message: `From where will the adapter get its data?`, choices: [ { message: "Website or cloud service", value: "cloud" }, { message: "Local network or wireless", value: "local", }, ], migrate: ctx => ctx.ioPackageJson.common?.connectionType, }, { condition: { name: "features", contains: "adapter" }, type: "select", name: "dataSource", label: "Data Source", optional: true, // We cannot assume this when creating templates message: `How will the adapter receive its data?`, choices: [ { message: "Request it regularly from the service or device", value: "poll", }, { message: "The service or device actively sends new data", value: "push", }, { message: "Assumption or educated guess", hint: "(e.g. when receiving incomplete events)", value: "assumption", }, ], migrate: ctx => ctx.ioPackageJson.common?.dataSource, }, { condition: { name: "features", contains: "adapter" }, type: "select", name: "connectionIndicator", label: "Show Connection Indicator", expert: true, message: `Do you want to indicate the connection state?`, hint: "(To some device or some service)", initial: "no", choices: ["yes", "no"], migrate: ctx => ctx.ioPackageJson.instanceObjects?.some((o) => o._id === "info.connection") ? "yes" : "no", }, ], }, { title: "Settings", headline: "Define the settings for the adapter", questions: [ { condition: [ { name: "features", contains: "adapter" }, { name: "cli", value: false }, ], type: "web_unknown", // TODO: give this a good type name: "adapterSettings", label: "Adapter Settings", message: "Define the settings for the adapter", hint: "(optional)", optional: true, }, ], }, { title: "Code", headline: "Some more questions about the source code...", questions: [ { condition: { name: "features", contains: "adapter" }, type: "select", name: "language", label: "Programming Language", message: "Which language do you want to use to code the adapter?", choices: ["JavaScript", "TypeScript", "TypeScript (without build)"], migrate: async (ctx) => (await ctx.hasFilesWithExtension("src", ".ts", f => !f.endsWith(".d.ts"))) ? "TypeScript" : "JavaScript", }, { condition: [{ name: "features", contains: "adapter" }], type: "select", name: "nodeVersion", label: "Node.js version", expert: true, optional: true, message: "What's the minimum Node.js version you want to support?", initial: "20", choices: ["20", "22", "24"], migrate: ctx => { if (ctx.hasDevDependency("@tsconfig/node18")) { return "20"; // For migrations upgrade to Node.js 20 as minimum } else if (ctx.hasDevDependency("@tsconfig/node20")) { return "20"; } else if (ctx.hasDevDependency("@tsconfig/node22")) { return "22"; } else if (ctx.hasDevDependency("@tsconfig/node24")) { return "24"; } return "20"; }, }, { condition: [{ name: "features", contains: "adapter" }], type: "select", name: "adminUi", label: "Admin UI", message: "Which framework would you like to use for the Admin UI?", initial: "json", choices: [ { message: "JSON UI", hint: "(good for simple Admin UIs with a few fields)", value: "json", }, { message: "HTML / Materialize", hint: "(good for Admin UIs that have a few special requirements)", value: "html", }, { message: "React", hint: "(good for complex Admin UIs)", value: "react", }, { message: "No UI", hint: "(should only be used if you have another way to configure)", value: "none", }, ], replay: (answers) => { if (answers.adminReact === "yes") { answers.adminUi = "react"; } else if (answers.adminReact === "no") { answers.adminUi = "html"; } }, migrate: migrateAdminUi, }, { condition: [{ name: "adminFeatures", contains: "tab" }], type: "select", name: "tabReact", label: "Tab with React", 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" }, name: "tools", label: "Tools", message: "Which of the following tools do you want to use?", initial: [0, 1], choices: [ { message: "ESLint", hint: "(recommended)" }, { message: "type checking", hint: "(recommended)" }, { message: "Prettier", hint: "(requires ESLint, enables automatic code formatting in VSCode)", }, { message: "devcontainer", hint: "(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, ctx.hasDevDependency("prettier") ? "Prettier" : null, (await ctx.directoryExists(".devcontainer")) ? "devcontainer" : null, ].filter(f => !!f), }), styledMultiselect({ condition: { name: "language", value: "TypeScript" }, name: "tools", label: "Tools", message: "Which of the following tools do you want to use?", initial: [0], choices: [ { message: "ESLint", hint: "(recommended)" }, { message: "Prettier", hint: "(requires ESLint, enables automatic code formatting in VSCode)", }, { message: "code coverage" }, { message: "devcontainer", hint: "(Requires VSCode and Docker, starts a fresh ioBroker in a Docker container with only your adapter installed)", }, ], action: actionsAndTransformers_1.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), }), { condition: { name: "tools", contains: "ESLint" }, type: "select", name: "eslintConfig", label: "ESLint Configuration", message: "Do you want to configure ESLint by yourself or use the official ioBroker ESLint config?", initial: "official", choices: [ { message: "Use official ioBroker ESLint config (includes prettier)", hint: "(recommended)", value: "official", }, { message: "Configure ESLint by yourself", value: "custom", }, ], migrate: () => "custom", // Default to custom for existing projects }, { condition: [ { name: "features", contains: "adapter" }, { name: "adminUi", value: "html" }, ], type: "select", name: "i18n", label: "Translation Management", optional: true, expert: true, message: "How would you like to manage translations?", initial: "words.js", choices: [ { message: "JSON files", hint: "(required for Weblate; words.js will be generated using @iobroker/adapter-dev)", value: "JSON", }, { message: "words.js", hint: "(legacy)", value: "words.js", }, ], migrate: async (ctx) => ((await ctx.fileExists("admin/i18n/en/translations.json")) ? "JSON" : "words.js"), }, { type: "select", name: "releaseScript", label: "Release Script", message: "Would you like to automate new releases with one simple command?", initial: "yes", choices: ["yes", "no"], migrate: async (ctx) => (ctx.hasDevDependency("@alcalzone/release-script") ? "yes" : "no"), }, { condition: [ { name: "features", contains: "adapter" }, { name: "cli", value: true }, ], type: "select", name: "devServer", label: "ioBroker dev-server", optional: true, message: "Would you like to use dev-server to develop and test your code with a simple command line tool?", initial: "local", choices: [ { message: "yes, as global installation (might need root permissions to install on Linux/macOS)", value: "global", }, { message: "yes, as an adapter-own local installation (no root permissions needed)", hint: "(recommended)", value: "local", }, { message: "no", value: "no", }, ], migrate: () => "global", }, { condition: { name: "devServer", value: ["global", "local"] }, type: "numeral", name: "devServerPort", label: "dev-server Admin Port", message: "Please choose the port number on which dev-server should present the admin web interface:", initial: 8081, min: 1024, max: 0xffff, migrate: () => 8081, }, { condition: { name: "features", contains: "adapter" }, type: "select", name: "indentation", label: "Indentation", 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" }, type: "select", name: "quotes", label: "Quotes", message: "Do you prefer double or single quotes?", initial: "single", choices: ["double", "single"], migrate: async (ctx) => ((await ctx.analyzeCode('"', "'")) ? "double" : "single"), }, ], }, { title: "Administrative", headline: "Almost done! Just a few administrative details...", questions: [ { type: "input", name: "authorName", label: "Author Name", message: "Please enter your name (or nickname):", action: actionsAndTransformers_1.checkAuthorName, migrate: ctx => ctx.packageJson.author?.name, }, { type: "input", name: "authorGithub", label: "GitHub Name", message: "What's your name/org on GitHub?", initial: ((answers) => answers.authorName), action: actionsAndTransformers_1.checkAuthorName, migrate: ctx => ctx.ioPackageJson.common?.extIcon?.replace(/^\w+:\/\/[^/]+\.com\/([^/]+)\/.+$/, "$1"), }, { type: "input", name: "authorEmail", label: "Adapter E-Mail", message: "What's your email address?", action: actionsAndTransformers_1.checkEmail, migrate: ctx => ctx.packageJson.author?.email, }, { type: "select", name: "gitRemoteProtocol", label: "GIT Protocol", message: "Which protocol should be used for the repo URL?", expert: true, initial: "HTTPS", choices: [ { message: "HTTPS", }, { message: "SSH", hint: "(requires you to setup SSH keys)", }, ], migrate: ctx => (ctx.packageJson.repository?.url?.match(/^git@/) ? "SSH" : "HTTPS"), }, { condition: { name: "cli", value: true }, type: "select", name: "gitCommit", label: "Git Commit", expert: true, message: "Initialize the GitHub repo automatically?", initial: "no", choices: ["yes", "no"], migrate: () => "no", }, { condition: { name: "cli", value: true }, type: "select", name: "defaultBranch", label: "Git Default Branch", expert: true, message: "How should your default Git branch be called?", initial: "main", choices: [ { message: "main", }, { message: "master", hint: "(deprecated)", }, ], migrate: ctx => (ctx.ioPackageJson.common?.extIcon?.match(/\/main\/admin\//i) ? "main" : "master"), }, { type: "select", name: "license", label: "License", message: "Which license should be used for your project?", initial: 5, choices: [ // TODO: automate (GH#1) "GNU AGPLv3", "GNU GPLv3", "GNU LGPLv3", "Mozilla Public License 2.0", "Apache License 2.0", "MIT License", "The Unlicense", ], migrate: ctx => Object.keys(licenses_1.licenses).find(k => licenses_1.licenses[k].id === ctx.packageJson.license), }, { type: "select", name: "dependabot", label: "Dependabot", expert: true, message: "Do you want to receive regular dependency updates through Pull Requests?", hint: "(recommended)", initial: "yes", choices: ["yes", "no"], migrate: async (ctx) => ((await ctx.fileExists(".github/dependabot.yml")) ? "yes" : "no"), }, ], }, ]; /** Only the questions */ exports.questions = exports.questionGroups.map(q => q.questions).reduce((arr, next) => arr.concat(...next), []); /** * * @param answers */ function checkAnswers(answers) { for (const q of exports.questions) { // We don't use dynamic question names const questionName = q.name; const answer = answers[questionName]; const conditionFulfilled = testCondition(q.condition, answers); if (!q.optional && conditionFulfilled && answer == undefined) { // A required answer was not given throw new Error(`Missing answer "${questionName}"!`); } else if (!conditionFulfilled && answer != undefined) { // TODO: Find a fool-proof way to check for extraneous answers if (exports.questions.filter(qq => qq.name === questionName).length > 0) { // For now, don't enforce conditions for questions with multiple branches continue; } // An extraneous answer was given throw new Error(`Extraneous answer "${questionName}" given!`); } } } /** * * @param answers */ async function formatAnswers(answers) { for (const q of exports.questions) { const conditionFulfilled = testCondition(q.condition, answers); if (!conditionFulfilled) { continue; } // Apply default value from initial if answer is missing and not optional if (answers[q.name] == undefined && !q.optional && q.initial !== undefined) { answers[q.name] = typeof q.initial === "function" ? q.initial(answers) : q.initial; } // Apply an optional transformation if (answers[q.name] != undefined && typeof q.resultTransform === "function") { const transformed = q.resultTransform(answers[q.name]); answers[q.name] = transformed instanceof Promise ? await transformed : transformed; } } return answers; } /** * * @param answers * @param disableValidation */ async function validateAnswers(answers, disableValidation = []) { for (const q of exports.questions) { const conditionFulfilled = testCondition(q.condition, answers); if (!conditionFulfilled) { continue; } if (q.action == undefined) { continue; } if (disableValidation.indexOf(q.name) > -1) { continue; } const testResult = await q.action(answers[q.name]); if (typeof testResult === "string") { throw new Error(testResult); } } } /** * * @param key */ function getDefaultAnswer(key) { // Apparently, it is not possible to make the return type depend on the // given object key: https://github.com/microsoft/TypeScript/issues/31672 // So we cast to `any` until a solution emerges switch (key) { case "adapterSettings": { return [ { key: "option1", defaultValue: true, inputType: "checkbox", }, { key: "option2", defaultValue: "42", inputType: "text", }, ]; } case "keywords": { return ["template", "automation", "IoT", "integration"]; } } } /** * * @param answers */ function getIconName(answers) { return `${answers.adapterName}.${answers.icon?.extension || "png"}`; } async function migrateAdminUi(context) { if (await context.fileExists("admin/jsonConfig.json")) { return "json"; } const hasJsx = await context.hasFilesWithExtension("admin/src", ".jsx", f => !f.endsWith("tab.jsx")); const hasTsx = await context.hasFilesWithExtension("admin/src", ".tsx", f => !f.endsWith("tab.tsx")); if (hasJsx || hasTsx) { return "react"; } return "html"; } //# sourceMappingURL=questions.js.map