rook-cli 1.0.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 ADDED
@@ -0,0 +1,71 @@
1
+ # 🚀 ROOK CLI — Shopify Component Tool
2
+
3
+ CLI para instalar componentes Shopify de um repositório centralizado no GitHub.
4
+
5
+ ## Instalação
6
+
7
+ ```bash
8
+ # Instalar dependências
9
+ npm install
10
+
11
+ # Linkar globalmente (para usar o comando "rook" no terminal)
12
+ npm link
13
+ ```
14
+
15
+ ## Uso
16
+
17
+ ```bash
18
+ # Abre o menu interativo
19
+ rook
20
+
21
+ # Comando direto
22
+ rook add
23
+ ```
24
+
25
+ Caso tenha algum repositorio privado, você pode configurar o token de acesso:
26
+
27
+ ```bash
28
+ rook config
29
+ ```
30
+
31
+ # 1. Configurar o token (uma única vez)
32
+
33
+ ```bash
34
+ node bin/rook.js config
35
+ ```
36
+
37
+ # 2. Usar normalmente — autenticação é automática
38
+
39
+ ```bash
40
+ node bin/rook.js add
41
+ ```
42
+
43
+ ## Estrutura do Projeto
44
+
45
+ ```
46
+ src/
47
+ ├── app.js # Classe principal (Composition Root)
48
+ ├── commands/
49
+ │ └── AddCommand.js # Comando "add" — orquestra o fluxo
50
+ ├── ui/
51
+ │ └── PromptUI.js # Menus interativos (Inquirer)
52
+ ├── services/
53
+ │ ├── GitHubService.js # Comunicação com GitHub API
54
+ │ └── DownloadService.js # Download via tiged
55
+ ├── filesystem/
56
+ │ ├── FileMapper.js # Mapeamento de diretórios Shopify
57
+ │ └── ConflictResolver.js # Tratamento de conflitos
58
+ ├── config/
59
+ │ └── constants.js # Constantes globais
60
+ └── utils/
61
+ └── logger.js # Logger com feedback visual
62
+ ```
63
+
64
+ ## Tecnologias
65
+
66
+ - **Node.js** (ESM) — Ambiente de execução
67
+ - **Commander** — Parser de comandos CLI
68
+ - **Inquirer** — Menus interativos
69
+ - **tiged** — Download eficiente sem `.git`
70
+ - **fs-extra** — Manipulação de arquivos
71
+ - **picocolors** — Estilo visual no terminal
package/bin/rook.js ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Ponto de entrada do CLI — ROOK CLI
5
+ *
6
+ * Este arquivo é o entry point registrado no package.json.
7
+ * Sua única responsabilidade é inicializar a aplicação.
8
+ */
9
+
10
+ import 'dotenv/config';
11
+ import { App } from '../src/app.js';
12
+
13
+ const app = new App();
14
+ app.run();
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "rook-cli",
3
+ "version": "1.0.0",
4
+ "description": "CLI para instalar componentes Shopify de um repositório centralizado",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/chesslabdev/rook-cli.git"
8
+ },
9
+ "type": "module",
10
+ "bin": {
11
+ "rook": "bin/rook.js"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src"
16
+ ],
17
+ "scripts": {
18
+ "start": "node bin/rook.js",
19
+ "test": "echo \"No tests yet\" && exit 0"
20
+ },
21
+ "keywords": [
22
+ "shopify",
23
+ "cli",
24
+ "components",
25
+ "themes"
26
+ ],
27
+ "author": "ROOK",
28
+ "license": "ISC",
29
+ "dependencies": {
30
+ "@inquirer/prompts": "^8.3.0",
31
+ "commander": "^14.0.3",
32
+ "dotenv": "^17.3.1",
33
+ "fs-extra": "^11.3.3",
34
+ "picocolors": "^1.1.1",
35
+ "tiged": "^2.12.7"
36
+ }
37
+ }
package/src/app.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * App — Classe principal da aplicação.
3
+ *
4
+ * Responsável por:
5
+ * - Registrar comandos no Commander
6
+ * - Instanciar e injetar dependências (Composition Root)
7
+ * - Iniciar o CLI
8
+ *
9
+ * Princípio: Inversão de Dependência (DIP) — atua como Composition Root
10
+ * Princípio: Aberto/Fechado (OCP) — novos comandos são adicionados sem alterar o existente
11
+ */
12
+
13
+ import { Command } from 'commander';
14
+ import { Logger } from './utils/logger.js';
15
+ import { PromptUI } from './ui/PromptUI.js';
16
+ import { TokenManager } from './auth/TokenManager.js';
17
+ import { GitHubService } from './services/GitHubService.js';
18
+ import { DownloadService } from './services/DownloadService.js';
19
+ import { FileMapper } from './filesystem/FileMapper.js';
20
+ import { ConflictResolver } from './filesystem/ConflictResolver.js';
21
+ import { AddCommand } from './commands/AddCommand.js';
22
+ import { ConfigCommand } from './commands/ConfigCommand.js';
23
+ import { CLI_NAME } from './config/constants.js';
24
+
25
+ export class App {
26
+
27
+ constructor() {
28
+ // -- Instância do Commander (parser de comandos) --
29
+ this.programa = new Command();
30
+
31
+ // -- Instanciação de dependências (Composition Root) --
32
+ this.logger = new Logger();
33
+ this.promptUI = new PromptUI();
34
+ this.tokenManager = new TokenManager(this.logger);
35
+ this.githubService = new GitHubService(this.logger, this.tokenManager);
36
+ this.downloadService = new DownloadService(this.logger, this.tokenManager);
37
+ this.conflictResolver = new ConflictResolver(this.logger);
38
+ this.fileMapper = new FileMapper(this.logger, this.conflictResolver);
39
+
40
+ // -- Comandos --
41
+ this.addCommand = new AddCommand(
42
+ this.logger,
43
+ this.promptUI,
44
+ this.githubService,
45
+ this.downloadService,
46
+ this.fileMapper
47
+ );
48
+
49
+ this.configCommand = new ConfigCommand(
50
+ this.logger,
51
+ this.tokenManager
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Configura e executa o CLI.
57
+ * Registra todos os comandos e faz o parse dos argumentos.
58
+ */
59
+ run() {
60
+ this.programa
61
+ .name(CLI_NAME)
62
+ .description('CLI para instalar componentes Shopify de um repositório centralizado')
63
+ .version('1.0.0');
64
+
65
+ // Comando: rook add
66
+ this.programa
67
+ .command('add')
68
+ .description('Adiciona kits ou componentes ao tema Shopify atual')
69
+ .action(async () => {
70
+ this.logger.banner();
71
+ await this.addCommand.executar();
72
+ });
73
+
74
+ // Comando: rook config
75
+ this.programa
76
+ .command('config')
77
+ .description('Configura o token de acesso para repositórios privados do GitHub')
78
+ .action(async () => {
79
+ this.logger.banner();
80
+ await this.configCommand.executar();
81
+ });
82
+
83
+ // Comando padrão (sem argumentos) — abre o menu interativo
84
+ this.programa
85
+ .action(async () => {
86
+ this.logger.banner();
87
+ await this.addCommand.executar();
88
+ });
89
+
90
+ // Parse dos argumentos
91
+ this.programa.parse(process.argv);
92
+ }
93
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * TokenManager — Gerenciamento do token de acesso GitHub.
3
+ *
4
+ * Armazena e recupera o Personal Access Token (PAT) do GitHub
5
+ * em um arquivo de configuração na home do usuário (~/.rookrc).
6
+ * O token NUNCA é commitado no repositório do projeto.
7
+ *
8
+ * Princípio: Responsabilidade Única (SRP) — apenas gerencia credenciais
9
+ */
10
+
11
+ import path from 'path';
12
+ import os from 'os';
13
+ import fs from 'fs-extra';
14
+
15
+ /** Caminho do arquivo de configuração (~/.rookrc) */
16
+ const CONFIG_PATH = path.join(os.homedir(), '.rookrc');
17
+
18
+ export class TokenManager {
19
+
20
+ /**
21
+ * @param {import('../utils/logger.js').Logger} logger
22
+ */
23
+ constructor(logger) {
24
+ this.logger = logger;
25
+ }
26
+
27
+ /**
28
+ * Lê a configuração salva no disco.
29
+ * @returns {Promise<Object>} Objeto de configuração
30
+ * @private
31
+ */
32
+ async _lerConfig() {
33
+ try {
34
+ if (await fs.pathExists(CONFIG_PATH)) {
35
+ const conteudo = await fs.readFile(CONFIG_PATH, 'utf-8');
36
+ return JSON.parse(conteudo);
37
+ }
38
+ } catch {
39
+ // Se o arquivo estiver corrompido, retorna vazio
40
+ this.logger.aviso('Arquivo de configuração corrompido. Recriando...');
41
+ }
42
+ return {};
43
+ }
44
+
45
+ /**
46
+ * Salva a configuração no disco.
47
+ * @param {Object} config - Objeto de configuração
48
+ * @private
49
+ */
50
+ async _salvarConfig(config) {
51
+ await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
52
+ }
53
+
54
+ /**
55
+ * Salva o token de acesso do GitHub.
56
+ * @param {string} token - Personal Access Token (PAT) do GitHub
57
+ */
58
+ async salvarToken(token) {
59
+ const config = await this._lerConfig();
60
+ config.githubToken = token;
61
+ await this._salvarConfig(config);
62
+ this.logger.sucesso(`Token salvo em: ${CONFIG_PATH}`);
63
+ }
64
+
65
+ /**
66
+ * Recupera o token de acesso do GitHub.
67
+ * @returns {Promise<string|null>} Token salvo ou null se não existir
68
+ */
69
+ async obterToken() {
70
+ // 1. Prioridade: variável de ambiente
71
+ if (process.env.GITHUB_TOKEN) {
72
+ return process.env.GITHUB_TOKEN;
73
+ }
74
+
75
+ // 2. Fallback: arquivo de configuração
76
+ const config = await this._lerConfig();
77
+ return config.githubToken || null;
78
+ }
79
+
80
+ /**
81
+ * Verifica se existe um token configurado.
82
+ * @returns {Promise<boolean>}
83
+ */
84
+ async temToken() {
85
+ const token = await this.obterToken();
86
+ return token !== null && token.length > 0;
87
+ }
88
+
89
+ /**
90
+ * Remove o token salvo.
91
+ */
92
+ async removerToken() {
93
+ const config = await this._lerConfig();
94
+ delete config.githubToken;
95
+ await this._salvarConfig(config);
96
+ this.logger.sucesso('Token removido com sucesso.');
97
+ }
98
+
99
+ /**
100
+ * Retorna os headers de autenticação para uso nas requisições.
101
+ * Se não houver token, retorna headers sem autenticação.
102
+ *
103
+ * @returns {Promise<Object>} Headers HTTP com ou sem Authorization
104
+ */
105
+ async obterHeaders() {
106
+ const headers = {
107
+ 'Accept': 'application/vnd.github.v3+json',
108
+ };
109
+
110
+ const token = await this.obterToken();
111
+
112
+ if (token) {
113
+ headers['Authorization'] = `Bearer ${token}`;
114
+ }
115
+
116
+ return headers;
117
+ }
118
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * AddCommand — Comando "add" do CLI.
3
+ *
4
+ * Orquestra o fluxo de instalação:
5
+ * 1. Exibe menu de seleção (kit ou componente)
6
+ * 2. Lista itens disponíveis via GitHub
7
+ * 3. Realiza download via tiged
8
+ * 4. Distribui arquivos nas pastas corretas
9
+ *
10
+ * Princípio: Inversão de Dependência (DIP) — todas as dependências são injetadas
11
+ * Princípio: Responsabilidade Única (SRP) — orquestra apenas o fluxo do comando "add"
12
+ */
13
+
14
+ import path from 'path';
15
+ import os from 'os';
16
+ import fs from 'fs-extra';
17
+
18
+ export class AddCommand {
19
+
20
+ /**
21
+ * @param {import('../utils/logger.js').Logger} logger
22
+ * @param {import('../ui/PromptUI.js').PromptUI} promptUI
23
+ * @param {import('../services/GitHubService.js').GitHubService} githubService
24
+ * @param {import('../services/DownloadService.js').DownloadService} downloadService
25
+ * @param {import('../filesystem/FileMapper.js').FileMapper} fileMapper
26
+ */
27
+ constructor(logger, promptUI, githubService, downloadService, fileMapper) {
28
+ this.logger = logger;
29
+ this.promptUI = promptUI;
30
+ this.githubService = githubService;
31
+ this.downloadService = downloadService;
32
+ this.fileMapper = fileMapper;
33
+ }
34
+
35
+ /**
36
+ * Executa o fluxo principal do comando "add".
37
+ * @returns {Promise<void>}
38
+ */
39
+ async executar() {
40
+ try {
41
+ // 1. Menu principal
42
+ const tipoInstalacao = await this.promptUI.menuPrincipal();
43
+
44
+ // 2. Delega para o fluxo correto
45
+ if (tipoInstalacao === 'kit') {
46
+ await this._instalarKit();
47
+ } else {
48
+ await this._instalarComponentes();
49
+ }
50
+
51
+ } catch (erro) {
52
+ // Trata cancelamento do usuário (Ctrl+C)
53
+ if (erro.name === 'ExitPromptError') {
54
+ this.logger.aviso('Operação cancelada pelo usuário.');
55
+ return;
56
+ }
57
+ this.logger.erro(`Erro durante a instalação: ${erro.message}`);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Fluxo de instalação de um kit completo.
63
+ * @private
64
+ */
65
+ async _instalarKit() {
66
+ // Lista kits disponíveis
67
+ const kits = await this.githubService.listarKits();
68
+ const kitSelecionado = await this.promptUI.selecionarKit(kits);
69
+
70
+ this.logger.destaque(`\n📥 Instalando kit: ${kitSelecionado.nome}\n`);
71
+
72
+ // Baixa e distribui
73
+ await this._baixarEDistribuir(kitSelecionado.caminho, kitSelecionado.nome);
74
+ }
75
+
76
+ /**
77
+ * Fluxo de instalação de componentes individuais.
78
+ * @private
79
+ */
80
+ async _instalarComponentes() {
81
+ // Lista componentes disponíveis
82
+ const componentes = await this.githubService.listarComponentes();
83
+ const selecionados = await this.promptUI.selecionarComponentes(componentes);
84
+
85
+ this.logger.destaque(`\n📥 Instalando ${selecionados.length} componente(s)...\n`);
86
+
87
+ // Baixa e distribui cada componente
88
+ for (const componente of selecionados) {
89
+ await this._baixarEDistribuir(componente.caminho, componente.nome);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Processo genérico: baixa do repositório e distribui nas pastas locais.
95
+ *
96
+ * @param {string} caminhoRemoto - Caminho no repositório (ex: "components/whatsapp-btn")
97
+ * @param {string} nomePacote - Nome do pacote para exibição nos logs
98
+ * @private
99
+ */
100
+ async _baixarEDistribuir(caminhoRemoto, nomePacote) {
101
+ // Cria pasta temporária para o download
102
+ const pastaTmp = path.join(os.tmpdir(), `rook-cli-${Date.now()}`);
103
+
104
+ try {
105
+ await fs.ensureDir(pastaTmp);
106
+
107
+ // Download via tiged
108
+ await this.downloadService.baixar(caminhoRemoto, pastaTmp);
109
+
110
+ // Distribui nas pastas Shopify locais
111
+ const diretorioAtual = process.cwd();
112
+ const totalCopiados = await this.fileMapper.distribuir(pastaTmp, diretorioAtual);
113
+
114
+ this.logger.sucesso(`\n🎉 "${nomePacote}" instalado! (${totalCopiados} arquivo(s) copiados)\n`);
115
+
116
+ } finally {
117
+ // Limpa pasta temporária
118
+ await fs.remove(pastaTmp);
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * ConfigCommand — Comando "config" do CLI.
3
+ *
4
+ * Permite ao usuário configurar o token de acesso
5
+ * para repositórios privados do GitHub.
6
+ *
7
+ * Princípio: Responsabilidade Única (SRP) — só gerencia configuração
8
+ * Princípio: Inversão de Dependência (DIP) — recebe dependências via construtor
9
+ */
10
+
11
+ import { password, select } from '@inquirer/prompts';
12
+
13
+ export class ConfigCommand {
14
+
15
+ /**
16
+ * @param {import('../utils/logger.js').Logger} logger
17
+ * @param {import('../auth/TokenManager.js').TokenManager} tokenManager
18
+ */
19
+ constructor(logger, tokenManager) {
20
+ this.logger = logger;
21
+ this.tokenManager = tokenManager;
22
+ }
23
+
24
+ /**
25
+ * Executa o fluxo de configuração.
26
+ * @returns {Promise<void>}
27
+ */
28
+ async executar() {
29
+ try {
30
+ const temToken = await this.tokenManager.temToken();
31
+
32
+ const acao = await select({
33
+ message: '⚙️ O que deseja configurar?',
34
+ choices: [
35
+ {
36
+ name: temToken
37
+ ? '🔑 Atualizar token do GitHub (já configurado ✔)'
38
+ : '🔑 Configurar token do GitHub (necessário para repos privados)',
39
+ value: 'token',
40
+ },
41
+ {
42
+ name: '🗑️ Remover token salvo',
43
+ value: 'remover',
44
+ disabled: !temToken ? '(nenhum token salvo)' : false,
45
+ },
46
+ {
47
+ name: '📋 Verificar status da configuração',
48
+ value: 'status',
49
+ },
50
+ ],
51
+ });
52
+
53
+ switch (acao) {
54
+ case 'token':
55
+ await this._configurarToken();
56
+ break;
57
+ case 'remover':
58
+ await this.tokenManager.removerToken();
59
+ break;
60
+ case 'status':
61
+ await this._exibirStatus();
62
+ break;
63
+ }
64
+
65
+ } catch (erro) {
66
+ if (erro.name === 'ExitPromptError') {
67
+ this.logger.aviso('Configuração cancelada.');
68
+ return;
69
+ }
70
+ this.logger.erro(`Erro na configuração: ${erro.message}`);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Solicita e salva o token do GitHub.
76
+ * @private
77
+ */
78
+ async _configurarToken() {
79
+ this.logger.info('Para acessar repositórios privados, você precisa de um Personal Access Token (PAT).');
80
+ this.logger.sutil('Crie um em: https://github.com/settings/tokens');
81
+ this.logger.sutil('Permissões necessárias: repo (acesso completo a repos privados)\n');
82
+
83
+ const token = await password({
84
+ message: '🔑 Cole seu GitHub Personal Access Token:',
85
+ mask: '•',
86
+ validate: (valor) => {
87
+ if (!valor || valor.trim().length === 0) {
88
+ return 'O token não pode ser vazio.';
89
+ }
90
+ if (valor.trim().length < 10) {
91
+ return 'Token parece inválido (muito curto).';
92
+ }
93
+ return true;
94
+ },
95
+ });
96
+
97
+ await this.tokenManager.salvarToken(token.trim());
98
+ this.logger.sucesso('Token configurado com sucesso! Agora você pode acessar repositórios privados. 🔓');
99
+ }
100
+
101
+ /**
102
+ * Exibe o status atual da configuração.
103
+ * @private
104
+ */
105
+ async _exibirStatus() {
106
+ const temToken = await this.tokenManager.temToken();
107
+
108
+ console.log('');
109
+ this.logger.destaque('📊 Status da configuração:');
110
+ console.log('');
111
+
112
+ if (temToken) {
113
+ this.logger.sucesso('Token GitHub: Configurado ✔');
114
+
115
+ // Indica a origem do token
116
+ if (process.env.GITHUB_TOKEN) {
117
+ this.logger.sutil('Origem: variável de ambiente GITHUB_TOKEN');
118
+ } else {
119
+ this.logger.sutil('Origem: arquivo ~/.rookrc');
120
+ }
121
+ } else {
122
+ this.logger.aviso('Token GitHub: Não configurado ✖');
123
+ this.logger.sutil('Execute "rook config" para configurar.');
124
+ }
125
+
126
+ console.log('');
127
+ }
128
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Constantes globais do projeto.
3
+ *
4
+ * Centraliza todas as configurações fixas do CLI,
5
+ * como URLs do repositório e diretórios válidos do Shopify.
6
+ */
7
+
8
+ /** Repositório de origem no GitHub (owner/repo) */
9
+ export const GITHUB_REPO = process.env.GITHUB_REPO;
10
+
11
+ /** URL base da API do GitHub para conteúdo do repositório */
12
+ export const GITHUB_API_BASE = `https://api.github.com/repos/${GITHUB_REPO}/contents`;
13
+
14
+ /** URL base para download via tiged */
15
+ export const TIGED_BASE = `github:${GITHUB_REPO}`;
16
+
17
+ /**
18
+ * Diretórios padrão da estrutura de um tema Shopify.
19
+ * Usados para mapear os arquivos do repositório remoto
20
+ * para as pastas corretas no tema local.
21
+ */
22
+ export const SHOPIFY_DIRS = [
23
+ 'assets',
24
+ 'blocks',
25
+ 'config',
26
+ 'layout',
27
+ 'locales',
28
+ 'sections',
29
+ 'snippets',
30
+ 'templates',
31
+ ];
32
+
33
+ /** Pastas raiz do repositório remoto */
34
+ export const REMOTE_PATHS = {
35
+ KITS: 'kits',
36
+ COMPONENTS: 'components',
37
+ };
38
+
39
+ /** Nome do CLI exibido nos logs */
40
+ export const CLI_NAME = 'rook';
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ConflictResolver — Tratamento de conflitos de arquivos.
3
+ *
4
+ * Quando um arquivo de destino já existe, este módulo
5
+ * solicita confirmação do usuário antes de sobrescrever.
6
+ * Implementa o RF04 do PRD.
7
+ *
8
+ * Princípio: Responsabilidade Única (SRP) — só trata conflitos
9
+ */
10
+
11
+ import { confirm } from '@inquirer/prompts';
12
+
13
+ export class ConflictResolver {
14
+
15
+ /**
16
+ * @param {import('../utils/logger.js').Logger} logger
17
+ */
18
+ constructor(logger) {
19
+ this.logger = logger;
20
+
21
+ /**
22
+ * Cache de decisão do usuário para "aplicar a todos".
23
+ * null = não decidido, true = sobrescrever tudo, false = pular tudo
24
+ * @type {boolean|null}
25
+ */
26
+ this._decisaoGlobal = null;
27
+ }
28
+
29
+ /**
30
+ * Pergunta ao usuário se deseja sobrescrever o arquivo existente.
31
+ *
32
+ * @param {string} nomeArquivo - Nome do arquivo em conflito
33
+ * @returns {Promise<boolean>} true para sobrescrever, false para pular
34
+ */
35
+ async resolver(nomeArquivo) {
36
+ // Se já tem uma decisão global, usa ela
37
+ if (this._decisaoGlobal !== null) {
38
+ if (!this._decisaoGlobal) {
39
+ this.logger.aviso(` ⏩ Pulando: ${nomeArquivo} (decisão global)`);
40
+ }
41
+ return this._decisaoGlobal;
42
+ }
43
+
44
+ this.logger.aviso(`Arquivo já existe: ${nomeArquivo}`);
45
+
46
+ const sobrescrever = await confirm({
47
+ message: `Sobrescrever "${nomeArquivo}"?`,
48
+ default: false,
49
+ });
50
+
51
+ return sobrescrever;
52
+ }
53
+
54
+ /**
55
+ * Define uma decisão global para todos os conflitos restantes.
56
+ * Útil para opção "sobrescrever todos" ou "pular todos".
57
+ *
58
+ * @param {boolean} decisao - true para sobrescrever, false para pular
59
+ */
60
+ definirDecisaoGlobal(decisao) {
61
+ this._decisaoGlobal = decisao;
62
+ }
63
+
64
+ /**
65
+ * Reseta a decisão global (para novo ciclo de instalação).
66
+ */
67
+ resetar() {
68
+ this._decisaoGlobal = null;
69
+ }
70
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * FileMapper — Mapeamento e distribuição de arquivos.
3
+ *
4
+ * Responsável por identificar a estrutura de arquivos baixados
5
+ * e distribuí-los nas pastas corretas do tema Shopify local,
6
+ * conforme o padrão de diretórios definido no RF02.
7
+ *
8
+ * Princípio: Responsabilidade Única (SRP) — só mapeia e move
9
+ * Princípio: Inversão de Dependência (DIP) — recebe dependências via construtor
10
+ */
11
+
12
+ import path from 'path';
13
+ import fs from 'fs-extra';
14
+ import { SHOPIFY_DIRS } from '../config/constants.js';
15
+
16
+ export class FileMapper {
17
+
18
+ /**
19
+ * @param {import('../utils/logger.js').Logger} logger
20
+ * @param {import('./ConflictResolver.js').ConflictResolver} conflictResolver
21
+ */
22
+ constructor(logger, conflictResolver) {
23
+ this.logger = logger;
24
+ this.conflictResolver = conflictResolver;
25
+ }
26
+
27
+ /**
28
+ * Distribui os arquivos de uma pasta temporária para o diretório
29
+ * de trabalho atual, respeitando a estrutura Shopify.
30
+ *
31
+ * @param {string} pastaOrigem - Caminho da pasta temporária com os arquivos baixados
32
+ * @param {string} pastaDestino - Diretório raiz do tema Shopify local (cwd)
33
+ * @returns {Promise<number>} Número de arquivos copiados
34
+ */
35
+ async distribuir(pastaOrigem, pastaDestino) {
36
+ let totalCopiados = 0;
37
+
38
+ // Itera sobre cada diretório padrão do Shopify
39
+ for (const dir of SHOPIFY_DIRS) {
40
+ const origemDir = path.join(pastaOrigem, dir);
41
+
42
+ // Verifica se o diretório existe no conteúdo baixado
43
+ if (!await fs.pathExists(origemDir)) {
44
+ continue;
45
+ }
46
+
47
+ const destinoDir = path.join(pastaDestino, dir);
48
+
49
+ // Garante que o diretório de destino existe
50
+ await fs.ensureDir(destinoDir);
51
+
52
+ // Lista os arquivos dentro do diretório
53
+ const arquivos = await this._listarArquivosRecursivo(origemDir);
54
+
55
+ for (const arquivoRelativo of arquivos) {
56
+ const arquivoOrigem = path.join(origemDir, arquivoRelativo);
57
+ const arquivoDestino = path.join(destinoDir, arquivoRelativo);
58
+
59
+ // Verifica conflito antes de copiar
60
+ const deveCopiar = await this._verificarConflito(arquivoDestino, arquivoRelativo);
61
+
62
+ if (deveCopiar) {
63
+ await fs.ensureDir(path.dirname(arquivoDestino));
64
+ await fs.copy(arquivoOrigem, arquivoDestino);
65
+ this.logger.sucesso(` → ${dir}/${arquivoRelativo}`);
66
+ totalCopiados++;
67
+ }
68
+ }
69
+ }
70
+
71
+ return totalCopiados;
72
+ }
73
+
74
+ /**
75
+ * Lista todos os arquivos de um diretório de forma recursiva.
76
+ * Retorna caminhos relativos ao diretório base.
77
+ *
78
+ * @param {string} diretorio - Caminho absoluto do diretório
79
+ * @param {string} [base=''] - Caminho base para relativizar
80
+ * @returns {Promise<string[]>} Lista de caminhos relativos dos arquivos
81
+ * @private
82
+ */
83
+ async _listarArquivosRecursivo(diretorio, base = '') {
84
+ const itens = await fs.readdir(diretorio, { withFileTypes: true });
85
+ let arquivos = [];
86
+
87
+ for (const item of itens) {
88
+ const caminhoRelativo = base ? path.join(base, item.name) : item.name;
89
+
90
+ if (item.isDirectory()) {
91
+ // Recursa em subdiretórios
92
+ const subArquivos = await this._listarArquivosRecursivo(
93
+ path.join(diretorio, item.name),
94
+ caminhoRelativo
95
+ );
96
+ arquivos = arquivos.concat(subArquivos);
97
+ } else {
98
+ arquivos.push(caminhoRelativo);
99
+ }
100
+ }
101
+
102
+ return arquivos;
103
+ }
104
+
105
+ /**
106
+ * Verifica se existe conflito e delega ao ConflictResolver.
107
+ *
108
+ * @param {string} caminhoDestino - Caminho absoluto do arquivo de destino
109
+ * @param {string} nomeArquivo - Nome do arquivo para exibição
110
+ * @returns {Promise<boolean>} true se deve copiar, false se cancelado
111
+ * @private
112
+ */
113
+ async _verificarConflito(caminhoDestino, nomeArquivo) {
114
+ const existe = await fs.pathExists(caminhoDestino);
115
+
116
+ if (!existe) {
117
+ return true;
118
+ }
119
+
120
+ // Delega ao ConflictResolver (RF04)
121
+ return this.conflictResolver.resolver(nomeArquivo);
122
+ }
123
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * DownloadService — Motor de download de componentes.
3
+ *
4
+ * Suporta dois modos de download:
5
+ * - Repositórios públicos: usa tiged (rápido, sem .git)
6
+ * - Repositórios privados: usa GitHub API (tarball autenticado)
7
+ *
8
+ * Princípio: Responsabilidade Única (SRP) — só faz download
9
+ * Princípio: Inversão de Dependência (DIP) — recebe dependências via construtor
10
+ */
11
+
12
+ import tiged from 'tiged';
13
+ import path from 'path';
14
+ import fs from 'fs-extra';
15
+ import { pipeline } from 'stream/promises';
16
+ import { createWriteStream } from 'fs';
17
+ import { execSync } from 'child_process';
18
+ import { TIGED_BASE, GITHUB_REPO } from '../config/constants.js';
19
+
20
+ export class DownloadService {
21
+
22
+ /**
23
+ * @param {import('../utils/logger.js').Logger} logger - Instância do logger
24
+ * @param {import('../auth/TokenManager.js').TokenManager} tokenManager - Gerenciador de tokens
25
+ */
26
+ constructor(logger, tokenManager) {
27
+ this.logger = logger;
28
+ this.tokenManager = tokenManager;
29
+ }
30
+
31
+ /**
32
+ * Realiza o download de um subdiretório do repositório.
33
+ * Escolhe automaticamente o método baseado na disponibilidade do token.
34
+ *
35
+ * @param {string} caminhoRemoto - Subcaminho no repositório (ex: "kits/base")
36
+ * @param {string} destinoLocal - Caminho absoluto para a pasta de destino
37
+ * @returns {Promise<void>}
38
+ */
39
+ async baixar(caminhoRemoto, destinoLocal) {
40
+ const temToken = await this.tokenManager.temToken();
41
+
42
+ if (temToken) {
43
+ // Repositório privado: download via GitHub API
44
+ await this._baixarViaAPI(caminhoRemoto, destinoLocal);
45
+ } else {
46
+ // Repositório público: download via tiged (mais rápido)
47
+ await this._baixarViaTiged(caminhoRemoto, destinoLocal);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Download via GitHub API — funciona com repos privados.
53
+ * Baixa o tarball do repositório e extrai apenas o subdiretório desejado.
54
+ *
55
+ * @param {string} caminhoRemoto - Subcaminho no repositório
56
+ * @param {string} destinoLocal - Pasta de destino
57
+ * @private
58
+ */
59
+ async _baixarViaAPI(caminhoRemoto, destinoLocal) {
60
+ this.logger.info(`Baixando (modo autenticado): ${caminhoRemoto}...`);
61
+
62
+ const headers = await this.tokenManager.obterHeaders();
63
+ const tarballUrl = `https://api.github.com/repos/${GITHUB_REPO}/tarball`;
64
+
65
+ try {
66
+ const resposta = await fetch(tarballUrl, {
67
+ headers,
68
+ redirect: 'follow',
69
+ });
70
+
71
+ if (!resposta.ok) {
72
+ throw new Error(`Erro ao baixar tarball: HTTP ${resposta.status}`);
73
+ }
74
+
75
+ // Salva o tarball em arquivo temporário
76
+ const tarballPath = path.join(destinoLocal, '..', `rook-download-${Date.now()}.tar.gz`);
77
+ const extractDir = path.join(destinoLocal, '..', `rook-extract-${Date.now()}`);
78
+
79
+ await fs.ensureDir(extractDir);
80
+
81
+ // Escreve o tarball no disco
82
+ const fileStream = createWriteStream(tarballPath);
83
+ await pipeline(resposta.body, fileStream);
84
+
85
+ // Extrai o tarball
86
+ execSync(`tar -xzf "${tarballPath}" -C "${extractDir}"`, { stdio: 'pipe' });
87
+
88
+ // O tarball do GitHub cria uma pasta com o nome do repo + hash
89
+ // Precisamos encontrar essa pasta raiz
90
+ const extraidos = await fs.readdir(extractDir);
91
+ const pastaRaiz = path.join(extractDir, extraidos[0]);
92
+
93
+ // Copia apenas o subdiretório desejado para o destino
94
+ const pastaDesejada = path.join(pastaRaiz, caminhoRemoto);
95
+
96
+ if (!await fs.pathExists(pastaDesejada)) {
97
+ throw new Error(`Caminho "${caminhoRemoto}" não encontrado no repositório.`);
98
+ }
99
+
100
+ await fs.copy(pastaDesejada, destinoLocal);
101
+
102
+ // Limpa arquivos temporários
103
+ await fs.remove(tarballPath);
104
+ await fs.remove(extractDir);
105
+
106
+ this.logger.sucesso(`Download concluído: ${caminhoRemoto}`);
107
+
108
+ } catch (erro) {
109
+ this.logger.erro(`Falha no download de "${caminhoRemoto}": ${erro.message}`);
110
+ throw erro;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Download via tiged — funciona apenas com repos públicos.
116
+ * Mais rápido pois baixa diretamente o subdiretório.
117
+ *
118
+ * @param {string} caminhoRemoto - Subcaminho no repositório
119
+ * @param {string} destinoLocal - Pasta de destino
120
+ * @private
121
+ */
122
+ async _baixarViaTiged(caminhoRemoto, destinoLocal) {
123
+ const origem = `${TIGED_BASE}/${caminhoRemoto}`;
124
+
125
+ this.logger.info(`Baixando: ${caminhoRemoto}...`);
126
+
127
+ try {
128
+ const emitter = tiged(origem, {
129
+ disableCache: true,
130
+ force: true,
131
+ verbose: false,
132
+ });
133
+
134
+ // Escuta eventos do tiged para feedback
135
+ emitter.on('info', (info) => {
136
+ this.logger.sutil(info.message);
137
+ });
138
+
139
+ emitter.on('warn', (warning) => {
140
+ this.logger.aviso(warning.message);
141
+ });
142
+
143
+ await emitter.clone(destinoLocal);
144
+
145
+ this.logger.sucesso(`Download concluído: ${caminhoRemoto}`);
146
+ } catch (erro) {
147
+ this.logger.erro(`Falha no download de "${caminhoRemoto}": ${erro.message}`);
148
+ throw erro;
149
+ }
150
+ }
151
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * GitHubService — Comunicação com a API do GitHub.
3
+ *
4
+ * Responsável por listar kits e componentes disponíveis
5
+ * no repositório remoto. Suporta repositórios públicos e privados
6
+ * através do TokenManager para autenticação.
7
+ *
8
+ * Princípio: Responsabilidade Única (SRP)
9
+ * Princípio: Inversão de Dependência (DIP) — recebe logger e tokenManager via construtor
10
+ */
11
+
12
+ import { GITHUB_API_BASE, REMOTE_PATHS } from '../config/constants.js';
13
+
14
+ export class GitHubService {
15
+
16
+ /**
17
+ * @param {import('../utils/logger.js').Logger} logger - Instância do logger
18
+ * @param {import('../auth/TokenManager.js').TokenManager} tokenManager - Gerenciador de tokens
19
+ */
20
+ constructor(logger, tokenManager) {
21
+ this.logger = logger;
22
+ this.tokenManager = tokenManager;
23
+ }
24
+
25
+ /**
26
+ * Busca o conteúdo de um diretório no repositório GitHub.
27
+ * Usa autenticação se houver token configurado.
28
+ *
29
+ * @param {string} caminho - Caminho relativo no repositório
30
+ * @returns {Promise<Array>} Lista de itens no diretório
31
+ */
32
+ async listarDiretorio(caminho) {
33
+ const url = `${GITHUB_API_BASE}/${caminho}`;
34
+ const headers = await this.tokenManager.obterHeaders();
35
+
36
+ try {
37
+ const resposta = await fetch(url, { headers });
38
+
39
+ if (resposta.status === 401 || resposta.status === 403) {
40
+ throw new Error(
41
+ 'Acesso negado. Verifique se o token está correto.\n' +
42
+ ' Execute "rook config" para reconfigurar.'
43
+ );
44
+ }
45
+
46
+ if (resposta.status === 404) {
47
+ const temToken = await this.tokenManager.temToken();
48
+ const dica = temToken
49
+ ? 'Verifique se o caminho existe no repositório.'
50
+ : 'Se o repositório é privado, execute "rook config" para configurar o token.';
51
+ throw new Error(`Repositório ou caminho não encontrado. ${dica}`);
52
+ }
53
+
54
+ if (!resposta.ok) {
55
+ throw new Error(`Erro HTTP ${resposta.status}: ${resposta.statusText}`);
56
+ }
57
+
58
+ const dados = await resposta.json();
59
+ return dados;
60
+ } catch (erro) {
61
+ this.logger.erro(`Falha ao acessar o repositório: ${erro.message}`);
62
+ throw erro;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Lista os kits disponíveis no repositório.
68
+ * @returns {Promise<Array<{nome: string, caminho: string}>>} Lista de kits
69
+ */
70
+ async listarKits() {
71
+ this.logger.info('Buscando kits disponíveis...');
72
+
73
+ const itens = await this.listarDiretorio(REMOTE_PATHS.KITS);
74
+
75
+ return itens
76
+ .filter(item => item.type === 'dir')
77
+ .map(item => ({
78
+ nome: item.name,
79
+ caminho: `${REMOTE_PATHS.KITS}/${item.name}`,
80
+ }));
81
+ }
82
+
83
+ /**
84
+ * Lista os componentes individuais disponíveis no repositório.
85
+ * @returns {Promise<Array<{nome: string, caminho: string}>>} Lista de componentes
86
+ */
87
+ async listarComponentes() {
88
+ this.logger.info('Buscando componentes disponíveis...');
89
+
90
+ const itens = await this.listarDiretorio(REMOTE_PATHS.COMPONENTS);
91
+
92
+ return itens
93
+ .filter(item => item.type === 'dir')
94
+ .map(item => ({
95
+ nome: item.name,
96
+ caminho: `${REMOTE_PATHS.COMPONENTS}/${item.name}`,
97
+ }));
98
+ }
99
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * PromptUI — Interface interativa do terminal.
3
+ *
4
+ * Gerencia todos os menus e prompts de seleção
5
+ * apresentados ao usuário via @inquirer/prompts.
6
+ *
7
+ * Princípio: Responsabilidade Única (SRP) — lida apenas com UI
8
+ * Princípio: Aberto/Fechado (OCP) — fácil adicionar novos menus
9
+ */
10
+
11
+ import { select, checkbox } from '@inquirer/prompts';
12
+
13
+ export class PromptUI {
14
+
15
+ /**
16
+ * Exibe o menu principal com as opções de instalação.
17
+ *
18
+ * @returns {Promise<string>} Opção selecionada ('kit' ou 'componente')
19
+ */
20
+ async menuPrincipal() {
21
+ const escolha = await select({
22
+ message: 'O que você deseja instalar?',
23
+ choices: [
24
+ {
25
+ name: '🎯 Kit Base — Estrutura completa para iniciar projetos',
26
+ value: 'kit',
27
+ description: 'Instala todos os arquivos base de um kit',
28
+ },
29
+ {
30
+ name: '🧩 Componente Individual — Selecione itens específicos',
31
+ value: 'componente',
32
+ description: 'Escolha componentes como botões, menus, etc.',
33
+ },
34
+ ],
35
+ });
36
+
37
+ return escolha;
38
+ }
39
+
40
+ /**
41
+ * Exibe a lista de kits disponíveis para seleção.
42
+ *
43
+ * @param {Array<{nome: string, caminho: string}>} kits - Kits disponíveis
44
+ * @returns {Promise<{nome: string, caminho: string}>} Kit selecionado
45
+ */
46
+ async selecionarKit(kits) {
47
+ if (kits.length === 0) {
48
+ throw new Error('Nenhum kit disponível no repositório.');
49
+ }
50
+
51
+ const caminho = await select({
52
+ message: '🎯 Selecione o kit para instalar:',
53
+ choices: kits.map(kit => ({
54
+ name: `📁 ${kit.nome}`,
55
+ value: kit.caminho,
56
+ })),
57
+ });
58
+
59
+ return kits.find(k => k.caminho === caminho);
60
+ }
61
+
62
+ /**
63
+ * Exibe a lista de componentes para seleção múltipla (checkbox).
64
+ *
65
+ * @param {Array<{nome: string, caminho: string}>} componentes - Componentes disponíveis
66
+ * @returns {Promise<Array<{nome: string, caminho: string}>>} Componentes selecionados
67
+ */
68
+ async selecionarComponentes(componentes) {
69
+ if (componentes.length === 0) {
70
+ throw new Error('Nenhum componente disponível no repositório.');
71
+ }
72
+
73
+ const selecionados = await checkbox({
74
+ message: '🧩 Selecione os componentes desejados (espaço para marcar):',
75
+ choices: componentes.map(comp => ({
76
+ name: `📦 ${comp.nome}`,
77
+ value: comp.caminho,
78
+ })),
79
+ required: true,
80
+ });
81
+
82
+ return componentes.filter(c => selecionados.includes(c.caminho));
83
+ }
84
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Logger — Responsável pela saída visual no terminal.
3
+ *
4
+ * Encapsula o uso do picocolors para fornecer
5
+ * métodos semânticos de log (sucesso, erro, info, etc).
6
+ *
7
+ * Princípio: Responsabilidade Única (SRP)
8
+ */
9
+
10
+ import pc from 'picocolors';
11
+
12
+ export class Logger {
13
+
14
+ /**
15
+ * Exibe uma mensagem de informação (azul).
16
+ * @param {string} mensagem - Texto a ser exibido
17
+ */
18
+ info(mensagem) {
19
+ console.log(pc.cyan(`ℹ ${mensagem}`));
20
+ }
21
+
22
+ /**
23
+ * Exibe uma mensagem de sucesso (verde).
24
+ * @param {string} mensagem - Texto a ser exibido
25
+ */
26
+ sucesso(mensagem) {
27
+ console.log(pc.green(`✔ ${mensagem}`));
28
+ }
29
+
30
+ /**
31
+ * Exibe uma mensagem de aviso (amarelo).
32
+ * @param {string} mensagem - Texto a ser exibido
33
+ */
34
+ aviso(mensagem) {
35
+ console.log(pc.yellow(`⚠ ${mensagem}`));
36
+ }
37
+
38
+ /**
39
+ * Exibe uma mensagem de erro (vermelho).
40
+ * @param {string} mensagem - Texto a ser exibido
41
+ */
42
+ erro(mensagem) {
43
+ console.error(pc.red(`✖ ${mensagem}`));
44
+ }
45
+
46
+ /**
47
+ * Exibe o banner/header do CLI.
48
+ */
49
+ banner() {
50
+ console.log('');
51
+ console.log(pc.bold(pc.magenta(' ╔══════════════════════════════╗')));
52
+ console.log(pc.bold(pc.magenta(' ║ 🚀 ROOK CLI v1.0.0 ║')));
53
+ console.log(pc.bold(pc.magenta(' ║ Shopify Component Tool ║')));
54
+ console.log(pc.bold(pc.magenta(' ╚══════════════════════════════╝')));
55
+ console.log('');
56
+ }
57
+
58
+ /**
59
+ * Exibe texto em destaque (negrito).
60
+ * @param {string} mensagem - Texto a ser exibido
61
+ */
62
+ destaque(mensagem) {
63
+ console.log(pc.bold(mensagem));
64
+ }
65
+
66
+ /**
67
+ * Exibe texto esmaecido (para detalhes secundários).
68
+ * @param {string} mensagem - Texto a ser exibido
69
+ */
70
+ sutil(mensagem) {
71
+ console.log(pc.dim(` ${mensagem}`));
72
+ }
73
+ }