@iobroker/create-adapter
Version:
Command line utility to create customized ioBroker adapters
914 lines • 37.7 kB
JavaScript
;
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