Skip to content

Commit fb7d25e

Browse files
committed
feat: wokwi-cli init wizard
1 parent cc601f4 commit fb7d25e

File tree

10 files changed

+318
-3
lines changed

10 files changed

+318
-3
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ cd esp-idf-hello-world
3636
wokwi-cli .
3737
```
3838

39+
## Configuration Wizard
40+
41+
To generate a `wokwi.toml` and a default `diagram.json` files for your project, run:
42+
43+
```bash
44+
wokwi-cli init
45+
```
46+
47+
This will ask you a few questions and will create the necessary files in the current directory. If you want to create the files in a different directory, pass the directory name as an argument:
48+
49+
```bash
50+
wokwi-cli init my-project
51+
```
52+
3953
## Development
4054

4155
Clone the repository, install the npm depenedencies, and then run the CLI:

package-lock.json

Lines changed: 41 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"url": "https://github.com/wokwi/wokwi-cli"
3737
},
3838
"dependencies": {
39+
"@clack/prompts": "^0.7.0",
3940
"@iarna/toml": "2.2.5",
4041
"arg": "^5.0.2",
4142
"chalk": "^5.3.0",
@@ -46,6 +47,7 @@
4647
"devDependencies": {
4748
"@types/ws": "^8.5.4",
4849
"@typescript-eslint/eslint-plugin": "^5.59.1",
50+
"@yao-pkg/pkg": "^5.11.5",
4951
"esbuild": "^0.18.17",
5052
"eslint": "^8.39.0",
5153
"eslint-config-prettier": "^8.8.0",
@@ -55,7 +57,6 @@
5557
"eslint-plugin-promise": "^6.1.1",
5658
"husky": "^8.0.0",
5759
"lint-staged": "^13.2.2",
58-
"@yao-pkg/pkg": "^5.11.5",
5960
"prettier": "^3.0.3",
6061
"rimraf": "^5.0.0",
6162
"tsx": "^4.6.1",

src/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ExpectEngine } from './ExpectEngine.js';
1010
import { TestScenario } from './TestScenario.js';
1111
import { parseConfig } from './config.js';
1212
import { cliHelp } from './help.js';
13+
import { initProjectWizard } from './project/initProjectWizard.js';
1314
import { loadChips } from './loadChips.js';
1415
import { readVersion } from './readVersion.js';
1516
import { DelayCommand } from './scenario/DelayCommand.js';
@@ -84,6 +85,11 @@ async function main() {
8485
process.exit(0);
8586
}
8687

88+
if (args._[0] === 'init') {
89+
await initProjectWizard(args._[1] ?? '.', { diagramFile });
90+
process.exit(0);
91+
}
92+
8793
const token = process.env.WOKWI_CLI_TOKEN;
8894
if (token == null || token.length === 0) {
8995
console.error(

src/project/boards.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export const boards = [
2+
// ESP32 DevKits
3+
{ title: 'ESP32 DevKit', board: 'board-esp32-devkit-c-v4', family: 'esp32' },
4+
{ title: 'ESP32-C3 DevKit', board: 'board-esp32-c3-devkitm-1', family: 'esp32' },
5+
{ title: 'ESP32-C6 DevKit', board: 'board-esp32-c6-devkitc-1', family: 'esp32' },
6+
{ title: 'ESP32-H2 DevKit', board: 'board-esp32-h2-devkitm-1', family: 'esp32' },
7+
{ title: 'ESP32-P4 (Preview)', board: 'board-esp32-p4-preview', family: 'esp32' },
8+
{ title: 'ESP32-S2 DevKit', board: 'board-esp32-s2-devkitm-1', family: 'esp32' },
9+
{ title: 'ESP32-S3 DevKit', board: 'board-esp32-s3-devkitc-1', family: 'esp32' },
10+
11+
// ESP32-based boards
12+
{ title: 'ESP32-C3 Rust DevKit', board: 'board-esp32-c3-rust-1', family: 'esp32' },
13+
{ title: 'ESP32-S3-BOX', board: 'board-esp32-s3-box', family: 'esp32' },
14+
{ title: 'ESP32-S3-BOX-3', board: 'board-esp32-s3-box-3', family: 'esp32' },
15+
{ title: 'M5Stack CoreS3', board: 'board-m5stack-core-s3', family: 'esp32' },
16+
17+
// RP2040-based boards
18+
{ title: 'Raspberry Pi Pico', board: 'wokwi-pi-pico', family: 'rp2' },
19+
{ title: 'Raspberry Pi Pico W', board: 'board-pi-pico-w', family: 'rp2' },
20+
21+
// STM32 boards
22+
{ title: 'STM32 Nucleo-64 C031C6', board: 'board-st-nucleo-c031c6', family: 'stm32' },
23+
{ title: 'STM32 Nucleo-64 L031K6', board: 'board-st-nucleo-l031k6', family: 'stm32' },
24+
];

src/project/createDiagram.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function createDiagram(board: string) {
2+
return {
3+
version: 1,
4+
author: 'Uri Shaked',
5+
editor: 'wokwi',
6+
parts: [{ type: board, id: 'esp' }],
7+
connections: [
8+
['esp:TX', '$serialMonitor:RX', ''],
9+
['esp:RX', '$serialMonitor:TX', ''],
10+
],
11+
};
12+
}

src/project/findFirmwarePath.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { join } from 'path';
2+
import { readFileOrNull } from '../utils/files.js';
3+
import { type ProjectType } from './projectType.js';
4+
5+
export interface FindFirmwarePathResult {
6+
firmware?: string;
7+
elf?: string;
8+
}
9+
10+
export async function findFirmwarePath(
11+
rootDir: string,
12+
projectType: ProjectType | null,
13+
): Promise<FindFirmwarePathResult> {
14+
const cmakeLists = await readFileOrNull(join(rootDir, 'CMakeLists.txt'));
15+
16+
switch (projectType) {
17+
case 'esp-idf': {
18+
const projectName = cmakeLists?.toString('utf-8').match(/\Wproject\(([^\s)]+)\)/i)?.[1];
19+
return {
20+
firmware: 'build/flasher_args.json',
21+
elf: projectName && `build/${projectName}.elf`,
22+
};
23+
}
24+
25+
case 'pico-sdk': {
26+
const projectName = cmakeLists?.toString('utf-8').match(/\Wadd_executable\(([^\s)]+)/i)?.[1];
27+
return {
28+
firmware: projectName && `build/${projectName}.uf2`,
29+
elf: projectName && `build/${projectName}.elf`,
30+
};
31+
}
32+
33+
case 'platformio': {
34+
const platformIni = await readFileOrNull(join(rootDir, 'platformio.ini'));
35+
const firstEnv = platformIni?.toString('utf-8').match(/\[env:([^\s\]]+)\]/)?.[1];
36+
return {
37+
firmware: firstEnv && `.pio/build/${firstEnv}/firmware.bin`,
38+
elf: firstEnv && `.pio/build/${firstEnv}/firmware.elf`,
39+
};
40+
}
41+
42+
default:
43+
return {};
44+
}
45+
}

src/project/initProjectWizard.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { cancel, confirm, intro, isCancel, log, note, outro, select, text } from '@clack/prompts';
2+
import chalkTemplate from 'chalk-template';
3+
import { existsSync, writeFileSync } from 'fs';
4+
import path from 'path';
5+
import { boards } from './boards.js';
6+
import { createDiagram } from './createDiagram.js';
7+
import { findFirmwarePath } from './findFirmwarePath.js';
8+
import { detectProjectType } from './projectType.js';
9+
10+
export async function initProjectWizard(rootDir: string, opts: { diagramFile?: string }) {
11+
const configPath = path.join(rootDir, 'wokwi.toml');
12+
const diagramFilePath = path.resolve(rootDir, opts.diagramFile ?? 'diagram.json');
13+
14+
intro(`Wokwi CLI - Project Initialization Wizard`);
15+
16+
note(`This wizard will help you configure your project for Wokwi.`, 'Welcome');
17+
18+
const existingFiles = [];
19+
if (existsSync(configPath)) {
20+
existingFiles.push('wokwi.toml');
21+
}
22+
if (existsSync(diagramFilePath)) {
23+
existingFiles.push('diagram.json');
24+
}
25+
26+
if (existingFiles.length > 0) {
27+
const shouldContinue = await confirm({
28+
message: `${existingFiles.join(
29+
' and ',
30+
)} already exist in the project directory. This operation will overwrite them. Continue?`,
31+
initialValue: false,
32+
});
33+
if (!shouldContinue || isCancel(shouldContinue)) {
34+
cancel('Operation cancelled.');
35+
process.exit(0);
36+
}
37+
}
38+
39+
const projectType = await detectProjectType(rootDir);
40+
if (projectType != null) {
41+
log.info(chalkTemplate`Detected project type: {greenBright ${projectType}}`);
42+
}
43+
44+
const filteredBoards =
45+
projectType === 'esp-idf'
46+
? boards.filter((board) => board.family === 'esp32')
47+
: projectType === 'pico-sdk'
48+
? boards.filter((board) => board.family === 'rp2')
49+
: boards;
50+
51+
const boardType = await select({
52+
message: 'Select the board to simulate:',
53+
options: filteredBoards.map((board) => ({ value: board.board, label: board.title })),
54+
maxItems: 12,
55+
});
56+
if (isCancel(boardType)) {
57+
cancel('Operation cancelled.');
58+
process.exit(0);
59+
}
60+
61+
const defaultFirmwarePath = await findFirmwarePath(rootDir, projectType);
62+
const firmwarePath = await text({
63+
message: 'Enter the path to the firmware file (e.g. firmware.bin):',
64+
initialValue: defaultFirmwarePath.firmware,
65+
validate: (value) => {
66+
if (!value) {
67+
return 'Please enter a valid path';
68+
}
69+
return undefined;
70+
},
71+
});
72+
if (isCancel(firmwarePath)) {
73+
cancel('Operation cancelled.');
74+
process.exit(0);
75+
}
76+
77+
let elfPath = firmwarePath;
78+
if (!elfPath.endsWith('.elf')) {
79+
const elfPathResponse = await text({
80+
message: 'Enter the path to the ELF file (e.g. firmware.elf):',
81+
initialValue: defaultFirmwarePath.elf,
82+
validate: (value) => {
83+
if (!value) {
84+
return 'Please enter a valid path';
85+
}
86+
return undefined;
87+
},
88+
});
89+
if (isCancel(elfPathResponse)) {
90+
cancel('Operation cancelled.');
91+
process.exit(0);
92+
}
93+
94+
elfPath = elfPathResponse;
95+
}
96+
97+
log.info(`Writing wokwi.toml...`);
98+
writeFileSync(
99+
configPath,
100+
`# Wokwi Configuration File
101+
# Reference: https://docs.wokwi.com/vscode/project-config
102+
103+
[wokwi]
104+
version = 1
105+
firmware = '${firmwarePath}'
106+
elf = '${elfPath}'
107+
`,
108+
);
109+
110+
log.info(`Writing diagram.json...`);
111+
writeFileSync(diagramFilePath, JSON.stringify(createDiagram(boardType as string), null, 2));
112+
113+
outro(`You're all set!`);
114+
}

