@guilhermefsousa/open-spec-kit 0.1.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 +57 -0
- package/bin/open-spec-kit.js +39 -0
- package/package.json +51 -0
- package/src/commands/doctor.js +324 -0
- package/src/commands/init.js +981 -0
- package/src/commands/update.js +168 -0
- package/src/commands/validate.js +599 -0
- package/src/parsers/markdown-sections.js +271 -0
- package/src/schemas/projects.schema.js +111 -0
- package/src/schemas/spec.schema.js +643 -0
- package/templates/agents/agents/spec-hub.agent.md +99 -0
- package/templates/agents/rules/hub_structure.instructions.md +49 -0
- package/templates/agents/rules/ownership.instructions.md +138 -0
- package/templates/agents/scripts/notify-gchat.ps1 +99 -0
- package/templates/agents/scripts/notify-gchat.sh +131 -0
- package/templates/agents/skills/dev-orchestrator/SKILL.md +573 -0
- package/templates/agents/skills/discovery/SKILL.md +406 -0
- package/templates/agents/skills/setup-project/SKILL.md +452 -0
- package/templates/agents/skills/specifying-features/SKILL.md +378 -0
- package/templates/github/agents/spec-hub.agent.md +75 -0
- package/templates/github/copilot-instructions.md +102 -0
- package/templates/github/instructions/hub_structure.instructions.md +33 -0
- package/templates/github/instructions/ownership.instructions.md +45 -0
- package/templates/github/prompts/dev.prompt.md +19 -0
- package/templates/github/prompts/discovery.prompt.md +20 -0
- package/templates/github/prompts/nova-feature.prompt.md +19 -0
- package/templates/github/prompts/setup.prompt.md +18 -0
- package/templates/github/skills/dev-orchestrator/SKILL.md +9 -0
- package/templates/github/skills/discovery/SKILL.md +9 -0
- package/templates/github/skills/setup-project/SKILL.md +9 -0
- package/templates/github/skills/specifying-features/SKILL.md +9 -0
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { writeFile, mkdir, access, cp } from 'fs/promises';
|
|
5
|
+
import { join, dirname } from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import https from 'https';
|
|
9
|
+
import http from 'http';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const TEMPLATES_DIR = join(__dirname, '..', '..', 'templates');
|
|
13
|
+
|
|
14
|
+
const AGENTS = {
|
|
15
|
+
claude: { name: 'Claude Code' },
|
|
16
|
+
copilot: { name: 'GitHub Copilot' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const FIGMA_MCP_PACKAGE = 'figma-developer-mcp';
|
|
20
|
+
|
|
21
|
+
const PRESETS = {
|
|
22
|
+
lean: {
|
|
23
|
+
name: 'Lean',
|
|
24
|
+
description: 'Bugfix ou feature trivial (brief + tasks)',
|
|
25
|
+
artifacts: ['brief.md', 'tasks.md'],
|
|
26
|
+
},
|
|
27
|
+
standard: {
|
|
28
|
+
name: 'Standard',
|
|
29
|
+
description: 'Feature média (brief + scenarios + contracts + tasks + links)',
|
|
30
|
+
artifacts: ['brief.md', 'scenarios.md', 'contracts.md', 'tasks.md', 'links.md'],
|
|
31
|
+
},
|
|
32
|
+
enterprise: {
|
|
33
|
+
name: 'Enterprise',
|
|
34
|
+
description: 'Feature crítica (standard + ADR + security review + runbook)',
|
|
35
|
+
artifacts: ['brief.md', 'scenarios.md', 'contracts.md', 'tasks.md', 'links.md'],
|
|
36
|
+
extras: ['adr', 'security-review', 'runbook'],
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const KNOWN_TECH_KEYWORDS = [
|
|
41
|
+
'.NET', 'Node.js', 'React', 'PostgreSQL', 'RabbitMQ', 'Kafka', 'Docker',
|
|
42
|
+
'Redis', 'TypeScript', 'Java', 'Kotlin', 'Python', 'Spring Boot', 'Angular',
|
|
43
|
+
'Vue', 'MongoDB', 'MySQL', 'xUnit', 'Jest', 'MassTransit', 'C#', 'Go',
|
|
44
|
+
'Rust', 'PHP', 'Laravel', 'Django', 'Flask', 'Express', 'NestJS', 'Next.js',
|
|
45
|
+
'Nuxt', 'Svelte', 'Terraform', 'Kubernetes', 'AWS', 'Azure', 'GCP',
|
|
46
|
+
'SQL Server', 'Oracle', 'Elasticsearch', 'GraphQL', 'gRPC', 'REST',
|
|
47
|
+
'SQS', 'SNS', 'DynamoDB', 'Cassandra', 'Nginx', 'Apache',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// ──────────────────────────────────────────────────────
|
|
51
|
+
// HTTP helper using Node.js built-in modules
|
|
52
|
+
// ──────────────────────────────────────────────────────
|
|
53
|
+
const SSL_ERROR_CODES = new Set([
|
|
54
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
55
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
56
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
57
|
+
'CERT_HAS_EXPIRED',
|
|
58
|
+
'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
59
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
function isSslError(err) {
|
|
63
|
+
return err.status === 0 && (
|
|
64
|
+
SSL_ERROR_CODES.has(err.code) ||
|
|
65
|
+
SSL_ERROR_CODES.has(err.message) ||
|
|
66
|
+
(err.message && SSL_ERROR_CODES.has(err.message.split(':').pop()?.trim()))
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function confluenceRequest(baseUrl, path, user, token, timeoutMs = 15000, allowInsecure = false) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
try {
|
|
73
|
+
const fullUrl = new URL(baseUrl.replace(/\/$/, '') + path);
|
|
74
|
+
const mod = fullUrl.protocol === 'https:' ? https : http;
|
|
75
|
+
const auth = Buffer.from(`${user}:${token}`).toString('base64');
|
|
76
|
+
const options = {
|
|
77
|
+
headers: {
|
|
78
|
+
'Authorization': `Basic ${auth}`,
|
|
79
|
+
'Accept': 'application/json',
|
|
80
|
+
},
|
|
81
|
+
timeout: timeoutMs,
|
|
82
|
+
...(allowInsecure && fullUrl.protocol === 'https:'
|
|
83
|
+
? { agent: new https.Agent({ rejectUnauthorized: false }) }
|
|
84
|
+
: {}),
|
|
85
|
+
};
|
|
86
|
+
const req = mod.get(fullUrl, options, (res) => {
|
|
87
|
+
let data = '';
|
|
88
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
89
|
+
res.on('end', () => {
|
|
90
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
91
|
+
try {
|
|
92
|
+
resolve({ status: res.statusCode, data: JSON.parse(data) });
|
|
93
|
+
} catch {
|
|
94
|
+
resolve({ status: res.statusCode, data });
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
reject({ status: res.statusCode, message: `HTTP ${res.statusCode}` });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
req.on('error', (err) => reject({ status: 0, message: err.message, code: err.code }));
|
|
102
|
+
req.on('timeout', () => {
|
|
103
|
+
req.destroy();
|
|
104
|
+
reject({ status: 0, message: 'Timeout (15s)' });
|
|
105
|
+
});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
reject({ status: 0, message: err.message, code: err.code });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stripHtml(html) {
|
|
113
|
+
if (!html) return '';
|
|
114
|
+
return html
|
|
115
|
+
.replace(/<[^>]+>/g, ' ')
|
|
116
|
+
.replace(/ /g, ' ')
|
|
117
|
+
.replace(/&/g, '&')
|
|
118
|
+
.replace(/</g, '<')
|
|
119
|
+
.replace(/>/g, '>')
|
|
120
|
+
.replace(/"/g, '"')
|
|
121
|
+
.replace(/'/g, "'")
|
|
122
|
+
.replace(/\s+/g, ' ')
|
|
123
|
+
.trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function detectTechInText(text) {
|
|
127
|
+
if (!text) return [];
|
|
128
|
+
const found = new Set();
|
|
129
|
+
for (const tech of KNOWN_TECH_KEYWORDS) {
|
|
130
|
+
const escaped = tech.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
131
|
+
const regex = new RegExp('\\b' + escaped + '\\b', 'i');
|
|
132
|
+
if (regex.test(text)) {
|
|
133
|
+
found.add(tech);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const tablePatterns = /(?:tecnologia|stack|framework|linguagem|banco|database|message.?broker|cache|container)[\s:|\\-]+([^\n|]+)/gi;
|
|
137
|
+
let match;
|
|
138
|
+
while ((match = tablePatterns.exec(text)) !== null) {
|
|
139
|
+
const value = match[1].trim();
|
|
140
|
+
for (const tech of KNOWN_TECH_KEYWORDS) {
|
|
141
|
+
if (value.toLowerCase().includes(tech.toLowerCase())) {
|
|
142
|
+
found.add(tech);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return [...found];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function detectRepoNames(text) {
|
|
150
|
+
if (!text) return [];
|
|
151
|
+
const repos = new Set();
|
|
152
|
+
const repoPatterns = /(?:reposit[óo]rio|repo|github|gitlab)[:\s]+([a-zA-Z][\w.-]+(?:\/[\w.-]+)?)/gi;
|
|
153
|
+
let match;
|
|
154
|
+
while ((match = repoPatterns.exec(text)) !== null) {
|
|
155
|
+
repos.add(match[1].trim());
|
|
156
|
+
}
|
|
157
|
+
return [...repos];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function deriveSigla(projectName) {
|
|
161
|
+
if (!projectName) return '';
|
|
162
|
+
const words = projectName.trim().split(/\s+/).filter(w => w.length > 0);
|
|
163
|
+
const skip = new Set(['de', 'do', 'da', 'dos', 'das', 'e', 'o', 'a', 'os', 'as', 'em', 'no', 'na', 'para', 'com']);
|
|
164
|
+
const letters = words
|
|
165
|
+
.filter(w => !skip.has(w.toLowerCase()))
|
|
166
|
+
.map(w => w[0].toUpperCase())
|
|
167
|
+
.join('');
|
|
168
|
+
return letters.slice(0, 4) || projectName.slice(0, 3).toUpperCase();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ──────────────────────────────────────────────────────
|
|
172
|
+
// Auto-detection from Confluence
|
|
173
|
+
// ──────────────────────────────────────────────────────
|
|
174
|
+
async function autoDetectFromConfluence(confluenceUrl, confluenceRef, user, token, spinner, allowInsecure = false) {
|
|
175
|
+
const result = {
|
|
176
|
+
projectName: null,
|
|
177
|
+
stack: [],
|
|
178
|
+
repos: [],
|
|
179
|
+
spaceKey: null,
|
|
180
|
+
success: false,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
spinner.text = 'Lendo página raiz do Confluence...';
|
|
185
|
+
const rootPage = await confluenceRequest(
|
|
186
|
+
confluenceUrl,
|
|
187
|
+
`/rest/api/content/${confluenceRef}?expand=body.storage,space`,
|
|
188
|
+
user, token, 15000, allowInsecure
|
|
189
|
+
);
|
|
190
|
+
result.projectName = rootPage.data.title || null;
|
|
191
|
+
if (rootPage.data.space && rootPage.data.space.key) {
|
|
192
|
+
result.spaceKey = rootPage.data.space.key;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let allText = '';
|
|
196
|
+
if (rootPage.data.body && rootPage.data.body.storage) {
|
|
197
|
+
allText += stripHtml(rootPage.data.body.storage.value) + ' ';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
spinner.text = 'Buscando páginas filhas...';
|
|
201
|
+
const children = await confluenceRequest(
|
|
202
|
+
confluenceUrl,
|
|
203
|
+
`/rest/api/content/${confluenceRef}/child/page?limit=50`,
|
|
204
|
+
user, token, 15000, allowInsecure
|
|
205
|
+
);
|
|
206
|
+
const childPages = children.data.results || [];
|
|
207
|
+
|
|
208
|
+
let demandasPage = childPages.find(p =>
|
|
209
|
+
p.title && p.title.toLowerCase().includes('demandas')
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// If "Demandas" not found among children, look UP the ancestors tree.
|
|
213
|
+
// The configured page may itself live INSIDE a "Demandas" folder.
|
|
214
|
+
if (!demandasPage) {
|
|
215
|
+
spinner.text = 'Buscando "Demandas" nos ancestrais...';
|
|
216
|
+
try {
|
|
217
|
+
const ancestorRes = await confluenceRequest(
|
|
218
|
+
confluenceUrl,
|
|
219
|
+
`/rest/api/content/${confluenceRef}?expand=ancestors`,
|
|
220
|
+
user, token, 15000, allowInsecure
|
|
221
|
+
);
|
|
222
|
+
const ancestors = ancestorRes.data.ancestors || [];
|
|
223
|
+
const demandasAncestor = ancestors.find(a =>
|
|
224
|
+
a.title && a.title.toLowerCase().includes('demandas')
|
|
225
|
+
);
|
|
226
|
+
if (demandasAncestor) {
|
|
227
|
+
demandasPage = demandasAncestor;
|
|
228
|
+
}
|
|
229
|
+
} catch { /* ignore ancestor lookup failure */ }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (demandasPage) {
|
|
233
|
+
spinner.text = 'Pasta "Demandas" encontrada — lendo todas as páginas...';
|
|
234
|
+
const demandasChildren = await confluenceRequest(
|
|
235
|
+
confluenceUrl,
|
|
236
|
+
`/rest/api/content/${demandasPage.id}/child/page?limit=50`,
|
|
237
|
+
user, token, 15000, allowInsecure
|
|
238
|
+
);
|
|
239
|
+
const demandaPages = demandasChildren.data.results || [];
|
|
240
|
+
for (const page of demandaPages) {
|
|
241
|
+
if (page.id === confluenceRef) continue;
|
|
242
|
+
try {
|
|
243
|
+
spinner.text = `Lendo: ${page.title}...`;
|
|
244
|
+
const pageData = await confluenceRequest(
|
|
245
|
+
confluenceUrl,
|
|
246
|
+
`/rest/api/content/${page.id}?expand=body.storage`,
|
|
247
|
+
user, token, 15000, allowInsecure
|
|
248
|
+
);
|
|
249
|
+
if (pageData.data.body && pageData.data.body.storage) {
|
|
250
|
+
allText += stripHtml(pageData.data.body.storage.value) + ' ';
|
|
251
|
+
}
|
|
252
|
+
} catch { /* skip pages that fail to load */ }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const page of childPages) {
|
|
257
|
+
if (demandasPage && page.id === demandasPage.id) continue;
|
|
258
|
+
try {
|
|
259
|
+
const pageData = await confluenceRequest(
|
|
260
|
+
confluenceUrl,
|
|
261
|
+
`/rest/api/content/${page.id}?expand=body.storage`,
|
|
262
|
+
user, token, 15000, allowInsecure
|
|
263
|
+
);
|
|
264
|
+
if (pageData.data.body && pageData.data.body.storage) {
|
|
265
|
+
allText += stripHtml(pageData.data.body.storage.value) + ' ';
|
|
266
|
+
}
|
|
267
|
+
} catch { /* skip pages that fail */ }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
result.stack = detectTechInText(allText);
|
|
271
|
+
result.repos = detectRepoNames(allText);
|
|
272
|
+
result.success = true;
|
|
273
|
+
} catch {
|
|
274
|
+
result.success = false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ──────────────────────────────────────────────────────
|
|
281
|
+
// Existing setup check
|
|
282
|
+
// ──────────────────────────────────────────────────────
|
|
283
|
+
async function checkGitExists() {
|
|
284
|
+
try {
|
|
285
|
+
await access(join(process.cwd(), '.git'));
|
|
286
|
+
return true;
|
|
287
|
+
} catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function checkExistingSetup() {
|
|
293
|
+
const cwd = process.cwd();
|
|
294
|
+
const check = ['.agents', '.github/agents', 'projects.yml', '.mcp.json', '.vscode/mcp.json', '.env'];
|
|
295
|
+
const found = [];
|
|
296
|
+
for (const f of check) {
|
|
297
|
+
try {
|
|
298
|
+
await access(join(cwd, f));
|
|
299
|
+
found.push(f);
|
|
300
|
+
} catch { /* not found */ }
|
|
301
|
+
}
|
|
302
|
+
return found;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Copy a template directory tree to the target.
|
|
307
|
+
* Templates are bundled with the npm package under cli/templates/.
|
|
308
|
+
*/
|
|
309
|
+
async function copyTemplateDir(templateName, targetRoot) {
|
|
310
|
+
const sourceDir = join(TEMPLATES_DIR, templateName);
|
|
311
|
+
try {
|
|
312
|
+
await access(sourceDir);
|
|
313
|
+
} catch {
|
|
314
|
+
throw new Error(`Template directory not found: ${sourceDir}`);
|
|
315
|
+
}
|
|
316
|
+
const targetMap = {
|
|
317
|
+
agents: '.agents',
|
|
318
|
+
github: '.github',
|
|
319
|
+
};
|
|
320
|
+
const targetDir = join(targetRoot, targetMap[templateName] || templateName);
|
|
321
|
+
await cp(sourceDir, targetDir, { recursive: true, force: true });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ──────────────────────────────────────────────────────
|
|
325
|
+
// File generators
|
|
326
|
+
// ──────────────────────────────────────────────────────
|
|
327
|
+
function generateProjectsYml(config) {
|
|
328
|
+
const isNumeric = /^\d+$/.test(config.confluenceRef);
|
|
329
|
+
const stackYaml = config.stack.map(s => ` - ${s}`).join('\n');
|
|
330
|
+
const agentsYaml = config.agents.map(a => ` - ${a}`).join('\n');
|
|
331
|
+
|
|
332
|
+
let yml = `project:
|
|
333
|
+
name: ${config.projectName}
|
|
334
|
+
sigla: ${config.sigla}
|
|
335
|
+
domain: # Domínio de negócio
|
|
336
|
+
status: inception
|
|
337
|
+
team_size: ${config.teamSize}
|
|
338
|
+
preset: ${config.preset}
|
|
339
|
+
stack:
|
|
340
|
+
${stackYaml}
|
|
341
|
+
agents:
|
|
342
|
+
${agentsYaml}
|
|
343
|
+
|
|
344
|
+
${config.vcs !== 'none'
|
|
345
|
+
? `vcs: ${config.vcs}\nvcs_org: ${config.vcsOrg}`
|
|
346
|
+
: `# vcs: github # Descomente e configure depois\n# vcs_org: sua-org`}
|
|
347
|
+
|
|
348
|
+
repos:
|
|
349
|
+
# Liste os repositórios de código do projeto.
|
|
350
|
+
# - name: MeuProjeto.Api
|
|
351
|
+
# type: api
|
|
352
|
+
# purpose: Endpoints REST
|
|
353
|
+
# status: planned
|
|
354
|
+
# url: null
|
|
355
|
+
|
|
356
|
+
# Agent configuration
|
|
357
|
+
agents:
|
|
358
|
+
coding:
|
|
359
|
+
dotnet: dotnet-engineer
|
|
360
|
+
nodejs: nodejs-engineer
|
|
361
|
+
java: java-engineer
|
|
362
|
+
python: labs-python-engineer
|
|
363
|
+
react: frontend-expert
|
|
364
|
+
mobile: mobile-engineer
|
|
365
|
+
security: labs-secops-agent
|
|
366
|
+
code_review: labs-code-reviewer
|
|
367
|
+
design_doc: design-doc
|
|
368
|
+
principal: principal-engineer
|
|
369
|
+
|
|
370
|
+
external_dependencies:
|
|
371
|
+
# - name: SistemaX
|
|
372
|
+
# type: event-producer
|
|
373
|
+
# description: Publica EventoY via RabbitMQ
|
|
374
|
+
|
|
375
|
+
confluence:
|
|
376
|
+
${isNumeric ? 'root_page_id' : 'space'}: ${config.confluenceRef}`;
|
|
377
|
+
|
|
378
|
+
if (config.confluenceLayout === 'page' && config.spaceKey) {
|
|
379
|
+
yml += `\n space_key: ${config.spaceKey}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (config.hasFigma && config.figmaFileUrl) {
|
|
383
|
+
yml += `\n\nfigma:\n file_url: ${config.figmaFileUrl}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
yml += '\n';
|
|
387
|
+
return yml;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function generateGitignore(config) {
|
|
391
|
+
let content = `# Secrets (nunca comitar)
|
|
392
|
+
.env
|
|
393
|
+
.mcp.json
|
|
394
|
+
|
|
395
|
+
# Claude Code local files
|
|
396
|
+
.claude/
|
|
397
|
+
|
|
398
|
+
# OS files
|
|
399
|
+
Thumbs.db
|
|
400
|
+
.DS_Store
|
|
401
|
+
|
|
402
|
+
# IDE
|
|
403
|
+
.idea/
|
|
404
|
+
*.swp
|
|
405
|
+
`;
|
|
406
|
+
|
|
407
|
+
if (config.agents.includes('copilot')) {
|
|
408
|
+
content += `
|
|
409
|
+
# VS Code (exceto MCP config compartilhada)
|
|
410
|
+
.vscode/*
|
|
411
|
+
!.vscode/mcp.json
|
|
412
|
+
`;
|
|
413
|
+
} else {
|
|
414
|
+
content += '\n.vscode/\n';
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return content;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function generateEnvFile(config) {
|
|
421
|
+
let content = `CONFLUENCE_URL=${config.confluenceUrl}
|
|
422
|
+
CONFLUENCE_USER=${config.confluenceUser}
|
|
423
|
+
CONFLUENCE_API_TOKEN=${config.confluenceToken}
|
|
424
|
+
GCHAT_WEBHOOK_URL=${config.gchatWebhookUrl || ''}
|
|
425
|
+
`;
|
|
426
|
+
|
|
427
|
+
if (config.hasFigma && config.figmaFileUrl) {
|
|
428
|
+
content += 'FIGMA_API_KEY=\n';
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return content;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function generateEnvExample(config) {
|
|
435
|
+
let content = `# Confluence credentials
|
|
436
|
+
# Obtenha o token em: https://id.atlassian.com/manage-profile/security/api-tokens
|
|
437
|
+
CONFLUENCE_URL=https://seu-dominio.atlassian.net/wiki
|
|
438
|
+
CONFLUENCE_USER=seu-email@empresa.com
|
|
439
|
+
CONFLUENCE_API_TOKEN=seu-token-aqui
|
|
440
|
+
|
|
441
|
+
# Google Chat webhook (opcional)
|
|
442
|
+
GCHAT_WEBHOOK_URL=
|
|
443
|
+
`;
|
|
444
|
+
|
|
445
|
+
if (config.hasFigma && config.figmaFileUrl) {
|
|
446
|
+
content += `
|
|
447
|
+
# Figma API key
|
|
448
|
+
# Obtenha em: https://www.figma.com/settings
|
|
449
|
+
FIGMA_API_KEY=seu-figma-api-key
|
|
450
|
+
`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return content;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function generateClaudeMcp(config) {
|
|
457
|
+
const servers = {
|
|
458
|
+
confluence: {
|
|
459
|
+
command: 'mcp-atlassian',
|
|
460
|
+
args: [
|
|
461
|
+
'--confluence-url', '${CONFLUENCE_URL}',
|
|
462
|
+
'--confluence-username', '${CONFLUENCE_USER}',
|
|
463
|
+
'--confluence-token', '${CONFLUENCE_API_TOKEN}'
|
|
464
|
+
]
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
if (config.hasFigma && config.figmaFileUrl) {
|
|
468
|
+
servers.figma = {
|
|
469
|
+
command: 'npx',
|
|
470
|
+
args: ['-y', FIGMA_MCP_PACKAGE, '--figma-api-key', '${FIGMA_API_KEY}']
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
return JSON.stringify({ mcpServers: servers }, null, 2) + '\n';
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function generateCopilotMcp(config) {
|
|
477
|
+
const servers = {
|
|
478
|
+
confluence: {
|
|
479
|
+
type: 'stdio',
|
|
480
|
+
command: 'mcp-atlassian',
|
|
481
|
+
args: [
|
|
482
|
+
'--env-file', '.env',
|
|
483
|
+
'--no-confluence-ssl-verify'
|
|
484
|
+
]
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
if (config.hasFigma && config.figmaFileUrl) {
|
|
488
|
+
servers.figma = {
|
|
489
|
+
type: 'stdio',
|
|
490
|
+
command: 'npx',
|
|
491
|
+
args: ['-y', FIGMA_MCP_PACKAGE, '--figma-api-key', '${input:figma-api-key}']
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
return JSON.stringify({ servers }, null, 2) + '\n';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function generateArchitectureSkeleton(config) {
|
|
498
|
+
return `# Arquitetura — ${config.projectName}
|
|
499
|
+
|
|
500
|
+
## Stack
|
|
501
|
+
|
|
502
|
+
${config.stack.map(s => `- ${s}`).join('\n')}
|
|
503
|
+
|
|
504
|
+
## Repositórios
|
|
505
|
+
|
|
506
|
+
(Preenchido pelo /setup)
|
|
507
|
+
|
|
508
|
+
## Decisões em Aberto
|
|
509
|
+
|
|
510
|
+
| # | Decisão | Impacto | Bloqueia | Status |
|
|
511
|
+
|---|---------|---------|----------|--------|
|
|
512
|
+
|
|
513
|
+
## Diagrama
|
|
514
|
+
|
|
515
|
+
(Gerado pelo /spec ou /dev)
|
|
516
|
+
`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ──────────────────────────────────────────────────────
|
|
520
|
+
// Main command
|
|
521
|
+
// ──────────────────────────────────────────────────────
|
|
522
|
+
export async function initCommand() {
|
|
523
|
+
console.log(chalk.bold('\n open-spec-kit init\n'));
|
|
524
|
+
|
|
525
|
+
// Pre-flight: check existing setup
|
|
526
|
+
const existing = await checkExistingSetup();
|
|
527
|
+
if (existing.length > 0) {
|
|
528
|
+
console.log(chalk.yellow(' Estrutura existente detectada:'));
|
|
529
|
+
existing.forEach(f => console.log(chalk.dim(` - ${f}`)));
|
|
530
|
+
const { proceed } = await inquirer.prompt([{
|
|
531
|
+
type: 'confirm',
|
|
532
|
+
name: 'proceed',
|
|
533
|
+
message: 'Deseja sobrescrever? (arquivos existentes serão substituídos)',
|
|
534
|
+
default: false
|
|
535
|
+
}]);
|
|
536
|
+
if (!proceed) {
|
|
537
|
+
console.log(chalk.dim(' Cancelado.'));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ════════════════════════════════════════════
|
|
543
|
+
// Phase 1 — Conexão
|
|
544
|
+
// ════════════════════════════════════════════
|
|
545
|
+
console.log(chalk.bold.blue('\n ── Fase 1: Conexão ──\n'));
|
|
546
|
+
|
|
547
|
+
const phase1 = await inquirer.prompt([
|
|
548
|
+
{
|
|
549
|
+
type: 'checkbox',
|
|
550
|
+
name: 'agents',
|
|
551
|
+
message: 'Quais AI tools você usa?',
|
|
552
|
+
choices: [
|
|
553
|
+
{ name: 'Claude Code', value: 'claude', checked: true },
|
|
554
|
+
{ name: 'GitHub Copilot', value: 'copilot', checked: true },
|
|
555
|
+
],
|
|
556
|
+
validate: v => v.length > 0 || 'Selecione pelo menos uma tool'
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
type: 'list',
|
|
560
|
+
name: 'preset',
|
|
561
|
+
message: 'Preset padrão do projeto:',
|
|
562
|
+
choices: Object.entries(PRESETS).map(([key, p]) => ({
|
|
563
|
+
name: `${p.name} — ${p.description}`,
|
|
564
|
+
value: key,
|
|
565
|
+
})),
|
|
566
|
+
default: 'standard'
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
type: 'list',
|
|
570
|
+
name: 'confluenceLayout',
|
|
571
|
+
message: 'Layout do Confluence:',
|
|
572
|
+
choices: [
|
|
573
|
+
{ name: '1 space = 1 projeto (space key)', value: 'space' },
|
|
574
|
+
{ name: '1 space = N projetos (page ID)', value: 'page' },
|
|
575
|
+
]
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
type: 'input',
|
|
579
|
+
name: 'confluenceRef',
|
|
580
|
+
message: 'Space key ou page ID (ex: TT ou 1234567890):',
|
|
581
|
+
validate: v => v.trim().length > 0 || 'Obrigatório'
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
type: 'input',
|
|
585
|
+
name: 'confluenceUrl',
|
|
586
|
+
message: 'Confluence URL (ex: https://seu-dominio.atlassian.net/wiki):',
|
|
587
|
+
validate: v => {
|
|
588
|
+
const trimmed = v.trim();
|
|
589
|
+
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
|
590
|
+
return 'URL deve começar com http:// ou https://';
|
|
591
|
+
}
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
type: 'input',
|
|
597
|
+
name: 'confluenceUser',
|
|
598
|
+
message: 'Confluence username/email (ex: voce@empresa.com):',
|
|
599
|
+
validate: v => v.trim().length > 0 || 'Obrigatório'
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
type: 'password',
|
|
603
|
+
name: 'confluenceToken',
|
|
604
|
+
message: 'Confluence API token:',
|
|
605
|
+
mask: '*',
|
|
606
|
+
validate: v => v.trim().length > 0 || 'Obrigatório'
|
|
607
|
+
}
|
|
608
|
+
]);
|
|
609
|
+
|
|
610
|
+
// Validate credentials
|
|
611
|
+
const spinner = ora('Validando credenciais do Confluence...').start();
|
|
612
|
+
let credentialsValid = false;
|
|
613
|
+
let allowInsecure = false;
|
|
614
|
+
const confluenceUrl = phase1.confluenceUrl.trim().replace(/\/$/, '');
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
await confluenceRequest(
|
|
618
|
+
confluenceUrl,
|
|
619
|
+
'/rest/api/space?limit=1',
|
|
620
|
+
phase1.confluenceUser.trim(),
|
|
621
|
+
phase1.confluenceToken.trim()
|
|
622
|
+
);
|
|
623
|
+
spinner.succeed('Credenciais validadas com sucesso!');
|
|
624
|
+
credentialsValid = true;
|
|
625
|
+
} catch (err) {
|
|
626
|
+
if (err.status === 401 || err.status === 403) {
|
|
627
|
+
spinner.fail(`Autenticação falhou (HTTP ${err.status}). Verifique suas credenciais.`);
|
|
628
|
+
console.log(chalk.red(' Não é possível continuar sem acesso ao Confluence.'));
|
|
629
|
+
console.log(chalk.dim(' Gere um token em: https://id.atlassian.com/manage-profile/security/api-tokens'));
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (isSslError(err)) {
|
|
633
|
+
spinner.text = 'Certificado SSL corporativo detectado — tentando sem verificação...';
|
|
634
|
+
try {
|
|
635
|
+
await confluenceRequest(
|
|
636
|
+
confluenceUrl,
|
|
637
|
+
'/rest/api/space?limit=1',
|
|
638
|
+
phase1.confluenceUser.trim(),
|
|
639
|
+
phase1.confluenceToken.trim(),
|
|
640
|
+
15000,
|
|
641
|
+
true
|
|
642
|
+
);
|
|
643
|
+
allowInsecure = true;
|
|
644
|
+
spinner.succeed('Credenciais validadas (SSL corporativo ignorado — ambiente interno).');
|
|
645
|
+
credentialsValid = true;
|
|
646
|
+
} catch (retryErr) {
|
|
647
|
+
if (retryErr.status === 401 || retryErr.status === 403) {
|
|
648
|
+
spinner.fail(`Autenticação falhou (HTTP ${retryErr.status}). Verifique suas credenciais.`);
|
|
649
|
+
console.log(chalk.red(' Não é possível continuar sem acesso ao Confluence.'));
|
|
650
|
+
console.log(chalk.dim(' Gere um token em: https://id.atlassian.com/manage-profile/security/api-tokens'));
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
spinner.warn(`Não foi possível conectar ao Confluence: ${retryErr.message}`);
|
|
654
|
+
console.log(chalk.yellow(' Continuando sem auto-detecção...\n'));
|
|
655
|
+
}
|
|
656
|
+
} else {
|
|
657
|
+
spinner.warn(`Não foi possível conectar ao Confluence: ${err.message}`);
|
|
658
|
+
console.log(chalk.yellow(' Continuando sem auto-detecção...\n'));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ════════════════════════════════════════════
|
|
663
|
+
// Phase 2 — Auto-detecção
|
|
664
|
+
// ════════════════════════════════════════════
|
|
665
|
+
let detected = { projectName: null, stack: [], repos: [], spaceKey: null, success: false };
|
|
666
|
+
const isNumericRef = /^\d+$/.test(phase1.confluenceRef.trim());
|
|
667
|
+
|
|
668
|
+
if (credentialsValid && isNumericRef) {
|
|
669
|
+
console.log(chalk.bold.blue('\n ── Fase 2: Auto-detecção ──\n'));
|
|
670
|
+
const detectSpinner = ora('Analisando páginas do Confluence...').start();
|
|
671
|
+
|
|
672
|
+
try {
|
|
673
|
+
detected = await autoDetectFromConfluence(
|
|
674
|
+
confluenceUrl,
|
|
675
|
+
phase1.confluenceRef.trim(),
|
|
676
|
+
phase1.confluenceUser.trim(),
|
|
677
|
+
phase1.confluenceToken.trim(),
|
|
678
|
+
detectSpinner,
|
|
679
|
+
allowInsecure
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
if (detected.success && detected.stack.length > 0) {
|
|
683
|
+
detectSpinner.succeed('Auto-detecção concluída!');
|
|
684
|
+
console.log(chalk.green(` Stack detectada: ${detected.stack.join(', ')}`));
|
|
685
|
+
if (detected.repos.length > 0) {
|
|
686
|
+
console.log(chalk.green(` Repositórios: ${detected.repos.join(', ')}`));
|
|
687
|
+
}
|
|
688
|
+
if (detected.projectName) {
|
|
689
|
+
console.log(chalk.green(` Projeto: ${detected.projectName}`));
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
detectSpinner.info('Nenhuma stack detectada automaticamente — preencha manualmente.');
|
|
693
|
+
}
|
|
694
|
+
} catch {
|
|
695
|
+
detectSpinner.info('Auto-detecção não disponível — preencha manualmente.');
|
|
696
|
+
}
|
|
697
|
+
} else if (credentialsValid && !isNumericRef) {
|
|
698
|
+
console.log(chalk.dim('\n Auto-detecção disponível apenas com page ID numérico.\n'));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ════════════════════════════════════════════
|
|
702
|
+
// Phase 3 — Dados do projeto
|
|
703
|
+
// ════════════════════════════════════════════
|
|
704
|
+
console.log(chalk.bold.blue('\n ── Fase 3: Dados do projeto ──\n'));
|
|
705
|
+
|
|
706
|
+
// B3: verificar se .git já existe antes de oferecer git init
|
|
707
|
+
const gitAlreadyExists = await checkGitExists();
|
|
708
|
+
if (gitAlreadyExists) {
|
|
709
|
+
console.log(chalk.dim(' ℹ .git já existe neste diretório — etapa de git init será pulada automaticamente.\n'));
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const defaultStack = detected.stack.length > 0 ? detected.stack.join(', ') : '';
|
|
713
|
+
|
|
714
|
+
const phase3 = await inquirer.prompt([
|
|
715
|
+
{
|
|
716
|
+
type: 'input',
|
|
717
|
+
name: 'projectName',
|
|
718
|
+
message: 'Nome do projeto:',
|
|
719
|
+
default: detected.projectName || undefined,
|
|
720
|
+
validate: v => v.trim().length > 0 || 'Nome obrigatório'
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
type: 'input',
|
|
724
|
+
name: 'sigla',
|
|
725
|
+
message: 'Sigla (2-4 letras maiúsculas, usada como prefixo):',
|
|
726
|
+
default: (answers) => deriveSigla(answers.projectName || detected.projectName || ''),
|
|
727
|
+
filter: v => v.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 4),
|
|
728
|
+
validate: v => {
|
|
729
|
+
const clean = v.toUpperCase().replace(/[^A-Z]/g, '');
|
|
730
|
+
if (clean.length < 2 || clean.length > 4) return 'Sigla deve ter 2 a 4 letras';
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
type: 'input',
|
|
736
|
+
name: 'stack',
|
|
737
|
+
message: 'Stack principal (separado por vírgula, ex: Node.js, React, PostgreSQL):',
|
|
738
|
+
default: defaultStack || undefined,
|
|
739
|
+
filter: v => v.split(',').map(s => s.trim()).filter(Boolean),
|
|
740
|
+
when: () => detected.stack.length === 0
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
type: 'list',
|
|
744
|
+
name: 'vcs',
|
|
745
|
+
message: 'Plataforma de versionamento:',
|
|
746
|
+
choices: [
|
|
747
|
+
{ name: 'GitHub', value: 'github' },
|
|
748
|
+
{ name: 'GitLab', value: 'gitlab' },
|
|
749
|
+
{ name: 'Nenhum / configurar depois', value: 'none' },
|
|
750
|
+
]
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
type: 'input',
|
|
754
|
+
name: 'vcsOrg',
|
|
755
|
+
message: 'Organização/grupo no VCS (ex: minha-empresa ou meu-usuario):',
|
|
756
|
+
when: (answers) => answers.vcs !== 'none',
|
|
757
|
+
validate: v => v.trim().length > 0 || 'Obrigatório'
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
type: 'input',
|
|
761
|
+
name: 'gchatWebhookUrl',
|
|
762
|
+
message: 'Google Chat Webhook URL (opcional — deixe vazio para pular notificações):',
|
|
763
|
+
default: ''
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
type: 'confirm',
|
|
767
|
+
name: 'hasFigma',
|
|
768
|
+
message: 'O projeto tem designs no Figma?',
|
|
769
|
+
default: false
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
type: 'input',
|
|
773
|
+
name: 'figmaFileUrl',
|
|
774
|
+
message: 'URL do arquivo Figma (deixe vazio para pular):',
|
|
775
|
+
when: (answers) => answers.hasFigma
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
type: 'confirm',
|
|
779
|
+
name: 'initGit',
|
|
780
|
+
message: 'Inicializar repositório Git agora?',
|
|
781
|
+
default: true,
|
|
782
|
+
when: () => !gitAlreadyExists
|
|
783
|
+
}
|
|
784
|
+
]);
|
|
785
|
+
|
|
786
|
+
// If Figma URL is empty, treat as no Figma (BUG-026 fix)
|
|
787
|
+
if (phase3.hasFigma && (!phase3.figmaFileUrl || !phase3.figmaFileUrl.trim())) {
|
|
788
|
+
phase3.hasFigma = false;
|
|
789
|
+
phase3.figmaFileUrl = '';
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// B3: se .git já existe, git init é false independente do prompt (que foi pulado)
|
|
793
|
+
const initGitFinal = gitAlreadyExists ? false : (phase3.initGit ?? false);
|
|
794
|
+
|
|
795
|
+
// Build unified config object
|
|
796
|
+
const config = {
|
|
797
|
+
agents: phase1.agents,
|
|
798
|
+
preset: phase1.preset,
|
|
799
|
+
confluenceLayout: phase1.confluenceLayout,
|
|
800
|
+
confluenceRef: phase1.confluenceRef.trim(),
|
|
801
|
+
confluenceUrl,
|
|
802
|
+
confluenceUser: phase1.confluenceUser.trim(),
|
|
803
|
+
confluenceToken: phase1.confluenceToken.trim(),
|
|
804
|
+
spaceKey: detected.spaceKey || null,
|
|
805
|
+
projectName: phase3.projectName.trim(),
|
|
806
|
+
sigla: phase3.sigla,
|
|
807
|
+
stack: phase3.stack ?? detected.stack,
|
|
808
|
+
vcs: phase3.vcs,
|
|
809
|
+
// B2: vcsOrg é undefined quando vcs='none' (prompt foi pulado)
|
|
810
|
+
vcsOrg: phase3.vcs !== 'none' ? (phase3.vcsOrg || '').trim() : '',
|
|
811
|
+
teamSize: '5',
|
|
812
|
+
gchatWebhookUrl: (phase3.gchatWebhookUrl || '').trim(),
|
|
813
|
+
hasFigma: phase3.hasFigma,
|
|
814
|
+
figmaFileUrl: (phase3.figmaFileUrl || '').trim(),
|
|
815
|
+
initGit: initGitFinal,
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// ════════════════════════════════════════════
|
|
819
|
+
// Phase 4 — Revisão & Confirmação (B4)
|
|
820
|
+
// ════════════════════════════════════════════
|
|
821
|
+
console.log(chalk.bold.blue('\n ── Fase 4: Revisão ──\n'));
|
|
822
|
+
console.log(chalk.bold(' Confira todos os dados antes de gerar os arquivos:\n'));
|
|
823
|
+
|
|
824
|
+
const vcsDisplay = config.vcs === 'none'
|
|
825
|
+
? chalk.dim('Nenhum (configurar depois)')
|
|
826
|
+
: `${chalk.cyan(config.vcs)} — ${chalk.cyan(config.vcsOrg)}`;
|
|
827
|
+
|
|
828
|
+
console.log(` Projeto: ${chalk.cyan(config.projectName)}`);
|
|
829
|
+
console.log(` Sigla: ${chalk.cyan(config.sigla)}`);
|
|
830
|
+
console.log(` Stack: ${chalk.cyan(config.stack.length ? config.stack.join(', ') : '(não definida)')}`);
|
|
831
|
+
console.log(` Preset: ${chalk.cyan(PRESETS[config.preset].name)}`);
|
|
832
|
+
console.log(` AI tools: ${chalk.cyan(config.agents.map(a => AGENTS[a].name).join(', '))}`);
|
|
833
|
+
console.log(` VCS: ${vcsDisplay}`);
|
|
834
|
+
console.log(` Confluence: ${chalk.cyan(config.confluenceRef)} (${config.confluenceLayout === 'space' ? 'space key' : 'page ID'}) @ ${chalk.cyan(config.confluenceUrl)}`);
|
|
835
|
+
console.log(` GChat: ${chalk.cyan(config.gchatWebhookUrl ? '(webhook configurado)' : chalk.dim('Não configurado'))}`);
|
|
836
|
+
console.log(` Figma: ${chalk.cyan(config.hasFigma ? (config.figmaFileUrl || 'Sim (URL não informada)') : 'Não')}`);
|
|
837
|
+
if (gitAlreadyExists) {
|
|
838
|
+
console.log(` Git init: ${chalk.dim('Pulado (.git já existe)')}`);
|
|
839
|
+
} else {
|
|
840
|
+
console.log(` Git init: ${chalk.cyan(config.initGit ? 'Sim' : 'Não')}`);
|
|
841
|
+
}
|
|
842
|
+
console.log('');
|
|
843
|
+
|
|
844
|
+
const { confirmed } = await inquirer.prompt([{
|
|
845
|
+
type: 'confirm',
|
|
846
|
+
name: 'confirmed',
|
|
847
|
+
message: 'Confirmar e gerar arquivos?',
|
|
848
|
+
default: true
|
|
849
|
+
}]);
|
|
850
|
+
|
|
851
|
+
if (!confirmed) {
|
|
852
|
+
console.log(chalk.yellow('\n Cancelado. Execute open-spec-kit init novamente para recomeçar.\n'));
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// ════════════════════════════════════════════
|
|
857
|
+
// File generation
|
|
858
|
+
// ════════════════════════════════════════════
|
|
859
|
+
const genSpinner = ora('Gerando estrutura...').start();
|
|
860
|
+
const cwd = process.cwd();
|
|
861
|
+
|
|
862
|
+
try {
|
|
863
|
+
// Create common directories
|
|
864
|
+
const commonDirs = ['specs', 'docs/decisions', 'docs/lessons', 'scripts'];
|
|
865
|
+
for (const dir of commonDirs) {
|
|
866
|
+
await mkdir(join(cwd, dir), { recursive: true });
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Generate .env with real credentials (GAP-03 fix)
|
|
870
|
+
genSpinner.text = '.env criado';
|
|
871
|
+
await writeFile(join(cwd, '.env'), generateEnvFile(config));
|
|
872
|
+
|
|
873
|
+
// Generate .env.example
|
|
874
|
+
genSpinner.text = '.env.example criado';
|
|
875
|
+
await writeFile(join(cwd, '.env.example'), generateEnvExample(config));
|
|
876
|
+
|
|
877
|
+
// Generate projects.yml
|
|
878
|
+
genSpinner.text = 'projects.yml criado';
|
|
879
|
+
await writeFile(join(cwd, 'projects.yml'), generateProjectsYml(config));
|
|
880
|
+
|
|
881
|
+
// Generate .gitignore
|
|
882
|
+
genSpinner.text = '.gitignore criado';
|
|
883
|
+
await writeFile(join(cwd, '.gitignore'), generateGitignore(config));
|
|
884
|
+
|
|
885
|
+
// Copy shared templates (agents source of truth)
|
|
886
|
+
genSpinner.text = 'Copiando skills e rules...';
|
|
887
|
+
await copyTemplateDir('agents', cwd);
|
|
888
|
+
|
|
889
|
+
// Copy notify scripts to scripts/ (cross-platform)
|
|
890
|
+
genSpinner.text = 'Copiando scripts de notificação...';
|
|
891
|
+
const scriptsSource = join(TEMPLATES_DIR, 'agents', 'scripts');
|
|
892
|
+
const scriptsTarget = join(cwd, 'scripts');
|
|
893
|
+
try {
|
|
894
|
+
await access(scriptsSource);
|
|
895
|
+
await cp(scriptsSource, scriptsTarget, { recursive: true, force: true });
|
|
896
|
+
} catch { /* scripts dir may not exist in older templates */ }
|
|
897
|
+
|
|
898
|
+
// Generate per-agent files
|
|
899
|
+
if (config.agents.includes('claude')) {
|
|
900
|
+
genSpinner.text = 'Configurando Claude Code...';
|
|
901
|
+
await writeFile(join(cwd, '.mcp.json'), generateClaudeMcp(config));
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (config.agents.includes('copilot')) {
|
|
905
|
+
genSpinner.text = 'Configurando GitHub Copilot...';
|
|
906
|
+
await copyTemplateDir('github', cwd);
|
|
907
|
+
await mkdir(join(cwd, '.vscode'), { recursive: true });
|
|
908
|
+
await writeFile(join(cwd, '.vscode/mcp.json'), generateCopilotMcp(config));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Generate docs/architecture.md skeleton
|
|
912
|
+
await writeFile(join(cwd, 'docs/architecture.md'), generateArchitectureSkeleton(config));
|
|
913
|
+
|
|
914
|
+
genSpinner.succeed('Estrutura gerada com sucesso!');
|
|
915
|
+
|
|
916
|
+
// Install mcp-atlassian (BUG-024 fix)
|
|
917
|
+
const pipSpinner = ora('Verificando mcp-atlassian...').start();
|
|
918
|
+
try {
|
|
919
|
+
const pipList = execSync('pip list', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
920
|
+
if (!pipList.toLowerCase().includes('mcp-atlassian')) {
|
|
921
|
+
pipSpinner.text = 'Instalando mcp-atlassian...';
|
|
922
|
+
execSync('pip install mcp-atlassian', { stdio: 'pipe', timeout: 120000 });
|
|
923
|
+
pipSpinner.succeed('mcp-atlassian instalado com sucesso!');
|
|
924
|
+
} else {
|
|
925
|
+
pipSpinner.succeed('mcp-atlassian já está instalado.');
|
|
926
|
+
}
|
|
927
|
+
} catch {
|
|
928
|
+
pipSpinner.warn('Não foi possível instalar mcp-atlassian. Instale manualmente: pip install mcp-atlassian');
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Git init
|
|
932
|
+
if (config.initGit) {
|
|
933
|
+
try {
|
|
934
|
+
execSync('git init', { cwd, stdio: 'pipe' });
|
|
935
|
+
execSync('git add .', { cwd, stdio: 'pipe' });
|
|
936
|
+
execSync('git commit -m "chore: init spec repo via open-spec-kit"', { cwd, stdio: 'pipe' });
|
|
937
|
+
console.log(chalk.green('\n ✓ Repositório Git inicializado e commit inicial criado'));
|
|
938
|
+
} catch {
|
|
939
|
+
console.log(chalk.yellow('\n ⚠ Git init falhou — inicialize manualmente:'));
|
|
940
|
+
console.log(chalk.dim(' git init && git add . && git commit -m "chore: init spec repo"'));
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Summary
|
|
945
|
+
console.log(chalk.bold('\n Resumo:\n'));
|
|
946
|
+
console.log(` Projeto: ${chalk.cyan(config.projectName)}`);
|
|
947
|
+
console.log(` Sigla: ${chalk.cyan(config.sigla)}`);
|
|
948
|
+
console.log(` Stack: ${chalk.cyan(config.stack.join(', ') || '(não definida)')}`);
|
|
949
|
+
console.log(` Preset: ${chalk.cyan(PRESETS[config.preset].name)}`);
|
|
950
|
+
console.log(` AI tools: ${chalk.cyan(config.agents.map(a => AGENTS[a].name).join(', '))}`);
|
|
951
|
+
const vcsSummary = config.vcs === 'none'
|
|
952
|
+
? chalk.dim('Nenhum (configurar depois)')
|
|
953
|
+
: `${chalk.cyan(config.vcs)} (${config.vcsOrg})`;
|
|
954
|
+
console.log(` VCS: ${vcsSummary}`);
|
|
955
|
+
console.log(` Confluence: ${chalk.cyan(config.confluenceRef)} (${config.confluenceLayout})`);
|
|
956
|
+
if (config.hasFigma && config.figmaFileUrl) {
|
|
957
|
+
console.log(` Figma: ${chalk.cyan(config.figmaFileUrl)}`);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
console.log(chalk.bold('\n Arquivos gerados:\n'));
|
|
961
|
+
console.log(chalk.dim(' .env — credenciais reais (NÃO comitar)'));
|
|
962
|
+
console.log(chalk.dim(' .env.example — template para o time'));
|
|
963
|
+
console.log(chalk.dim(' projects.yml — configuração do projeto'));
|
|
964
|
+
console.log(chalk.dim(' .gitignore — ignora .env e .mcp.json'));
|
|
965
|
+
if (config.agents.includes('claude')) {
|
|
966
|
+
console.log(chalk.dim(' .mcp.json — MCP config para Claude Code'));
|
|
967
|
+
}
|
|
968
|
+
if (config.agents.includes('copilot')) {
|
|
969
|
+
console.log(chalk.dim(' .vscode/mcp.json — MCP config para GitHub Copilot'));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
console.log(chalk.bold('\n Próximos passos:\n'));
|
|
973
|
+
console.log(' → Execute /setup para fazer o bootstrap no Confluence');
|
|
974
|
+
console.log(' → Execute /discovery para analisar a primeira demanda');
|
|
975
|
+
console.log('');
|
|
976
|
+
|
|
977
|
+
} catch (err) {
|
|
978
|
+
genSpinner.fail(`Erro: ${err.message}`);
|
|
979
|
+
process.exit(1);
|
|
980
|
+
}
|
|
981
|
+
}
|