Skip to content
109 changes: 109 additions & 0 deletions .github/create_templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
})();

Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
55 changes: 54 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -114,6 +120,39 @@ async function ask(): Promise<Answers> {
}
}

/**
* 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<void> {
if (testCondition(q.condition, answers)) {
if (q.replay) {
Expand All @@ -138,7 +177,21 @@ async function ask(): Promise<Answers> {
} 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 {
Expand Down
Loading