diff --git a/.github/create_templates.ts b/.github/create_templates.ts index 18a81f9c..bd4ebccb 100644 --- a/.github/create_templates.ts +++ b/.github/create_templates.ts @@ -248,6 +248,115 @@ void (async () => { console.error(red("At least one template had test errors!")); process.exit(1); } + + console.log(); + console.log(green("Test non-interactive replay mode")); + console.log(green("=================================")); + // Test with TypeScript template + const testTemplate = "TypeScript"; + console.log(blue(`Testing non-interactive replay with ${testTemplate} template`)); + const templateDir = getTemplateDir(testTemplate); + const replayFile = path.join(templateDir, ".create-adapter.json"); + + // Check if the replay file exists + if (!(await fs.pathExists(replayFile))) { + console.error(red(`Replay file not found: ${replayFile}`)); + console.error(red("Skipping non-interactive test")); + hadError = true; + } else { + // Read the original replay file + const originalReplay = await fs.readJSON(replayFile); + + // Create a modified replay file with some fields removed + const modifiedReplay = { ...originalReplay }; + + // Remove a required field with default (startMode) + delete modifiedReplay.startMode; + + // Remove a choice with default (tools - this is multiselect) + delete modifiedReplay.tools; + + // Remove an optional field (description) - note: this might not be in the file anyway + delete modifiedReplay.description; + + // Write the modified replay file + const testReplayFile = path.join(templateDir, ".create-adapter-test.json"); + await fs.writeJSON(testReplayFile, modifiedReplay, { spaces: "\t" }); + + // Run the adapter creator with non-interactive mode + const testOutputDir = path.join(outDir, "NonInteractiveTest"); + await fs.emptyDir(testOutputDir); + + console.log("Running adapter creator in non-interactive mode..."); + try { + // The script is run from .github directory, so go up one level to find bin/ + const binPath = path.join(process.cwd(), "..", "bin", "create-adapter.js"); + execSync( + `node "${binPath}" --replay "${testReplayFile}" --nonInteractive --target "${testOutputDir}" --noInstall --skipAdapterExistenceCheck`, + { + cwd: path.join(process.cwd(), ".."), + stdio: "pipe", + encoding: "utf8", + } + ); + } catch (e: any) { + console.error(red("Non-interactive mode test failed!")); + console.error(red("Error message:"), e.message); + if (e.stdout) console.error(red("stdout:"), e.stdout); + if (e.stderr) console.error(red("stderr:"), e.stderr); + hadError = true; + } + + // Verify the result + const resultReplayFile = path.join(testOutputDir, "ioBroker.template", ".create-adapter.json"); + if (await fs.pathExists(resultReplayFile)) { + const resultReplay = await fs.readJSON(resultReplayFile); + + // Verify that defaults were applied + if (resultReplay.startMode === "daemon") { + console.log(green("✓ Required field with default (startMode) was correctly applied")); + } else { + console.error(red(`✗ startMode was not correctly applied: ${resultReplay.startMode}`)); + hadError = true; + } + + // Verify that tools were converted from indices to values + if (Array.isArray(resultReplay.tools) && resultReplay.tools.includes("ESLint")) { + console.log(green("✓ Choice with default (tools) was correctly applied and converted")); + } else { + console.error(red(`✗ tools was not correctly applied: ${JSON.stringify(resultReplay.tools)}`)); + hadError = true; + } + + // Verify that the adapter was created successfully (has package.json and main.ts) + const packageJsonPath = path.join(testOutputDir, "ioBroker.template", "package.json"); + const mainTsPath = path.join(testOutputDir, "ioBroker.template", "src", "main.ts"); + if (await fs.pathExists(packageJsonPath)) { + console.log(green("✓ package.json was created successfully")); + } else { + console.error(red("✗ package.json was not created")); + hadError = true; + } + if (await fs.pathExists(mainTsPath)) { + console.log(green("✓ src/main.ts was created successfully")); + } else { + console.error(red("✗ src/main.ts was not created")); + hadError = true; + } + } else { + console.error(red("Non-interactive test output file not found!")); + hadError = true; + } + + // Clean up test files + await fs.remove(testReplayFile); + await fs.remove(testOutputDir); + } + + if (hadError) { + console.error(red("Non-interactive mode test had errors!")); + process.exit(1); + } } })(); diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ef147b..2e31eeb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ (at the beginning of a new line) --> ## __WORK IN PROGRESS__ -* (@Apollon77) Revert an unneeded feature +* (@Apollon77/@copilot) Add `--nonInteractive` option for replay mode to enable automated regeneration without prompts (#1249) ## 3.0.0 (2025-11-08) * IMPORTANT: The adapter creator requires Node.js 20.x or newer to run! diff --git a/README.md b/README.md index eae720ba..611f32f3 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ The following CLI options are available: - `--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` +- `--nonInteractive` - Enable non-interactive mode. When used with `--replay`, missing answers will use their default values instead of prompting the user. Useful for automated regeneration. Shortcut: `-y` - `--noInstall` - Don't install dependencies after creating the files. Shortcut: `-n` - `--ignoreOutdatedVersion` - Skip the check if this version is outdated (not recommended). The version check is automatically skipped in CI environments. diff --git a/src/cli.ts b/src/cli.ts index 7ae8f678..f1b5cc7b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -64,6 +64,12 @@ const argv = yargs(hideBin(process.argv)) default: false, desc: "Skip check if this version is outdated", }, + nonInteractive: { + alias: "y", + type: "boolean", + default: false, + desc: "Enable non-interactive mode - use defaults for missing answers in replay mode", + }, }) .parseSync(); @@ -114,6 +120,39 @@ async function ask(): Promise { } } + /** + * Converts an initial value (which may be an index or array of indices) to the actual answer value + * This is necessary because enquirer's select/multiselect questions use indices in their initial property + */ + function convertInitialToValue(q: Question, initial: any): any { + if (initial === undefined) { + return initial; + } + + // For select questions, convert index to value + if (q.type === "select" && typeof initial === "number" && q.choices) { + const choice = q.choices[initial]; + return choice && typeof choice === "object" && "value" in choice ? choice.value : choice; + } + + // For multiselect questions, convert array of indices to array of values + if (q.type === "multiselect" && Array.isArray(initial) && q.choices) { + return initial + .map(index => { + const choice = q.choices![index]; + if (typeof choice === "object" && "message" in choice) { + // If choice has a value property, use it, otherwise use the message + return "value" in choice ? choice.value : choice.message; + } + return choice; + }) + .filter(v => v !== undefined); + } + + // For other question types, return the initial value as-is + return initial; + } + async function askQuestion(q: Question): Promise { if (testCondition(q.condition, answers)) { if (q.replay) { @@ -138,7 +177,21 @@ async function ask(): Promise { } else { if (answers.expert !== "yes" && q.expert && q.initial !== undefined) { // In non-expert mode, prefill the default answer for expert questions - answer = { [q.name as string]: q.initial }; + answer = { [q.name as string]: convertInitialToValue(q, q.initial) }; + } else if (argv.nonInteractive && argv.replay) { + // In non-interactive replay mode, use the default answer for missing questions + if (q.initial !== undefined) { + answer = { [q.name as string]: convertInitialToValue(q, q.initial) }; + } else if (q.optional) { + // For optional questions without defaults, use empty string + answer = { [q.name as string]: "" }; + } else { + // For required questions without defaults in non-interactive mode, fail + error( + `Cannot run in non-interactive mode: required question "${q.label}" is missing from replay file and has no default value`, + ); + return process.exit(1); + } } else { // Ask the user for an answer try {