src/project/projectType.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { join } from 'path';
2+
import { fileExists, readFileOrNull } from '../utils/files.js';
3+
4+
export type ProjectType =
5+
| 'none'
6+
| 'rust'
7+
| 'zephyr'
8+
| 'platformio'
9+
| 'esp-idf'
10+
| 'pico-sdk'
11+
| 'arduino';
12+
13+
export async function detectProjectType(root: string): Promise<ProjectType | null> {
14+
if (await fileExists(join(root, 'Cargo.toml'))) {
15+
return 'rust';
16+
}
17+
18+
if (await fileExists(join(root, 'west.yml'))) {
19+
return 'zephyr';
20+
}
21+
22+
if (await fileExists(join(root, 'platformio.ini'))) {
23+
return 'platformio';
24+
}
25+
26+
const cmakeLists = await readFileOrNull(join(root, 'CMakeLists.txt'));
27+
if (cmakeLists) {
28+
const cmakeListStr = Buffer.from(cmakeLists).toString();
29+
if (cmakeListStr.includes('$ENV{IDF_PATH}')) {
30+
return 'esp-idf';
31+
}
32+
if (cmakeListStr.includes('pico_sdk_init')) {
33+
return 'pico-sdk';
34+
}
35+
}
36+
37+
if (await fileExists(join(root, '.vscode/arduino.json'))) {
38+
return 'arduino';
39+
}
40+
41+
return null;
42+
}

src/utils/files.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { access, readFile } from 'fs/promises';
2+
3+
export async function fileExists(path: string) {
4+
try {
5+
await access(path);
6+
return true;
7+
} catch {
8+
return false;
9+
}
10+
}
11+
12+
export async function readFileOrNull(path: string) {
13+
try {
14+
return await readFile(path);
15+
} catch {
16+
return null;
17+
}
18+
}

0 commit comments

Comments
 (0)