rook-cli 1.0.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/package.json +8 -4
- package/src/app.js +30 -2
- package/src/commands/AddCommand.js +49 -11
- package/src/commands/GenerateCommand.js +143 -0
- package/src/mcp/server.js +387 -0
- package/src/services/ScaffoldService.js +131 -0
- package/src/templates/block.liquid.txt +52 -0
- package/src/templates/controller.js.txt +27 -0
- package/src/templates/section.liquid.txt +92 -0
- package/src/templates/snippet.liquid.txt +21 -0
- package/src/ui/PromptUI.js +54 -1
- package/src/utils/logger.js +1 -1
- package/src/utils/stringUtils.js +86 -0
package/README.md
CHANGED
|
@@ -40,6 +40,53 @@ node bin/rook.js config
|
|
|
40
40
|
node bin/rook.js add
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
## Modo Headless (Automação / CI)
|
|
44
|
+
|
|
45
|
+
Para rodar os comandos sem interação (útil para scripts ou MCP), utilize as flags adicionais:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Adicionar componente/kit diretamente e sobrescrever conflitos
|
|
49
|
+
rook add --type componente --name whatsapp-float --force
|
|
50
|
+
|
|
51
|
+
# Gerar scaffold de arquivos Liquid diretamente
|
|
52
|
+
rook generate hero-banner --type section --yes
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Integração com IA (MCP Server)
|
|
56
|
+
|
|
57
|
+
O Rook CLI expõe todas as suas funcionalidades como ferramentas (*tools*) através do protocolo **Model Context Protocol (MCP)**. Isso permite que Inteligências Artificiais como Claude Desktop e Cursor instalem componentes e gerem arquivos no seu projeto automaticamente.
|
|
58
|
+
|
|
59
|
+
Existem duas formas de configurar o servidor MCP dependendo de como você instalou o pacote:
|
|
60
|
+
|
|
61
|
+
### Opção 1: Usando `npx` (Recomendada)
|
|
62
|
+
Ideal se o pacote for instalado remotamente via NPM. O npx garante que a IA sempre fará o bypass do PATH sem problemas:
|
|
63
|
+
|
|
64
|
+
**Em `claude_desktop_config.json` ou `.cursor/mcp.json`:**
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"mcpServers": {
|
|
68
|
+
"rook-cli": {
|
|
69
|
+
"command": "npx",
|
|
70
|
+
"args": ["-y", "--package", "rook-cli", "rook-mcp"]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Opção 2: Instalando globalmente (`npm install -g rook-cli`)
|
|
77
|
+
Se você instalou o CLI como pacote global no seu sistema, o comando `rook-mcp` estará disponível nativamente.
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"rook-cli": {
|
|
83
|
+
"command": "rook-mcp",
|
|
84
|
+
"args": []
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
43
90
|
## Estrutura do Projeto
|
|
44
91
|
|
|
45
92
|
```
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rook-cli",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "CLI para instalar componentes Shopify
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "CLI para instalar componentes Shopify",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/chesslabdev/rook-cli.git"
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
10
|
"bin": {
|
|
11
|
-
"rook": "bin/rook.js"
|
|
11
|
+
"rook": "bin/rook.js",
|
|
12
|
+
"rook-mcp": "src/mcp/server.js"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"bin",
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
],
|
|
17
18
|
"scripts": {
|
|
18
19
|
"start": "node bin/rook.js",
|
|
20
|
+
"mcp": "node src/mcp/server.js",
|
|
19
21
|
"test": "echo \"No tests yet\" && exit 0"
|
|
20
22
|
},
|
|
21
23
|
"keywords": [
|
|
@@ -28,10 +30,12 @@
|
|
|
28
30
|
"license": "ISC",
|
|
29
31
|
"dependencies": {
|
|
30
32
|
"@inquirer/prompts": "^8.3.0",
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
31
34
|
"commander": "^14.0.3",
|
|
32
35
|
"dotenv": "^17.3.1",
|
|
33
36
|
"fs-extra": "^11.3.3",
|
|
34
37
|
"picocolors": "^1.1.1",
|
|
35
|
-
"tiged": "^2.12.7"
|
|
38
|
+
"tiged": "^2.12.7",
|
|
39
|
+
"zod": "^4.3.6"
|
|
36
40
|
}
|
|
37
41
|
}
|
package/src/app.js
CHANGED
|
@@ -20,6 +20,8 @@ import { FileMapper } from './filesystem/FileMapper.js';
|
|
|
20
20
|
import { ConflictResolver } from './filesystem/ConflictResolver.js';
|
|
21
21
|
import { AddCommand } from './commands/AddCommand.js';
|
|
22
22
|
import { ConfigCommand } from './commands/ConfigCommand.js';
|
|
23
|
+
import { GenerateCommand } from './commands/GenerateCommand.js';
|
|
24
|
+
import { ScaffoldService } from './services/ScaffoldService.js';
|
|
23
25
|
import { CLI_NAME } from './config/constants.js';
|
|
24
26
|
|
|
25
27
|
export class App {
|
|
@@ -50,6 +52,15 @@ export class App {
|
|
|
50
52
|
this.logger,
|
|
51
53
|
this.tokenManager
|
|
52
54
|
);
|
|
55
|
+
|
|
56
|
+
this.scaffoldService = new ScaffoldService(this.logger);
|
|
57
|
+
|
|
58
|
+
this.generateCommand = new GenerateCommand(
|
|
59
|
+
this.logger,
|
|
60
|
+
this.promptUI,
|
|
61
|
+
this.scaffoldService,
|
|
62
|
+
this.conflictResolver
|
|
63
|
+
);
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
/**
|
|
@@ -63,12 +74,16 @@ export class App {
|
|
|
63
74
|
.version('1.0.1');
|
|
64
75
|
|
|
65
76
|
// Comando: rook add
|
|
77
|
+
// Flags de headless mode: --type, --name, --force
|
|
66
78
|
this.programa
|
|
67
79
|
.command('add')
|
|
68
80
|
.description('Adiciona kits ou componentes ao tema Shopify atual')
|
|
69
|
-
.
|
|
81
|
+
.option('--type <tipo>', 'Tipo de instalação: "kit" ou "componente"')
|
|
82
|
+
.option('--name <nome>', 'Nome do kit ou componente a instalar')
|
|
83
|
+
.option('--force', 'Sobrescreve arquivos existentes sem perguntar')
|
|
84
|
+
.action(async (opcoes) => {
|
|
70
85
|
this.logger.banner();
|
|
71
|
-
await this.addCommand.executar();
|
|
86
|
+
await this.addCommand.executar(opcoes);
|
|
72
87
|
});
|
|
73
88
|
|
|
74
89
|
// Comando: rook config
|
|
@@ -80,6 +95,19 @@ export class App {
|
|
|
80
95
|
await this.configCommand.executar();
|
|
81
96
|
});
|
|
82
97
|
|
|
98
|
+
// Comando: rook generate [nome-do-componente]
|
|
99
|
+
// Flags de headless mode: --type, --yes
|
|
100
|
+
this.programa
|
|
101
|
+
.command('generate [nome]')
|
|
102
|
+
.alias('g')
|
|
103
|
+
.description('Gera scaffold de componentes Shopify (sections, blocks, snippets, assets)')
|
|
104
|
+
.option('--type <tipo>', 'Tipo de componente: section, block, snippet, controller')
|
|
105
|
+
.option('--yes', 'Pula confirmações e sobrescreve arquivos existentes')
|
|
106
|
+
.action(async (nome, opcoes) => {
|
|
107
|
+
this.logger.banner();
|
|
108
|
+
await this.generateCommand.executar(nome, opcoes);
|
|
109
|
+
});
|
|
110
|
+
|
|
83
111
|
// Comando padrão (sem argumentos) — abre o menu interativo
|
|
84
112
|
this.programa
|
|
85
113
|
.action(async () => {
|
|
@@ -34,18 +34,32 @@ export class AddCommand {
|
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* Executa o fluxo principal do comando "add".
|
|
37
|
+
*
|
|
38
|
+
* Suporta dois modos:
|
|
39
|
+
* - Interativo (padrão): exibe menus para o usuário
|
|
40
|
+
* - Headless (com flags): executa direto, sem prompts (para uso com MCP/IA)
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} [opcoes={}] - Opções passadas via flags do Commander
|
|
43
|
+
* @param {string} [opcoes.type] - Tipo de instalação ("kit" ou "componente")
|
|
44
|
+
* @param {string} [opcoes.name] - Nome do kit ou componente
|
|
45
|
+
* @param {boolean} [opcoes.force] - Sobrescreve sem perguntar
|
|
37
46
|
* @returns {Promise<void>}
|
|
38
47
|
*/
|
|
39
|
-
async executar() {
|
|
48
|
+
async executar(opcoes = {}) {
|
|
40
49
|
try {
|
|
41
|
-
//
|
|
42
|
-
|
|
50
|
+
// Modo headless: se --force foi passado, configura o ConflictResolver
|
|
51
|
+
if (opcoes.force) {
|
|
52
|
+
this.fileMapper.conflictResolver?.definirDecisaoGlobal?.(true);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 1. Determina o tipo (flag ou menu interativo)
|
|
56
|
+
const tipoInstalacao = opcoes.type || await this.promptUI.menuPrincipal();
|
|
43
57
|
|
|
44
58
|
// 2. Delega para o fluxo correto
|
|
45
59
|
if (tipoInstalacao === 'kit') {
|
|
46
|
-
await this._instalarKit();
|
|
60
|
+
await this._instalarKit(opcoes.name);
|
|
47
61
|
} else {
|
|
48
|
-
await this._instalarComponentes();
|
|
62
|
+
await this._instalarComponentes(opcoes.name);
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
} catch (erro) {
|
|
@@ -63,13 +77,14 @@ export class AddCommand {
|
|
|
63
77
|
*
|
|
64
78
|
* Segue o padrão descrito no PRD (Seção 5.3):
|
|
65
79
|
* 1. Lista kits disponíveis (com dados do kit.json)
|
|
66
|
-
* 2. Usuário seleciona o kit
|
|
80
|
+
* 2. Usuário seleciona o kit (ou recebe via flag headless)
|
|
67
81
|
* 3. Instala cada componente referenciado no manifesto
|
|
68
82
|
* 4. Instala os arquivos locais exclusivos do kit
|
|
69
83
|
*
|
|
84
|
+
* @param {string} [nomeKit] - Nome do kit (modo headless). Se omitido, exibe menu.
|
|
70
85
|
* @private
|
|
71
86
|
*/
|
|
72
|
-
async _instalarKit() {
|
|
87
|
+
async _instalarKit(nomeKit) {
|
|
73
88
|
// 1. Lista kits disponíveis (já enriquecidos com dados do kit.json)
|
|
74
89
|
const kits = await this.githubService.listarKits();
|
|
75
90
|
|
|
@@ -78,8 +93,17 @@ export class AddCommand {
|
|
|
78
93
|
return;
|
|
79
94
|
}
|
|
80
95
|
|
|
81
|
-
// 2.
|
|
82
|
-
|
|
96
|
+
// 2. Seleciona o kit (headless ou interativo)
|
|
97
|
+
let kitSelecionado;
|
|
98
|
+
if (nomeKit) {
|
|
99
|
+
kitSelecionado = kits.find(k => k.slug === nomeKit || k.nome === nomeKit);
|
|
100
|
+
if (!kitSelecionado) {
|
|
101
|
+
this.logger.erro(`Kit "${nomeKit}" não encontrado.`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
kitSelecionado = await this.promptUI.selecionarKit(kits);
|
|
106
|
+
}
|
|
83
107
|
|
|
84
108
|
this.logger.destaque(`\n📥 Instalando kit: ${kitSelecionado.nome}\n`);
|
|
85
109
|
|
|
@@ -132,12 +156,26 @@ export class AddCommand {
|
|
|
132
156
|
|
|
133
157
|
/**
|
|
134
158
|
* Fluxo de instalação de componentes individuais.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} [nomeComponente] - Nome do componente (modo headless). Se omitido, exibe menu.
|
|
135
161
|
* @private
|
|
136
162
|
*/
|
|
137
|
-
async _instalarComponentes() {
|
|
163
|
+
async _instalarComponentes(nomeComponente) {
|
|
138
164
|
// Lista componentes disponíveis
|
|
139
165
|
const componentes = await this.githubService.listarComponentes();
|
|
140
|
-
|
|
166
|
+
|
|
167
|
+
let selecionados;
|
|
168
|
+
if (nomeComponente) {
|
|
169
|
+
// Modo headless: busca pelo nome diretamente
|
|
170
|
+
const encontrado = componentes.find(c => c.nome === nomeComponente);
|
|
171
|
+
if (!encontrado) {
|
|
172
|
+
this.logger.erro(`Componente "${nomeComponente}" não encontrado no repositório.`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
selecionados = [encontrado];
|
|
176
|
+
} else {
|
|
177
|
+
selecionados = await this.promptUI.selecionarComponentes(componentes);
|
|
178
|
+
}
|
|
141
179
|
|
|
142
180
|
this.logger.destaque(`\n📥 Instalando ${selecionados.length} componente(s)...\n`);
|
|
143
181
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GenerateCommand — Comando "generate" do CLI.
|
|
3
|
+
*
|
|
4
|
+
* Orquestra o fluxo de scaffold de componentes:
|
|
5
|
+
* 1. Recebe o nome do componente (argumento ou prompt)
|
|
6
|
+
* 2. Exibe menu multi-select para tipos de arquivo
|
|
7
|
+
* 3. Normaliza o nome para os formatos corretos (kebab, Pascal, snake)
|
|
8
|
+
* 4. Delega a geração ao ScaffoldService
|
|
9
|
+
* 5. Grava os arquivos via FileSystemManager (fs-extra)
|
|
10
|
+
* 6. Trata conflitos de sobrescrita
|
|
11
|
+
*
|
|
12
|
+
* Princípio: Inversão de Dependência (DIP) — todas as dependências são injetadas
|
|
13
|
+
* Princípio: Responsabilidade Única (SRP) — orquestra apenas o fluxo do comando "generate"
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import fs from 'fs-extra';
|
|
18
|
+
import { generateNames } from '../utils/stringUtils.js';
|
|
19
|
+
|
|
20
|
+
export class GenerateCommand {
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {import('../utils/logger.js').Logger} logger
|
|
24
|
+
* @param {import('../ui/PromptUI.js').PromptUI} promptUI
|
|
25
|
+
* @param {import('../services/ScaffoldService.js').ScaffoldService} scaffoldService
|
|
26
|
+
* @param {import('../filesystem/ConflictResolver.js').ConflictResolver} conflictResolver
|
|
27
|
+
*/
|
|
28
|
+
constructor(logger, promptUI, scaffoldService, conflictResolver) {
|
|
29
|
+
this.logger = logger;
|
|
30
|
+
this.promptUI = promptUI;
|
|
31
|
+
this.scaffoldService = scaffoldService;
|
|
32
|
+
this.conflictResolver = conflictResolver;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Executa o fluxo principal do comando "generate".
|
|
37
|
+
*
|
|
38
|
+
* Suporta dois modos:
|
|
39
|
+
* - Interativo (padrão): pergunta nome e tipos ao usuário
|
|
40
|
+
* - Headless (com flags): executa direto, sem prompts (para uso com MCP/IA)
|
|
41
|
+
*
|
|
42
|
+
* @param {string} [nomeComponente] - Nome passado como argumento CLI (opcional)
|
|
43
|
+
* @param {Object} [opcoes={}] - Opções passadas via flags do Commander
|
|
44
|
+
* @param {string} [opcoes.type] - Tipo de componente (pula multi-select)
|
|
45
|
+
* @param {boolean} [opcoes.yes] - Sobrescreve arquivos sem perguntar
|
|
46
|
+
* @returns {Promise<void>}
|
|
47
|
+
*/
|
|
48
|
+
async executar(nomeComponente, opcoes = {}) {
|
|
49
|
+
try {
|
|
50
|
+
// Modo headless: se --yes foi passado, sobrescreve tudo
|
|
51
|
+
if (opcoes.yes) {
|
|
52
|
+
this.conflictResolver.definirDecisaoGlobal(true);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 1. Se o nome não veio como argumento, pergunta ao usuário
|
|
56
|
+
const nome = nomeComponente || await this.promptUI.perguntarNomeComponente();
|
|
57
|
+
|
|
58
|
+
if (!nome || nome.trim() === '') {
|
|
59
|
+
this.logger.erro('Nome do componente é obrigatório.');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2. Normaliza os nomes
|
|
64
|
+
const names = generateNames(nome);
|
|
65
|
+
|
|
66
|
+
this.logger.destaque(`\n🔧 Gerando componente: ${names.PascalName} (${names.kebabName})\n`);
|
|
67
|
+
|
|
68
|
+
// 3. Determina os tipos (flag headless ou multi-select interativo)
|
|
69
|
+
let tiposSelecionados;
|
|
70
|
+
if (opcoes.type) {
|
|
71
|
+
// Headless: aceita tipo único ou múltiplos separados por vírgula
|
|
72
|
+
tiposSelecionados = opcoes.type.split(',').map(t => t.trim());
|
|
73
|
+
|
|
74
|
+
// Valida tipos
|
|
75
|
+
const tiposValidos = this.scaffoldService.getAvailableTypes();
|
|
76
|
+
for (const tipo of tiposSelecionados) {
|
|
77
|
+
if (!tiposValidos.includes(tipo)) {
|
|
78
|
+
this.logger.erro(`Tipo inválido: "${tipo}". Tipos válidos: ${tiposValidos.join(', ')}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
tiposSelecionados = await this.promptUI.selecionarTiposGerador();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (tiposSelecionados.length === 0) {
|
|
87
|
+
this.logger.aviso('Nenhum tipo selecionado. Operação cancelada.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 4. Gera os arquivos via ScaffoldService
|
|
92
|
+
const arquivosGerados = await this.scaffoldService.generateMultiple(tiposSelecionados, names);
|
|
93
|
+
|
|
94
|
+
// 5. Grava os arquivos no disco
|
|
95
|
+
const diretorioAtual = process.cwd();
|
|
96
|
+
let totalGravados = 0;
|
|
97
|
+
|
|
98
|
+
// Reseta decisões anteriores do ConflictResolver (se não é headless)
|
|
99
|
+
if (!opcoes.yes) {
|
|
100
|
+
this.conflictResolver.resetar();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const arquivo of arquivosGerados) {
|
|
104
|
+
const destDir = path.join(diretorioAtual, arquivo.outputDir);
|
|
105
|
+
const destPath = path.join(destDir, arquivo.fileName);
|
|
106
|
+
const caminhoRelativo = path.join(arquivo.outputDir, arquivo.fileName);
|
|
107
|
+
|
|
108
|
+
// RF03: Verifica se já existe
|
|
109
|
+
if (await fs.pathExists(destPath)) {
|
|
110
|
+
const sobrescrever = await this.conflictResolver.resolver(caminhoRelativo);
|
|
111
|
+
|
|
112
|
+
if (!sobrescrever) {
|
|
113
|
+
this.logger.aviso(` ⏩ Pulando: ${caminhoRelativo}`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Cria o diretório se não existe e grava o arquivo
|
|
119
|
+
await fs.ensureDir(destDir);
|
|
120
|
+
await fs.writeFile(destPath, arquivo.content, 'utf-8');
|
|
121
|
+
|
|
122
|
+
this.logger.sucesso(` ✅ ${caminhoRelativo}`);
|
|
123
|
+
totalGravados++;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 6. Resumo final
|
|
127
|
+
console.log('');
|
|
128
|
+
this.logger.destaque('═══════════════════════════════════════════');
|
|
129
|
+
this.logger.sucesso(`🎉 ${totalGravados}/${arquivosGerados.length} arquivo(s) gerado(s) com sucesso!`);
|
|
130
|
+
this.logger.sutil(`Componente: ${names.PascalName} (${names.kebabName})`);
|
|
131
|
+
this.logger.destaque('═══════════════════════════════════════════');
|
|
132
|
+
console.log('');
|
|
133
|
+
|
|
134
|
+
} catch (erro) {
|
|
135
|
+
// Trata cancelamento do usuário (Ctrl+C)
|
|
136
|
+
if (erro.name === 'ExitPromptError') {
|
|
137
|
+
this.logger.aviso('Operação cancelada pelo usuário.');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.logger.erro(`Erro durante a geração: ${erro.message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rook MCP Server — Ponte entre IA e o CLI Rook.
|
|
5
|
+
*
|
|
6
|
+
* Expõe as funcionalidades do CLI como Tools que clientes MCP
|
|
7
|
+
* (Claude Desktop, Cursor, etc.) podem descobrir e executar.
|
|
8
|
+
*
|
|
9
|
+
* Tools disponíveis:
|
|
10
|
+
* - list_components: Lista kits e componentes do repositório remoto
|
|
11
|
+
* - install_component: Instala um componente ou kit no tema local
|
|
12
|
+
* - generate_scaffold: Gera boilerplate de componentes Liquid
|
|
13
|
+
*
|
|
14
|
+
* Princípio: Composição — reutiliza os serviços existentes do CLI
|
|
15
|
+
* Princípio: Responsabilidade Única (SRP) — apenas expõe tools via protocolo MCP
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
19
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
20
|
+
import {
|
|
21
|
+
CallToolRequestSchema,
|
|
22
|
+
ListToolsRequestSchema,
|
|
23
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
|
|
26
|
+
// --- Dependências internas do CLI (reutilizadas) ---
|
|
27
|
+
import { Logger } from '../utils/logger.js';
|
|
28
|
+
import { TokenManager } from '../auth/TokenManager.js';
|
|
29
|
+
import { GitHubService } from '../services/GitHubService.js';
|
|
30
|
+
import { DownloadService } from '../services/DownloadService.js';
|
|
31
|
+
import { ScaffoldService } from '../services/ScaffoldService.js';
|
|
32
|
+
import { FileMapper } from '../filesystem/FileMapper.js';
|
|
33
|
+
import { ConflictResolver } from '../filesystem/ConflictResolver.js';
|
|
34
|
+
import { generateNames } from '../utils/stringUtils.js';
|
|
35
|
+
|
|
36
|
+
import path from 'path';
|
|
37
|
+
import os from 'os';
|
|
38
|
+
import fs from 'fs-extra';
|
|
39
|
+
import { fileURLToPath } from 'url';
|
|
40
|
+
|
|
41
|
+
// -- Resolve .env relativo ao diretório do CLI --
|
|
42
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
43
|
+
const __dirname = path.dirname(__filename);
|
|
44
|
+
const cliRoot = path.resolve(__dirname, '..', '..');
|
|
45
|
+
|
|
46
|
+
import dotenv from 'dotenv';
|
|
47
|
+
dotenv.config({ path: path.join(cliRoot, '.env') });
|
|
48
|
+
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════
|
|
50
|
+
// Composition Root — instanciação de dependências (mesmo padrão do app.js)
|
|
51
|
+
// ═══════════════════════════════════════════════════════════════
|
|
52
|
+
|
|
53
|
+
const logger = new Logger();
|
|
54
|
+
const tokenManager = new TokenManager(logger);
|
|
55
|
+
const githubService = new GitHubService(logger, tokenManager);
|
|
56
|
+
const downloadService = new DownloadService(logger, tokenManager);
|
|
57
|
+
const conflictResolver = new ConflictResolver(logger);
|
|
58
|
+
const fileMapper = new FileMapper(logger, conflictResolver);
|
|
59
|
+
const scaffoldService = new ScaffoldService(logger);
|
|
60
|
+
|
|
61
|
+
// No modo MCP, o ConflictResolver opera em modo headless (sobrescreve tudo)
|
|
62
|
+
conflictResolver.definirDecisaoGlobal(true);
|
|
63
|
+
|
|
64
|
+
// ═══════════════════════════════════════════════════════════════
|
|
65
|
+
// Schemas de validação (Zod)
|
|
66
|
+
// ═══════════════════════════════════════════════════════════════
|
|
67
|
+
|
|
68
|
+
const ListComponentsSchema = z.object({
|
|
69
|
+
category: z
|
|
70
|
+
.enum(['kits', 'components', 'all'])
|
|
71
|
+
.optional()
|
|
72
|
+
.default('all')
|
|
73
|
+
.describe('Filtrar por categoria: "kits", "components" ou "all" (padrão).'),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const InstallComponentSchema = z.object({
|
|
77
|
+
name: z
|
|
78
|
+
.string()
|
|
79
|
+
.min(1)
|
|
80
|
+
.describe('Nome do componente ou kit a ser instalado (ex: "whatsapp-float").'),
|
|
81
|
+
type: z
|
|
82
|
+
.enum(['component', 'kit'])
|
|
83
|
+
.describe('Tipo do pacote: "component" para componente individual, "kit" para kit completo.'),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const GenerateScaffoldSchema = z.object({
|
|
87
|
+
name: z
|
|
88
|
+
.string()
|
|
89
|
+
.min(1)
|
|
90
|
+
.describe('Nome do novo recurso (ex: "hero-banner"). Será convertido para kebab-case automaticamente.'),
|
|
91
|
+
elementType: z
|
|
92
|
+
.enum(['section', 'block', 'snippet', 'controller'])
|
|
93
|
+
.describe('Tipo de recurso Shopify a ser gerado.'),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ═══════════════════════════════════════════════════════════════
|
|
97
|
+
// Funções Handler das Tools
|
|
98
|
+
// ═══════════════════════════════════════════════════════════════
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Handler: list_components
|
|
102
|
+
* Lista kits e/ou componentes disponíveis no repositório remoto.
|
|
103
|
+
*/
|
|
104
|
+
async function handleListComponents(args) {
|
|
105
|
+
const { category } = ListComponentsSchema.parse(args);
|
|
106
|
+
|
|
107
|
+
const resultado = {};
|
|
108
|
+
|
|
109
|
+
if (category === 'kits' || category === 'all') {
|
|
110
|
+
const kits = await githubService.listarKits();
|
|
111
|
+
resultado.kits = kits.map(kit => ({
|
|
112
|
+
name: kit.nome,
|
|
113
|
+
slug: kit.slug,
|
|
114
|
+
description: kit.descricao,
|
|
115
|
+
components: kit.componentes,
|
|
116
|
+
path: kit.caminho,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (category === 'components' || category === 'all') {
|
|
121
|
+
const componentes = await githubService.listarComponentes();
|
|
122
|
+
resultado.components = componentes.map(comp => ({
|
|
123
|
+
name: comp.nome,
|
|
124
|
+
path: comp.caminho,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const totalKits = resultado.kits?.length || 0;
|
|
129
|
+
const totalComponents = resultado.components?.length || 0;
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
content: [{
|
|
133
|
+
type: 'text',
|
|
134
|
+
text: JSON.stringify({
|
|
135
|
+
summary: `Encontrados ${totalKits} kit(s) e ${totalComponents} componente(s).`,
|
|
136
|
+
...resultado,
|
|
137
|
+
}, null, 2),
|
|
138
|
+
}],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Handler: install_component
|
|
144
|
+
* Instala um componente individual ou kit completo no tema do usuário.
|
|
145
|
+
*/
|
|
146
|
+
async function handleInstallComponent(args) {
|
|
147
|
+
const { name, type } = InstallComponentSchema.parse(args);
|
|
148
|
+
const diretorioAtual = process.cwd();
|
|
149
|
+
|
|
150
|
+
if (type === 'kit') {
|
|
151
|
+
// --- Instalação de Kit ---
|
|
152
|
+
const kits = await githubService.listarKits();
|
|
153
|
+
const kit = kits.find(k => k.slug === name || k.nome === name);
|
|
154
|
+
|
|
155
|
+
if (!kit) {
|
|
156
|
+
const disponiveis = kits.map(k => k.slug).join(', ');
|
|
157
|
+
throw new Error(`Kit "${name}" não encontrado. Kits disponíveis: ${disponiveis}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let totalComponentes = 0;
|
|
161
|
+
let totalArquivos = 0;
|
|
162
|
+
|
|
163
|
+
// Instala cada componente do manifesto
|
|
164
|
+
for (const nomeComponente of kit.componentes) {
|
|
165
|
+
const copiados = await baixarEDistribuir(`components/${nomeComponente}`, diretorioAtual);
|
|
166
|
+
totalArquivos += copiados;
|
|
167
|
+
totalComponentes++;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Instala arquivos base do kit
|
|
171
|
+
const copiadosKit = await baixarEDistribuir(kit.caminho, diretorioAtual);
|
|
172
|
+
totalArquivos += copiadosKit;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
content: [{
|
|
176
|
+
type: 'text',
|
|
177
|
+
text: JSON.stringify({
|
|
178
|
+
success: true,
|
|
179
|
+
message: `Kit "${kit.nome}" instalado com sucesso.`,
|
|
180
|
+
components_installed: totalComponentes,
|
|
181
|
+
files_copied: totalArquivos,
|
|
182
|
+
}, null, 2),
|
|
183
|
+
}],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
} else {
|
|
187
|
+
// --- Instalação de Componente Individual ---
|
|
188
|
+
const caminhoRemoto = `components/${name}`;
|
|
189
|
+
const copiados = await baixarEDistribuir(caminhoRemoto, diretorioAtual);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
content: [{
|
|
193
|
+
type: 'text',
|
|
194
|
+
text: JSON.stringify({
|
|
195
|
+
success: true,
|
|
196
|
+
message: `Componente "${name}" instalado com sucesso.`,
|
|
197
|
+
files_copied: copiados,
|
|
198
|
+
}, null, 2),
|
|
199
|
+
}],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Handler: generate_scaffold
|
|
206
|
+
* Gera boilerplate de um componente Liquid (section, block, snippet, controller).
|
|
207
|
+
*/
|
|
208
|
+
async function handleGenerateScaffold(args) {
|
|
209
|
+
const { name, elementType } = GenerateScaffoldSchema.parse(args);
|
|
210
|
+
|
|
211
|
+
// Normaliza nomes (kebab, Pascal, snake)
|
|
212
|
+
const names = generateNames(name);
|
|
213
|
+
|
|
214
|
+
// Gera o conteúdo via ScaffoldService
|
|
215
|
+
const arquivo = await scaffoldService.generate(elementType, names);
|
|
216
|
+
|
|
217
|
+
// Grava no disco
|
|
218
|
+
const diretorioAtual = process.cwd();
|
|
219
|
+
const destDir = path.join(diretorioAtual, arquivo.outputDir);
|
|
220
|
+
const destPath = path.join(destDir, arquivo.fileName);
|
|
221
|
+
const caminhoRelativo = path.join(arquivo.outputDir, arquivo.fileName);
|
|
222
|
+
|
|
223
|
+
await fs.ensureDir(destDir);
|
|
224
|
+
await fs.writeFile(destPath, arquivo.content, 'utf-8');
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
content: [{
|
|
228
|
+
type: 'text',
|
|
229
|
+
text: JSON.stringify({
|
|
230
|
+
success: true,
|
|
231
|
+
message: `Scaffold gerado com sucesso: ${caminhoRelativo}`,
|
|
232
|
+
file: caminhoRelativo,
|
|
233
|
+
names: {
|
|
234
|
+
kebab: names.kebabName,
|
|
235
|
+
pascal: names.PascalName,
|
|
236
|
+
snake: names.snake_name,
|
|
237
|
+
},
|
|
238
|
+
}, null, 2),
|
|
239
|
+
}],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ═══════════════════════════════════════════════════════════════
|
|
244
|
+
// Função auxiliar: Download + Distribuição
|
|
245
|
+
// ═══════════════════════════════════════════════════════════════
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Processo genérico: baixa do repositório e distribui nas pastas locais.
|
|
249
|
+
* Reutiliza o mesmo padrão do AddCommand._baixarEDistribuir().
|
|
250
|
+
*
|
|
251
|
+
* @param {string} caminhoRemoto - Caminho no repositório remoto
|
|
252
|
+
* @param {string} diretorioDestino - Diretório raiz do tema Shopify local
|
|
253
|
+
* @returns {Promise<number>} Número de arquivos copiados
|
|
254
|
+
*/
|
|
255
|
+
async function baixarEDistribuir(caminhoRemoto, diretorioDestino) {
|
|
256
|
+
const pastaTmp = path.join(os.tmpdir(), `rook-mcp-${Date.now()}`);
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await fs.ensureDir(pastaTmp);
|
|
260
|
+
await downloadService.baixar(caminhoRemoto, pastaTmp);
|
|
261
|
+
const totalCopiados = await fileMapper.distribuir(pastaTmp, diretorioDestino);
|
|
262
|
+
return totalCopiados;
|
|
263
|
+
} finally {
|
|
264
|
+
await fs.remove(pastaTmp);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ═══════════════════════════════════════════════════════════════
|
|
269
|
+
// Inicialização do Servidor MCP
|
|
270
|
+
// ═══════════════════════════════════════════════════════════════
|
|
271
|
+
|
|
272
|
+
const server = new Server(
|
|
273
|
+
{
|
|
274
|
+
name: 'rook-cli-server',
|
|
275
|
+
version: '1.0.0',
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
capabilities: {
|
|
279
|
+
tools: {},
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// --- Registrar lista de Tools disponíveis ---
|
|
285
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
286
|
+
return {
|
|
287
|
+
tools: [
|
|
288
|
+
{
|
|
289
|
+
name: 'list_components',
|
|
290
|
+
description:
|
|
291
|
+
'Lista componentes e kits disponíveis no repositório Rook. ' +
|
|
292
|
+
'Use para descobrir o que pode ser instalado no tema Shopify do usuário.',
|
|
293
|
+
inputSchema: {
|
|
294
|
+
type: 'object',
|
|
295
|
+
properties: {
|
|
296
|
+
category: {
|
|
297
|
+
type: 'string',
|
|
298
|
+
enum: ['kits', 'components', 'all'],
|
|
299
|
+
description: 'Filtrar por categoria: "kits", "components" ou "all" (padrão).',
|
|
300
|
+
default: 'all',
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: 'install_component',
|
|
307
|
+
description:
|
|
308
|
+
'Instala um componente individual ou kit completo no tema Shopify atual. ' +
|
|
309
|
+
'Os arquivos são automaticamente distribuídos nas pastas corretas (sections/, snippets/, blocks/, assets/).',
|
|
310
|
+
inputSchema: {
|
|
311
|
+
type: 'object',
|
|
312
|
+
properties: {
|
|
313
|
+
name: {
|
|
314
|
+
type: 'string',
|
|
315
|
+
description: 'Nome/slug do componente ou kit (ex: "whatsapp-float", "starter-base").',
|
|
316
|
+
},
|
|
317
|
+
type: {
|
|
318
|
+
type: 'string',
|
|
319
|
+
enum: ['component', 'kit'],
|
|
320
|
+
description: 'Tipo do pacote: "component" ou "kit".',
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
required: ['name', 'type'],
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: 'generate_scaffold',
|
|
328
|
+
description:
|
|
329
|
+
'Gera a estrutura inicial (boilerplate) de um novo componente Liquid para Shopify. ' +
|
|
330
|
+
'Cria o arquivo com template padronizado já preenchido com LiquidDoc e Web Components.',
|
|
331
|
+
inputSchema: {
|
|
332
|
+
type: 'object',
|
|
333
|
+
properties: {
|
|
334
|
+
name: {
|
|
335
|
+
type: 'string',
|
|
336
|
+
description: 'Nome do novo recurso em qualquer formato (ex: "hero-banner", "HeroBanner"). Será normalizado automaticamente.',
|
|
337
|
+
},
|
|
338
|
+
elementType: {
|
|
339
|
+
type: 'string',
|
|
340
|
+
enum: ['section', 'block', 'snippet', 'controller'],
|
|
341
|
+
description: 'Tipo de recurso Shopify: section, block, snippet ou controller (JS asset).',
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
required: ['name', 'elementType'],
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
};
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// --- Executar Tool solicitada pela IA ---
|
|
352
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
353
|
+
const { name, arguments: args } = request.params;
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
switch (name) {
|
|
357
|
+
case 'list_components':
|
|
358
|
+
return await handleListComponents(args || {});
|
|
359
|
+
|
|
360
|
+
case 'install_component':
|
|
361
|
+
return await handleInstallComponent(args);
|
|
362
|
+
|
|
363
|
+
case 'generate_scaffold':
|
|
364
|
+
return await handleGenerateScaffold(args);
|
|
365
|
+
|
|
366
|
+
default:
|
|
367
|
+
throw new Error(`Tool desconhecida: "${name}". Tools disponíveis: list_components, install_component, generate_scaffold`);
|
|
368
|
+
}
|
|
369
|
+
} catch (erro) {
|
|
370
|
+
// Retorna erro estruturado para a IA
|
|
371
|
+
return {
|
|
372
|
+
isError: true,
|
|
373
|
+
content: [{
|
|
374
|
+
type: 'text',
|
|
375
|
+
text: JSON.stringify({
|
|
376
|
+
error: true,
|
|
377
|
+
tool: name,
|
|
378
|
+
message: erro.message,
|
|
379
|
+
}, null, 2),
|
|
380
|
+
}],
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// --- Iniciar transporte Stdio ---
|
|
386
|
+
const transport = new StdioServerTransport();
|
|
387
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScaffoldService — Motor de geração de scaffold via templates.
|
|
3
|
+
*
|
|
4
|
+
* Responsável por:
|
|
5
|
+
* - Ler os templates base da pasta src/templates/
|
|
6
|
+
* - Substituir placeholders ({{kebabName}}, {{PascalName}}, etc.)
|
|
7
|
+
* - Retornar o conteúdo final já renderizado
|
|
8
|
+
*
|
|
9
|
+
* Princípio: Responsabilidade Única (SRP) — lida apenas com renderização de templates
|
|
10
|
+
* Princípio: Aberto/Fechado (OCP) — novos templates são adicionados sem alterar lógica existente
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs-extra';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = path.dirname(__filename);
|
|
19
|
+
|
|
20
|
+
/** Diretório onde os templates estão armazenados */
|
|
21
|
+
const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mapa de tipos para seus respectivos arquivos de template
|
|
25
|
+
* e destinos de saída.
|
|
26
|
+
*
|
|
27
|
+
* @type {Object.<string, { templateFile: string, outputDir: string, outputFileName: (kebabName: string) => string }>}
|
|
28
|
+
*/
|
|
29
|
+
const TEMPLATE_MAP = {
|
|
30
|
+
section: {
|
|
31
|
+
templateFile: 'section.liquid.txt',
|
|
32
|
+
outputDir: 'sections',
|
|
33
|
+
outputFileName: (kebabName) => `${kebabName}.liquid`,
|
|
34
|
+
},
|
|
35
|
+
block: {
|
|
36
|
+
templateFile: 'block.liquid.txt',
|
|
37
|
+
outputDir: 'blocks',
|
|
38
|
+
outputFileName: (kebabName) => `${kebabName}-block.liquid`,
|
|
39
|
+
},
|
|
40
|
+
snippet: {
|
|
41
|
+
templateFile: 'snippet.liquid.txt',
|
|
42
|
+
outputDir: 'snippets',
|
|
43
|
+
outputFileName: (kebabName) => `${kebabName}.liquid`,
|
|
44
|
+
},
|
|
45
|
+
controller: {
|
|
46
|
+
templateFile: 'controller.js.txt',
|
|
47
|
+
outputDir: 'assets',
|
|
48
|
+
outputFileName: (kebabName) => `${kebabName}-controller.js`,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export class ScaffoldService {
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {import('../utils/logger.js').Logger} logger
|
|
56
|
+
*/
|
|
57
|
+
constructor(logger) {
|
|
58
|
+
this.logger = logger;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Retorna os tipos de componente disponíveis para geração.
|
|
63
|
+
* @returns {string[]} Lista de tipos disponíveis
|
|
64
|
+
*/
|
|
65
|
+
getAvailableTypes() {
|
|
66
|
+
return Object.keys(TEMPLATE_MAP);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Renderiza um template substituindo todos os placeholders.
|
|
71
|
+
* Utiliza replace via regex (RF02 do PRD).
|
|
72
|
+
*
|
|
73
|
+
* @param {string} templateContent - Conteúdo bruto do template
|
|
74
|
+
* @param {{ kebabName: string, PascalName: string, snake_name: string }} names - Nomes formatados
|
|
75
|
+
* @returns {string} Conteúdo final renderizado
|
|
76
|
+
*/
|
|
77
|
+
render(templateContent, names) {
|
|
78
|
+
return templateContent
|
|
79
|
+
.replace(/\{\{kebabName\}\}/g, names.kebabName)
|
|
80
|
+
.replace(/\{\{PascalName\}\}/g, names.PascalName)
|
|
81
|
+
.replace(/\{\{snake_name\}\}/g, names.snake_name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Gera um único componente: lê o template, renderiza e retorna
|
|
86
|
+
* o conteúdo com as informações de destino.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} type - Tipo do componente ('section', 'block', 'snippet', 'controller')
|
|
89
|
+
* @param {{ kebabName: string, PascalName: string, snake_name: string }} names - Nomes formatados
|
|
90
|
+
* @returns {Promise<{ content: string, outputDir: string, fileName: string }>}
|
|
91
|
+
* @throws {Error} Se o tipo não for suportado
|
|
92
|
+
*/
|
|
93
|
+
async generate(type, names) {
|
|
94
|
+
const config = TEMPLATE_MAP[type];
|
|
95
|
+
|
|
96
|
+
if (!config) {
|
|
97
|
+
throw new Error(`Tipo de componente não suportado: "${type}". Tipos válidos: ${this.getAvailableTypes().join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Lê o template do disco
|
|
101
|
+
const templatePath = path.join(TEMPLATES_DIR, config.templateFile);
|
|
102
|
+
const templateContent = await fs.readFile(templatePath, 'utf-8');
|
|
103
|
+
|
|
104
|
+
// Renderiza com as variáveis
|
|
105
|
+
const content = this.render(templateContent, names);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
content,
|
|
109
|
+
outputDir: config.outputDir,
|
|
110
|
+
fileName: config.outputFileName(names.kebabName),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Gera múltiplos componentes de uma vez.
|
|
116
|
+
*
|
|
117
|
+
* @param {string[]} types - Lista de tipos a serem gerados
|
|
118
|
+
* @param {{ kebabName: string, PascalName: string, snake_name: string }} names - Nomes formatados
|
|
119
|
+
* @returns {Promise<Array<{ content: string, outputDir: string, fileName: string }>>}
|
|
120
|
+
*/
|
|
121
|
+
async generateMultiple(types, names) {
|
|
122
|
+
const results = [];
|
|
123
|
+
|
|
124
|
+
for (const type of types) {
|
|
125
|
+
const result = await this.generate(type, names);
|
|
126
|
+
results.push(result);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return results;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{% doc %}
|
|
2
|
+
Block: {{PascalName}} Block
|
|
3
|
+
Usage: Use inside sections that support blocks.
|
|
4
|
+
{% enddoc %}
|
|
5
|
+
|
|
6
|
+
{% liquid
|
|
7
|
+
assign block_id = block.id
|
|
8
|
+
%}
|
|
9
|
+
|
|
10
|
+
<{{kebabName}}-block
|
|
11
|
+
id="block-{{ block_id }}"
|
|
12
|
+
class="{{kebabName}}-block"
|
|
13
|
+
data-block-id="{{ block_id }}"
|
|
14
|
+
{{ block.shopify_attributes }}
|
|
15
|
+
>
|
|
16
|
+
<div class="{{kebabName}}-block__inner">
|
|
17
|
+
{% if block.settings.title != blank %}
|
|
18
|
+
<h3 class="{{kebabName}}-block__title">{{ block.settings.title | escape }}</h3>
|
|
19
|
+
{% endif %}
|
|
20
|
+
</div>
|
|
21
|
+
</{{kebabName}}-block>
|
|
22
|
+
|
|
23
|
+
{% schema %}
|
|
24
|
+
{
|
|
25
|
+
"name": "{{PascalName}} Block",
|
|
26
|
+
"target": "section",
|
|
27
|
+
"settings": [
|
|
28
|
+
{
|
|
29
|
+
"type": "text",
|
|
30
|
+
"id": "title",
|
|
31
|
+
"label": "Block Title",
|
|
32
|
+
"default": "Feature"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
{% endschema %}
|
|
37
|
+
|
|
38
|
+
{% javascript %}
|
|
39
|
+
class {{PascalName}}Block extends HTMLElement {
|
|
40
|
+
connectedCallback() {
|
|
41
|
+
this.addEventListener('click', this.handleClick.bind(this));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
handleClick() {
|
|
45
|
+
console.log('Block clicked:', this.dataset.blockId);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!customElements.get('{{kebabName}}-block')) {
|
|
50
|
+
customElements.define('{{kebabName}}-block', {{PascalName}}Block);
|
|
51
|
+
}
|
|
52
|
+
{% endjavascript %}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controller: {{PascalName}}Controller
|
|
3
|
+
* Path: assets/{{kebabName}}-controller.js
|
|
4
|
+
* Description: Logic separated for reuse across multiple sections/blocks
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class {{PascalName}}Controller extends HTMLElement {
|
|
8
|
+
connectedCallback() {
|
|
9
|
+
const sectionId = this.dataset.sectionId;
|
|
10
|
+
if (!sectionId) return;
|
|
11
|
+
|
|
12
|
+
this.root = document.getElementById(`section-${sectionId}`);
|
|
13
|
+
this.bindEvents();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
bindEvents() {
|
|
17
|
+
// Add event listeners
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnectedCallback() {
|
|
21
|
+
// Remove event listeners
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!customElements.get('{{kebabName}}-controller')) {
|
|
26
|
+
customElements.define('{{kebabName}}-controller', {{PascalName}}Controller);
|
|
27
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{% doc %}
|
|
2
|
+
Section: {{PascalName}}
|
|
3
|
+
Description: Componente principal controlado via Web Component.
|
|
4
|
+
Architecture:
|
|
5
|
+
- HTML: Semântico com IDs dinâmicos
|
|
6
|
+
- JS: Web Component desacoplado
|
|
7
|
+
- CSS: Scoped ou via Assets
|
|
8
|
+
{% enddoc %}
|
|
9
|
+
|
|
10
|
+
{% liquid
|
|
11
|
+
assign section_id = section.id
|
|
12
|
+
assign container_id = 'section-' | append: section_id
|
|
13
|
+
%}
|
|
14
|
+
|
|
15
|
+
<{{kebabName}}-section
|
|
16
|
+
id="{{ container_id }}"
|
|
17
|
+
class="{{kebabName}} section-container"
|
|
18
|
+
data-section-id="{{ section_id }}"
|
|
19
|
+
>
|
|
20
|
+
<div class="{{kebabName}}__wrapper page-width">
|
|
21
|
+
{%- if section.settings.heading != blank -%}
|
|
22
|
+
<h2 class="{{kebabName}}__heading">{{ section.settings.heading | escape }}</h2>
|
|
23
|
+
{%- endif -%}
|
|
24
|
+
|
|
25
|
+
<div class="{{kebabName}}__content">
|
|
26
|
+
{% content_for 'blocks' %}
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</{{kebabName}}-section>
|
|
30
|
+
|
|
31
|
+
{% schema %}
|
|
32
|
+
{
|
|
33
|
+
"name": "{{PascalName}}",
|
|
34
|
+
"tag": "section",
|
|
35
|
+
"class": "section-{{kebabName}}",
|
|
36
|
+
"settings": [
|
|
37
|
+
{
|
|
38
|
+
"type": "text",
|
|
39
|
+
"id": "heading",
|
|
40
|
+
"label": "Heading",
|
|
41
|
+
"default": "{{PascalName}}"
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"blocks": [
|
|
45
|
+
{
|
|
46
|
+
"type": "@theme"
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"presets": [
|
|
50
|
+
{
|
|
51
|
+
"name": "{{PascalName}}"
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
{% endschema %}
|
|
56
|
+
|
|
57
|
+
{% stylesheet %}
|
|
58
|
+
/* BEM Styling */
|
|
59
|
+
.{{kebabName}} {
|
|
60
|
+
display: block;
|
|
61
|
+
position: relative;
|
|
62
|
+
padding: 2rem 0;
|
|
63
|
+
}
|
|
64
|
+
.{{kebabName}}__heading {
|
|
65
|
+
margin-bottom: 1.5rem;
|
|
66
|
+
}
|
|
67
|
+
{% endstylesheet %}
|
|
68
|
+
|
|
69
|
+
{% javascript %}
|
|
70
|
+
class {{PascalName}}Section extends HTMLElement {
|
|
71
|
+
constructor() {
|
|
72
|
+
super();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
connectedCallback() {
|
|
76
|
+
this.init();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
init() {
|
|
80
|
+
console.log('{{PascalName}} initialized', this.dataset.sectionId);
|
|
81
|
+
// Logic goes here
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
disconnectedCallback() {
|
|
85
|
+
// Cleanup events
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!customElements.get('{{kebabName}}-section')) {
|
|
90
|
+
customElements.define('{{kebabName}}-section', {{PascalName}}Section);
|
|
91
|
+
}
|
|
92
|
+
{% endjavascript %}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{% doc %}
|
|
2
|
+
Snippet: {{PascalName}}
|
|
3
|
+
Renders a reusable UI component.
|
|
4
|
+
|
|
5
|
+
@param {string} id - Unique DOM id
|
|
6
|
+
@param {string} [modifier_class] - Optional CSS class
|
|
7
|
+
@param {string} [content] - Optional inner HTML
|
|
8
|
+
|
|
9
|
+
@example
|
|
10
|
+
{% render '{{kebabName}}', id: 'my-id', content: 'Hello' %}
|
|
11
|
+
{% enddoc %}
|
|
12
|
+
|
|
13
|
+
<div
|
|
14
|
+
id="{{ id | escape }}"
|
|
15
|
+
class="{{kebabName}} {{ modifier_class }}"
|
|
16
|
+
data-ui-component="{{kebabName}}"
|
|
17
|
+
>
|
|
18
|
+
{% if content %}
|
|
19
|
+
{{ content }}
|
|
20
|
+
{% endif %}
|
|
21
|
+
</div>
|
package/src/ui/PromptUI.js
CHANGED
|
@@ -8,10 +8,63 @@
|
|
|
8
8
|
* Princípio: Aberto/Fechado (OCP) — fácil adicionar novos menus
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { select, checkbox } from '@inquirer/prompts';
|
|
11
|
+
import { select, checkbox, input } from '@inquirer/prompts';
|
|
12
12
|
|
|
13
13
|
export class PromptUI {
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Pergunta o nome do componente ao usuário.
|
|
17
|
+
* Usado quando o nome não é fornecido via argumento CLI.
|
|
18
|
+
*
|
|
19
|
+
* @returns {Promise<string>} Nome digitado pelo usuário
|
|
20
|
+
*/
|
|
21
|
+
async perguntarNomeComponente() {
|
|
22
|
+
const nome = await input({
|
|
23
|
+
message: '📝 Qual o nome do componente?',
|
|
24
|
+
validate: (value) => {
|
|
25
|
+
if (!value || value.trim() === '') {
|
|
26
|
+
return 'O nome do componente é obrigatório.';
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return nome.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Exibe menu multi-select para escolher os tipos de arquivo a serem gerados.
|
|
37
|
+
* Implementa o RF do PRD: "O que você deseja criar?"
|
|
38
|
+
*
|
|
39
|
+
* @returns {Promise<string[]>} Tipos selecionados ('section', 'block', 'snippet', 'controller')
|
|
40
|
+
*/
|
|
41
|
+
async selecionarTiposGerador() {
|
|
42
|
+
const tipos = await checkbox({
|
|
43
|
+
message: '🧱 O que você deseja criar?',
|
|
44
|
+
choices: [
|
|
45
|
+
{
|
|
46
|
+
name: '📄 Section — Cria sections/[nome].liquid',
|
|
47
|
+
value: 'section',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: '🧩 Block — Cria blocks/[nome]-block.liquid',
|
|
51
|
+
value: 'block',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: '🔗 Snippet — Cria snippets/[nome].liquid',
|
|
55
|
+
value: 'snippet',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: '⚡ Asset JS Controller — Cria assets/[nome]-controller.js',
|
|
59
|
+
value: 'controller',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
required: true,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return tipos;
|
|
66
|
+
}
|
|
67
|
+
|
|
15
68
|
/**
|
|
16
69
|
* Exibe o menu principal com as opções de instalação.
|
|
17
70
|
*
|
package/src/utils/logger.js
CHANGED
|
@@ -49,7 +49,7 @@ export class Logger {
|
|
|
49
49
|
banner() {
|
|
50
50
|
console.log('');
|
|
51
51
|
console.log(pc.bold(pc.white(' ╔══════════════════════════════╗')));
|
|
52
|
-
console.log(pc.bold(pc.white(' ║ ♟️ ROOK CLI v1.
|
|
52
|
+
console.log(pc.bold(pc.white(' ║ ♟️ ROOK CLI v1.1.1 ║')));
|
|
53
53
|
console.log(pc.bold(pc.white(' ║ Shopify Component Tool ║')));
|
|
54
54
|
console.log(pc.bold(pc.white(' ╚══════════════════════════════╝')));
|
|
55
55
|
console.log('');
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stringUtils — Utilitários puros de manipulação de strings.
|
|
3
|
+
*
|
|
4
|
+
* Responsável pelas conversões de casing necessárias
|
|
5
|
+
* no fluxo de geração de componentes (scaffold).
|
|
6
|
+
*
|
|
7
|
+
* Princípio: Responsabilidade Única (SRP) — funções puras sem side-effects
|
|
8
|
+
* Princípio: Aberto/Fechado (OCP) — novos formatadores são adicionados sem alterar existentes
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Converte qualquer input para kebab-case.
|
|
13
|
+
* Usado em: nomes de arquivo, classes CSS (BEM), tags HTML.
|
|
14
|
+
*
|
|
15
|
+
* Exemplos:
|
|
16
|
+
* "hero banner" → "hero-banner"
|
|
17
|
+
* "HeroBanner" → "hero-banner"
|
|
18
|
+
* "hero_banner" → "hero-banner"
|
|
19
|
+
* "HERO-BANNER" → "hero-banner"
|
|
20
|
+
*
|
|
21
|
+
* @param {string} input - String para converter
|
|
22
|
+
* @returns {string} String em kebab-case
|
|
23
|
+
*/
|
|
24
|
+
export function toKebabCase(input) {
|
|
25
|
+
return input
|
|
26
|
+
// Insere hífen antes de letras maiúsculas em camelCase/PascalCase
|
|
27
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
28
|
+
// Substitui espaços, underscores e múltiplos hífens por hífen único
|
|
29
|
+
.replace(/[\s_]+/g, '-')
|
|
30
|
+
// Remove múltiplos hífens consecutivos
|
|
31
|
+
.replace(/-+/g, '-')
|
|
32
|
+
// Remove hífens no início e final
|
|
33
|
+
.replace(/^-|-$/g, '')
|
|
34
|
+
.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Converte qualquer input para PascalCase.
|
|
39
|
+
* Usado em: classes JS (Web Components), labels do Schema Shopify.
|
|
40
|
+
*
|
|
41
|
+
* Exemplos:
|
|
42
|
+
* "hero banner" → "HeroBanner"
|
|
43
|
+
* "hero-banner" → "HeroBanner"
|
|
44
|
+
* "hero_banner" → "HeroBanner"
|
|
45
|
+
* "HeroBanner" → "HeroBanner"
|
|
46
|
+
*
|
|
47
|
+
* @param {string} input - String para converter
|
|
48
|
+
* @returns {string} String em PascalCase
|
|
49
|
+
*/
|
|
50
|
+
export function toPascalCase(input) {
|
|
51
|
+
return toKebabCase(input)
|
|
52
|
+
.split('-')
|
|
53
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
54
|
+
.join('');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Converte qualquer input para snake_case.
|
|
59
|
+
* Usado em: variáveis Liquid, IDs de settings.
|
|
60
|
+
*
|
|
61
|
+
* Exemplos:
|
|
62
|
+
* "hero banner" → "hero_banner"
|
|
63
|
+
* "hero-banner" → "hero_banner"
|
|
64
|
+
* "HeroBanner" → "hero_banner"
|
|
65
|
+
*
|
|
66
|
+
* @param {string} input - String para converter
|
|
67
|
+
* @returns {string} String em snake_case
|
|
68
|
+
*/
|
|
69
|
+
export function toSnakeCase(input) {
|
|
70
|
+
return toKebabCase(input).replace(/-/g, '_');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Gera todos os formatos de nome a partir de um input qualquer.
|
|
75
|
+
* Retorna um objeto padronizado usado nos templates.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} input - Nome original do componente
|
|
78
|
+
* @returns {{ kebabName: string, PascalName: string, snake_name: string }}
|
|
79
|
+
*/
|
|
80
|
+
export function generateNames(input) {
|
|
81
|
+
return {
|
|
82
|
+
kebabName: toKebabCase(input),
|
|
83
|
+
PascalName: toPascalCase(input),
|
|
84
|
+
snake_name: toSnakeCase(input),
|
|
85
|
+
};
|
|
86
|
+
}
|