@spec-wave/cli 0.5.8 → 0.5.10

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.
@@ -2,12 +2,12 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { resolveToken } from '../api/auth.mjs';
4
4
  import { getIssue, getPR, commentOnIssue } from '../api/github-rest.mjs';
5
- import { addProjectItem, setItemSingleSelect, getSingleSelectField, getIssueParent } from '../api/github-graphql.mjs';
5
+ import { addProjectItem, setItemSingleSelect, getSingleSelectField, getIssueParent, getItemSingleSelectValue } from '../api/github-graphql.mjs';
6
6
  import { detectIssueType } from '../lib/issue-type.mjs';
7
- import { CONFIG_FILE, STATUS_OPTIONS } from '../config.mjs';
7
+ import { CONFIG_FILE, STATUS_OPTIONS, STAGE_ORDER, PROGRESS_TODO } from '../config.mjs';
8
8
 
9
9
  const QA_STAGE = STATUS_OPTIONS.find(s => s.name.includes('QA'))?.name;
10
- const TODO_STATUS = 'Todo';
10
+ const TODO_STATUS = PROGRESS_TODO;
11
11
 
12
12
  function extractIssueNumbers(body) {
13
13
  if (!body) return [];
@@ -50,9 +50,17 @@ async function resolveFeatureIssue(token, owner, repo, issueNumber) {
50
50
  return null;
51
51
  }
52
52
 
53
+ // Avança para a Etapa "🧪 QA" e reinicia o Status para "Todo". Uma issue só
54
+ // AVANÇA: se já estiver em QA ou etapa posterior, não é tocada (retorna false).
53
55
  async function setQA(token, project, etapaField, statusField, nodeId) {
54
56
  const itemId = await addProjectItem(token, project.id, nodeId);
55
57
  if (etapaField?.id && QA_STAGE) {
58
+ const current = await getItemSingleSelectValue(token, itemId, etapaField.id).catch(() => null);
59
+ const curIdx = current ? STAGE_ORDER.indexOf(current) : -1;
60
+ const tgtIdx = STAGE_ORDER.indexOf(QA_STAGE);
61
+ if (curIdx !== -1 && tgtIdx !== -1 && curIdx >= tgtIdx) {
62
+ return false; // já está em QA ou adiante — não retrocede
63
+ }
56
64
  const optionId = etapaField.options?.[QA_STAGE];
57
65
  if (optionId) await setItemSingleSelect(token, project.id, itemId, etapaField.id, optionId);
58
66
  }
@@ -60,6 +68,7 @@ async function setQA(token, project, etapaField, statusField, nodeId) {
60
68
  const optionId = statusField.options?.[TODO_STATUS];
61
69
  if (optionId) await setItemSingleSelect(token, project.id, itemId, statusField.id, optionId);
62
70
  }
71
+ return true;
63
72
  }
64
73
 
65
74
  export async function qa({ prNumber }) {
@@ -115,9 +124,13 @@ export async function qa({ prNumber }) {
115
124
  if (!feature || seen.has(feature.number)) continue;
116
125
  seen.add(feature.number);
117
126
  try {
118
- await setQA(projectToken, project, etapaField, statusField, feature.node_id);
119
- updated.push(`#${feature.number} ${feature.title}`);
120
- console.log(`Feature #${feature.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
127
+ const moved = await setQA(projectToken, project, etapaField, statusField, feature.node_id);
128
+ if (moved) {
129
+ updated.push(`#${feature.number} ${feature.title}`);
130
+ console.log(`Feature #${feature.number} → "${QA_STAGE}" / Status "${TODO_STATUS}".`);
131
+ } else {
132
+ console.log(`Feature #${feature.number} já está em "${QA_STAGE}" ou etapa posterior — mantida (não retrocede).`);
133
+ }
121
134
  } catch (err) {
122
135
  console.warn(`Falha ao atualizar Feature #${feature.number}: ${err.message}`);
123
136
  }
@@ -0,0 +1,300 @@
1
+ import * as p from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { homedir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { resolveToken } from '../api/auth.mjs';
8
+ import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS } from '../config.mjs';
9
+ import { getProjectSnapshot } from '../api/github-graphql.mjs';
10
+ import {
11
+ getFileContent, upsertFile, listLabels, createLabel, updateLabel,
12
+ } from '../api/github-rest.mjs';
13
+ import {
14
+ TARGETS, SKILL_SOURCE, CLI_VERSION, parseSkill, renderContent,
15
+ mergeAgentsFile, resolveDest, isDetected, extractAgentsBlock,
16
+ } from './install-skill.mjs';
17
+
18
+ const __dir = path.dirname(fileURLToPath(import.meta.url));
19
+ const TEMPLATES_DIR = path.join(__dir, '..', 'templates');
20
+ function readTemplate(...parts) {
21
+ return readFileSync(path.join(TEMPLATES_DIR, ...parts), 'utf-8');
22
+ }
23
+
24
+ // Arquivos do repo gerenciados pela CLI (comparados com o template empacotado).
25
+ const REPO_FILES = [
26
+ ...WORKFLOW_FILES.map(f => ({ repoPath: `.github/workflows/${f}`, template: ['workflows', f] })),
27
+ ...ISSUE_TEMPLATE_FILES.map(f => ({ repoPath: `.github/ISSUE_TEMPLATE/${f}`, template: ['issue', f] })),
28
+ ];
29
+
30
+ // Detecta se a skill instalada em cada agente diverge da versão atual da CLI.
31
+ // Retorna os alvos desatualizados (conteúdo diferente ou ausente).
32
+ function detectSkill(parsed, baseDir, isGlobal) {
33
+ const jobs = [];
34
+ for (const target of TARGETS) {
35
+ if (!isDetected(target, baseDir)) continue;
36
+ const dest = resolveDest(target, baseDir, isGlobal);
37
+ if (!dest) continue;
38
+ const desired = renderContent(dest.format, parsed, CLI_VERSION);
39
+ const existing = existsSync(dest.path) ? readFileSync(dest.path, 'utf-8') : null;
40
+ let reason = null;
41
+ if (existing === null) {
42
+ reason = 'ausente';
43
+ } else if (dest.format === 'agents') {
44
+ const block = extractAgentsBlock(existing);
45
+ if (block === null) reason = 'bloco ausente';
46
+ else if (block.trim() !== desired.trim()) reason = 'desatualizada';
47
+ } else if (existing !== desired) {
48
+ reason = 'desatualizada';
49
+ }
50
+ if (reason) jobs.push({ target, dest, desired, reason });
51
+ }
52
+ return jobs;
53
+ }
54
+
55
+ // Aplica a atualização de uma skill (grava o arquivo / faz merge no AGENTS.md).
56
+ function applySkill(job) {
57
+ const content = job.dest.format === 'agents'
58
+ ? mergeAgentsFile(job.dest.path, job.desired)
59
+ : job.desired;
60
+ mkdirSync(path.dirname(job.dest.path), { recursive: true });
61
+ writeFileSync(job.dest.path, content, 'utf-8');
62
+ }
63
+
64
+ // Compara ALL_LABELS com as labels do repo. color no config é hex maiúsculo; a
65
+ // API retorna minúsculo — daí o toLowerCase() na comparação.
66
+ function diffLabels(existing) {
67
+ const byName = new Map(existing.map(l => [l.name, l]));
68
+ const missing = [];
69
+ const changed = [];
70
+ for (const label of ALL_LABELS) {
71
+ const cur = byName.get(label.name);
72
+ if (!cur) {
73
+ missing.push(label);
74
+ } else if (
75
+ cur.color.toLowerCase() !== label.color.toLowerCase() ||
76
+ (cur.description || '') !== (label.description || '')
77
+ ) {
78
+ changed.push(label);
79
+ }
80
+ }
81
+ return { missing, changed };
82
+ }
83
+
84
+ export async function update(options = {}) {
85
+ p.intro(chalk.bold(`spec-wave update (CLI v${CLI_VERSION})`));
86
+
87
+ const isGlobal = !!options.global;
88
+ const baseDir = isGlobal ? homedir() : process.cwd();
89
+
90
+ // ---------- Detecção ----------
91
+ // 1) Skill (por agente detectado).
92
+ const parsed = parseSkill(readFileSync(SKILL_SOURCE, 'utf-8'));
93
+ const skillJobs = options.skipSkill ? [] : detectSkill(parsed, baseDir, isGlobal);
94
+
95
+ // 2) Config + repo dependem do .spec-wave.json local do repo atual.
96
+ const configPath = path.join(process.cwd(), CONFIG_FILE);
97
+ let config = null;
98
+ if (existsSync(configPath)) {
99
+ try {
100
+ config = JSON.parse(readFileSync(configPath, 'utf-8'));
101
+ } catch (err) {
102
+ p.log.warn(`${CONFIG_FILE} corrompido (${err.message}); pulando config/repo.`);
103
+ }
104
+ }
105
+
106
+ const doConfig = !options.skipConfig && !!config;
107
+ const doRepo = !options.skipRepo && !!config?.owner && !!config?.repo;
108
+
109
+ // Token resolvido sob demanda (necessário só para repo/config remoto).
110
+ let token;
111
+ let tokenError;
112
+ async function getToken() {
113
+ if (token || tokenError) return token;
114
+ try {
115
+ token = await resolveToken();
116
+ } catch (err) {
117
+ tokenError = err;
118
+ }
119
+ return token;
120
+ }
121
+
122
+ // 2a) Config desatualizado? (versão divergente ou formato legado).
123
+ let configStale = null;
124
+ if (doConfig) {
125
+ const legacy = !config.project?.fields;
126
+ if (config.version !== CLI_VERSION || legacy) {
127
+ configStale = {
128
+ reason: legacy ? 'formato legado (sem project.fields)' : `versão ${config.version ?? '?'} ≠ ${CLI_VERSION}`,
129
+ canApply: !!config.project?.id,
130
+ };
131
+ }
132
+ }
133
+
134
+ // 2b) Arquivos do repo e labels divergentes (exige token + rede).
135
+ let repoFiles = [];
136
+ let labelDiff = { missing: [], changed: [] };
137
+ let repoChecked = false;
138
+ if (doRepo) {
139
+ const s = p.spinner();
140
+ s.start('Comparando arquivos e labels do repositório...');
141
+ const tk = await getToken();
142
+ if (!tk) {
143
+ s.stop('');
144
+ p.log.warn(`Sem token do GitHub (${tokenError?.message ?? 'indisponível'}); pulando verificação do repo.`);
145
+ } else {
146
+ const { owner, repo } = config;
147
+ try {
148
+ for (const f of REPO_FILES) {
149
+ const remote = await getFileContent(tk, owner, repo, f.repoPath);
150
+ const local = readTemplate(...f.template);
151
+ if (remote === null) repoFiles.push({ ...f, reason: 'ausente', local });
152
+ else if (remote !== local) repoFiles.push({ ...f, reason: 'desatualizado', local });
153
+ }
154
+ labelDiff = diffLabels(await listLabels(tk, owner, repo));
155
+ repoChecked = true;
156
+ s.stop('Repositório comparado.');
157
+ } catch (err) {
158
+ s.stop('');
159
+ p.log.warn(`Falha ao comparar o repo: ${err.message}`);
160
+ }
161
+ }
162
+ }
163
+
164
+ // ---------- Resumo ----------
165
+ const labelTotal = labelDiff.missing.length + labelDiff.changed.length;
166
+ const total = skillJobs.length + (configStale ? 1 : 0) + repoFiles.length + labelTotal;
167
+
168
+ if (total === 0) {
169
+ p.log.success('Tudo já está atualizado para a versão atual da CLI.');
170
+ p.outro('Nada a fazer.');
171
+ return;
172
+ }
173
+
174
+ const lines = [];
175
+ if (skillJobs.length) {
176
+ lines.push(chalk.bold('Skill:'));
177
+ for (const j of skillJobs) lines.push(` ${chalk.yellow('↻')} ${j.target.name} (${j.reason})\n ${chalk.dim(j.dest.path)}`);
178
+ }
179
+ if (configStale) {
180
+ lines.push(chalk.bold('Config local:'));
181
+ lines.push(` ${chalk.yellow('↻')} ${CONFIG_FILE} — ${configStale.reason}` +
182
+ (configStale.canApply ? '' : chalk.dim(' (sem project.id — rode `init` sem --skip-project)')));
183
+ }
184
+ if (repoFiles.length) {
185
+ lines.push(chalk.bold('Arquivos do repo:'));
186
+ for (const f of repoFiles) lines.push(` ${chalk.yellow('↻')} ${f.repoPath} (${f.reason})`);
187
+ }
188
+ if (labelTotal) {
189
+ lines.push(chalk.bold('Labels:'));
190
+ if (labelDiff.missing.length) lines.push(` ${chalk.yellow('+')} criar: ${labelDiff.missing.map(l => l.name).join(', ')}`);
191
+ if (labelDiff.changed.length) lines.push(` ${chalk.yellow('↻')} atualizar: ${labelDiff.changed.map(l => l.name).join(', ')}`);
192
+ }
193
+ p.note(lines.join('\n'), `${total} item(ns) desatualizado(s)`);
194
+
195
+ if (options.dryRun) {
196
+ p.outro('Dry-run: nada foi alterado.');
197
+ return;
198
+ }
199
+
200
+ if (!options.yes) {
201
+ const ok = await p.confirm({ message: `Aplicar as ${total} atualização(ões)?`, initialValue: true });
202
+ if (p.isCancel(ok) || !ok) {
203
+ p.cancel('Update cancelado.');
204
+ return;
205
+ }
206
+ }
207
+
208
+ // ---------- Aplicação ----------
209
+ // Skill
210
+ for (const job of skillJobs) {
211
+ try {
212
+ applySkill(job);
213
+ p.log.success(`Skill atualizada: ${job.target.name}`);
214
+ } catch (err) {
215
+ p.log.error(`Falha ao atualizar skill (${job.target.name}): ${err.message}`);
216
+ }
217
+ }
218
+
219
+ // Config (.spec-wave.json) — reconsulta o Project e reescreve local.
220
+ if (configStale) {
221
+ if (!configStale.canApply) {
222
+ p.log.warn(`${CONFIG_FILE}: sem project.id — pulei. Rode \`npx @spec-wave/cli init\` (sem --skip-project).`);
223
+ } else {
224
+ const tk = await getToken();
225
+ if (!tk) {
226
+ p.log.warn(`${CONFIG_FILE}: sem token — pulei. (${tokenError?.message ?? ''})`);
227
+ } else {
228
+ const s = p.spinner();
229
+ s.start('Atualizando .spec-wave.json...');
230
+ try {
231
+ const snapshot = await getProjectSnapshot(tk, config.project.id);
232
+ if (!snapshot) throw new Error('Project não encontrado');
233
+ const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
234
+ const updated = {
235
+ ...config,
236
+ version: CLI_VERSION,
237
+ project: {
238
+ ...projectRest,
239
+ title: snapshot.title,
240
+ url: snapshot.url,
241
+ id: snapshot.id,
242
+ number: snapshot.number,
243
+ fields: snapshot.fields,
244
+ },
245
+ refreshedAt: new Date().toISOString(),
246
+ };
247
+ writeFileSync(configPath, JSON.stringify(updated, null, 2) + '\n');
248
+ s.stop(`${CONFIG_FILE} atualizado (v${CLI_VERSION}).`);
249
+ } catch (err) {
250
+ s.stop('');
251
+ p.log.error(`Falha ao atualizar ${CONFIG_FILE}: ${err.message}`);
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ // Arquivos do repo
258
+ if (repoFiles.length) {
259
+ const tk = await getToken();
260
+ const { owner, repo } = config;
261
+ for (const f of repoFiles) {
262
+ try {
263
+ await upsertFile(tk, owner, repo, f.repoPath, f.local, `chore: update ${path.basename(f.repoPath)} [spec-wave]`);
264
+ p.log.success(`Arquivo atualizado no repo: ${f.repoPath}`);
265
+ } catch (err) {
266
+ p.log.error(`Falha ao atualizar ${f.repoPath}: ${err.message}`);
267
+ }
268
+ }
269
+ }
270
+
271
+ // Labels
272
+ if (labelTotal) {
273
+ const tk = await getToken();
274
+ const { owner, repo } = config;
275
+ for (const label of labelDiff.missing) {
276
+ try {
277
+ await createLabel(tk, owner, repo, label);
278
+ p.log.success(`Label criada: ${label.name}`);
279
+ } catch (err) {
280
+ p.log.error(`Falha ao criar label ${label.name}: ${err.message}`);
281
+ }
282
+ }
283
+ for (const label of labelDiff.changed) {
284
+ try {
285
+ await updateLabel(tk, owner, repo, label);
286
+ p.log.success(`Label atualizada: ${label.name}`);
287
+ } catch (err) {
288
+ p.log.error(`Falha ao atualizar label ${label.name}: ${err.message}`);
289
+ }
290
+ }
291
+ }
292
+
293
+ const committedRepo = repoFiles.length > 0;
294
+ p.outro(
295
+ 'Update concluído.' +
296
+ (skillJobs.length ? ' Recarregue o agente para pegar a skill nova.' : '') +
297
+ (configStale?.canApply ? ` Faça commit do ${CONFIG_FILE}.` : '') +
298
+ (committedRepo ? ' Arquivos do repo foram commitados no remoto.' : '')
299
+ );
300
+ }
package/src/config.mjs CHANGED
@@ -4,6 +4,9 @@
4
4
  // pelo comando `info` (e pela skill) para detectar se o spec-wave já foi configurado.
5
5
  export const CONFIG_FILE = '.spec-wave.json';
6
6
 
7
+ // Portal Web da ferramenta — exibido ao final do `init` e no `info`.
8
+ export const PORTAL_URL = 'https://spec-wave.astratech.net.br';
9
+
7
10
  // Providers de IA suportados pelos workflows (generate-plan/spec/decompose).
8
11
  // O provider e o modelo escolhidos no `init` são persistidos em .spec-wave.json
9
12
  // (bloco `ai`) e lidos em runtime por src/lib/claude.mjs. Cada provider declara
@@ -48,6 +51,23 @@ export const STATUS_OPTIONS = [
48
51
  { name: '🎉 Done', color: 'GREEN' },
49
52
  ];
50
53
 
54
+ // ⚠️ Dois campos DISTINTOS no board (não confundir):
55
+ // • "Etapa" (campo custom = as opções de STATUS_OPTIONS acima): as colunas do
56
+ // kanban. Determina a DIREÇÃO do fluxo — uma issue só AVANÇA, nunca volta.
57
+ // • "Status" (campo nativo: Todo/In Progress/Done): o PROGRESSO dentro da etapa
58
+ // atual. Ao avançar de etapa, o Status reinicia em "Todo".
59
+
60
+ // Etapas (campo Etapa) referenciadas pelo fluxo de implementação.
61
+ export const STAGE_DEVELOPMENT = STATUS_OPTIONS.find(s => s.name.includes('Desenvolvimento')).name;
62
+ export const STAGE_CODE_REVIEW = STATUS_OPTIONS.find(s => s.name.includes('Code Review')).name;
63
+ // Ordem canônica das etapas — usada para garantir que uma issue só AVANÇA.
64
+ export const STAGE_ORDER = STATUS_OPTIONS.map(s => s.name);
65
+
66
+ // Valores do campo nativo "Status" (progresso dentro da etapa).
67
+ export const PROGRESS_TODO = 'Todo';
68
+ export const PROGRESS_IN_PROGRESS = 'In Progress';
69
+ export const PROGRESS_DONE = 'Done';
70
+
51
71
  export const CUSTOM_FIELDS = [
52
72
  {
53
73
  name: 'Work Item Type',