@spec-wave/cli 0.13.0 → 0.15.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 +39 -6
- package/bin/spec-wave.mjs +17 -1
- package/package.json +1 -1
- package/src/api/github-rest.mjs +198 -2
- package/src/commands/code-review.mjs +5 -8
- package/src/commands/decompose.mjs +412 -130
- package/src/commands/dev-agent.mjs +3 -2
- package/src/commands/doctor.mjs +239 -9
- package/src/commands/generate-plan.mjs +105 -30
- package/src/commands/generate-spec.mjs +17 -5
- package/src/commands/implement.mjs +39 -15
- package/src/commands/info.mjs +4 -3
- package/src/commands/issue.mjs +4 -4
- package/src/commands/move.mjs +162 -0
- package/src/commands/order.mjs +1 -12
- package/src/commands/qa.mjs +5 -8
- package/src/commands/refresh.mjs +4 -3
- package/src/commands/story.mjs +1 -12
- package/src/commands/task.mjs +1 -11
- package/src/commands/update.mjs +372 -71
- package/src/commands/validate.mjs +37 -22
- package/src/config.mjs +40 -1
- package/src/lib/board.mjs +88 -26
- package/src/lib/claude.mjs +315 -70
- package/src/lib/critique.mjs +391 -91
- package/src/lib/decomposition-doc.mjs +451 -0
- package/src/lib/implement-board.mjs +14 -1
- package/src/lib/pr-branch.mjs +267 -0
- package/src/lib/project-root.mjs +93 -0
- package/src/lib/templates.mjs +53 -0
- package/src/setup/files.mjs +3 -10
- package/src/templates/skill/SKILL.md +158 -30
- package/src/templates/workflows/code-review.yml +1 -1
- package/src/templates/workflows/decompose.yml +20 -6
- package/src/templates/workflows/generate-plan.yml +1 -1
- package/src/templates/workflows/generate-spec.yml +1 -1
- package/src/templates/workflows/qa.yml +1 -1
- package/src/templates/workflows/validate.yml +1 -1
package/src/commands/update.mjs
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { readFileSync, writeFileSync,
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
5
4
|
import { homedir } from 'node:os';
|
|
6
5
|
import path from 'node:path';
|
|
7
6
|
import { resolveToken } from '../api/auth.mjs';
|
|
8
7
|
import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS } from '../config.mjs';
|
|
9
8
|
import { getProjectSnapshot } from '../api/github-graphql.mjs';
|
|
10
9
|
import {
|
|
11
|
-
getFileContent, upsertFile, listLabels, createLabel, updateLabel,
|
|
10
|
+
getFileContent, upsertFile, listLabels, createLabel, updateLabel, deleteLabel,
|
|
11
|
+
getRepoDefaultBranch, compareBranches, commitFilesToBranch, ensurePullRequest,
|
|
12
12
|
} from '../api/github-rest.mjs';
|
|
13
|
+
import {
|
|
14
|
+
resolveBranchName, composePrTitle, composePrBody, buildCommitMessage,
|
|
15
|
+
decideConfigInPr, explainGitWriteError,
|
|
16
|
+
} from '../lib/pr-branch.mjs';
|
|
17
|
+
// MESMO readTemplate do init: resolve {{CLI_VERSION}} antes da comparação byte a
|
|
18
|
+
// byte com o remoto. Se só o init resolvesse, todo update veria os workflows
|
|
19
|
+
// como desatualizados para sempre.
|
|
20
|
+
import { readTemplate } from '../lib/templates.mjs';
|
|
13
21
|
import {
|
|
14
22
|
TARGETS, SKILL_SOURCE, CLI_VERSION, parseSkill, renderContent,
|
|
15
23
|
mergeAgentsFile, resolveDest, isDetected, skillCopyReason,
|
|
16
24
|
} 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
|
-
}
|
|
25
|
+
import { findConfigPath } from '../lib/project-root.mjs';
|
|
23
26
|
|
|
24
27
|
// Arquivos do repo gerenciados pela CLI (comparados com o template empacotado).
|
|
25
28
|
const REPO_FILES = [
|
|
@@ -52,10 +55,23 @@ function applySkill(job) {
|
|
|
52
55
|
writeFileSync(job.dest.path, content, 'utf-8');
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Compara ALL_LABELS com as labels do repo (função PURA — testável).
|
|
60
|
+
*
|
|
61
|
+
* `color` no config é hex maiúsculo e a API retorna minúsculo — daí o
|
|
62
|
+
* toLowerCase() na comparação.
|
|
63
|
+
*
|
|
64
|
+
* `orphan` são labels `spec-wave:*` que existem no repo mas saíram do config: o
|
|
65
|
+
* update só sabia criar e atualizar, nunca remover, então uma label descontinuada
|
|
66
|
+
* ficava para sempre — foi o caso da `spec-wave:force`, removida do código na
|
|
67
|
+
* 0.13.0 mas ainda presente em todo repo inicializado com a 0.12.0. Só o
|
|
68
|
+
* namespace `spec-wave:` é considerado: labels do time não são da nossa conta.
|
|
69
|
+
*
|
|
70
|
+
* @param {Array<{name,color,description}>} existing labels do repo
|
|
71
|
+
* @returns {{ missing: object[], changed: object[], orphan: object[] }}
|
|
72
|
+
*/
|
|
73
|
+
export function diffLabels(existing) {
|
|
74
|
+
const byName = new Map((existing || []).map(l => [l.name, l]));
|
|
59
75
|
const missing = [];
|
|
60
76
|
const changed = [];
|
|
61
77
|
for (const label of ALL_LABELS) {
|
|
@@ -63,13 +79,90 @@ function diffLabels(existing) {
|
|
|
63
79
|
if (!cur) {
|
|
64
80
|
missing.push(label);
|
|
65
81
|
} else if (
|
|
66
|
-
cur.color.toLowerCase() !== label.color.toLowerCase() ||
|
|
82
|
+
(cur.color || '').toLowerCase() !== label.color.toLowerCase() ||
|
|
67
83
|
(cur.description || '') !== (label.description || '')
|
|
68
84
|
) {
|
|
69
85
|
changed.push(label);
|
|
70
86
|
}
|
|
71
87
|
}
|
|
72
|
-
|
|
88
|
+
const known = new Set(ALL_LABELS.map(l => l.name));
|
|
89
|
+
const orphan = (existing || []).filter(l => l.name?.startsWith('spec-wave:') && !known.has(l.name));
|
|
90
|
+
return { missing, changed, orphan };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Regenera o conteúdo do .spec-wave.json a partir do Project remoto SEM GRAVAR.
|
|
95
|
+
*
|
|
96
|
+
* A geração foi separada da gravação porque no modo --branch esse conteúdo pode
|
|
97
|
+
* precisar entrar na árvore do commit — e a árvore é montada antes de QUALQUER
|
|
98
|
+
* escrita, para que uma falha no caminho não deixe nada aplicado, nem local nem
|
|
99
|
+
* remoto.
|
|
100
|
+
*
|
|
101
|
+
* @returns {Promise<{ content: string|null, error: string|null }>}
|
|
102
|
+
*/
|
|
103
|
+
async function buildConfigContent(token, config) {
|
|
104
|
+
const snapshot = await getProjectSnapshot(token, config.project.id);
|
|
105
|
+
if (!snapshot) return { content: null, error: 'Project não encontrado' };
|
|
106
|
+
const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
|
|
107
|
+
const updated = {
|
|
108
|
+
...config,
|
|
109
|
+
version: CLI_VERSION,
|
|
110
|
+
project: {
|
|
111
|
+
...projectRest,
|
|
112
|
+
title: snapshot.title,
|
|
113
|
+
url: snapshot.url,
|
|
114
|
+
id: snapshot.id,
|
|
115
|
+
number: snapshot.number,
|
|
116
|
+
fields: snapshot.fields,
|
|
117
|
+
},
|
|
118
|
+
refreshedAt: new Date().toISOString(),
|
|
119
|
+
};
|
|
120
|
+
// MESMA serialização do init (2 espaços + \n final): este texto é comparado
|
|
121
|
+
// byte a byte com o remoto em decideConfigInPr, e um \n de diferença faria o
|
|
122
|
+
// config entrar no PR em toda execução.
|
|
123
|
+
return { content: `${JSON.stringify(updated, null, 2)}\n`, error: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Aplica o diff de labels direto na base.
|
|
128
|
+
*
|
|
129
|
+
* Label NUNCA entra num PR: é metadado do repositório, não arquivo versionado —
|
|
130
|
+
* não existe forma de propor a mudança para revisão.
|
|
131
|
+
*
|
|
132
|
+
* @returns {Promise<{created: string[], updated: string[], removed: string[]}>}
|
|
133
|
+
* só o que REALMENTE passou — é o que o corpo do PR vai afirmar a quem
|
|
134
|
+
* revisa, e ele não pode prometer o que falhou.
|
|
135
|
+
*/
|
|
136
|
+
async function applyLabels(token, owner, repo, labelDiff) {
|
|
137
|
+
const done = { created: [], updated: [], removed: [] };
|
|
138
|
+
for (const label of labelDiff.missing) {
|
|
139
|
+
try {
|
|
140
|
+
await createLabel(token, owner, repo, label);
|
|
141
|
+
done.created.push(label.name);
|
|
142
|
+
p.log.success(`Label criada: ${label.name}`);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
p.log.error(`Falha ao criar label ${label.name}: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const label of labelDiff.changed) {
|
|
148
|
+
try {
|
|
149
|
+
await updateLabel(token, owner, repo, label);
|
|
150
|
+
done.updated.push(label.name);
|
|
151
|
+
p.log.success(`Label atualizada: ${label.name}`);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
p.log.error(`Falha ao atualizar label ${label.name}: ${err.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const label of labelDiff.orphan) {
|
|
157
|
+
try {
|
|
158
|
+
await deleteLabel(token, owner, repo, label.name);
|
|
159
|
+
done.removed.push(label.name);
|
|
160
|
+
p.log.success(`Label descontinuada removida: ${label.name}`);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
p.log.error(`Falha ao remover label ${label.name}: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return done;
|
|
73
166
|
}
|
|
74
167
|
|
|
75
168
|
export async function update(options = {}) {
|
|
@@ -78,17 +171,34 @@ export async function update(options = {}) {
|
|
|
78
171
|
const isGlobal = !!options.global;
|
|
79
172
|
const baseDir = isGlobal ? homedir() : process.cwd();
|
|
80
173
|
|
|
174
|
+
// --branch: valida o nome ANTES de qualquer rede. Descobrir um espaço no nome
|
|
175
|
+
// só no createRef, depois de meia dúzia de requisições, é o pior desperdício
|
|
176
|
+
// possível — e a mensagem crua da API não aponta a causa.
|
|
177
|
+
const prRequested = options.branch !== undefined && options.branch !== false;
|
|
178
|
+
let branch = null;
|
|
179
|
+
if (prRequested) {
|
|
180
|
+
const r = resolveBranchName(options.branch, { version: CLI_VERSION });
|
|
181
|
+
if (r.error) {
|
|
182
|
+
p.log.error(r.error);
|
|
183
|
+
p.cancel('Update cancelado.');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
branch = r.branch;
|
|
187
|
+
}
|
|
188
|
+
|
|
81
189
|
// ---------- Detecção ----------
|
|
82
190
|
// 1) Skill (por agente detectado).
|
|
83
191
|
const parsed = parseSkill(readFileSync(SKILL_SOURCE, 'utf-8'));
|
|
84
192
|
const skillJobs = options.skipSkill ? [] : detectSkill(parsed, baseDir, isGlobal);
|
|
85
193
|
|
|
86
194
|
// 2) Config + repo dependem do .spec-wave.json local do repo atual.
|
|
87
|
-
const configPath =
|
|
195
|
+
const configPath = findConfigPath();
|
|
88
196
|
let config = null;
|
|
89
|
-
|
|
197
|
+
let rawConfig = null; // texto cru: comparado com o remoto em decideConfigInPr
|
|
198
|
+
if (configPath) {
|
|
90
199
|
try {
|
|
91
|
-
|
|
200
|
+
rawConfig = readFileSync(configPath, 'utf-8');
|
|
201
|
+
config = JSON.parse(rawConfig);
|
|
92
202
|
} catch (err) {
|
|
93
203
|
p.log.warn(`${CONFIG_FILE} corrompido (${err.message}); pulando config/repo.`);
|
|
94
204
|
}
|
|
@@ -123,8 +233,10 @@ export async function update(options = {}) {
|
|
|
123
233
|
}
|
|
124
234
|
|
|
125
235
|
// 2b) Arquivos do repo e labels divergentes (exige token + rede).
|
|
236
|
+
const owner = config?.owner;
|
|
237
|
+
const repo = config?.repo;
|
|
126
238
|
let repoFiles = [];
|
|
127
|
-
let labelDiff = { missing: [], changed: [] };
|
|
239
|
+
let labelDiff = { missing: [], changed: [], orphan: [] };
|
|
128
240
|
let repoChecked = false;
|
|
129
241
|
if (doRepo) {
|
|
130
242
|
const s = p.spinner();
|
|
@@ -134,7 +246,6 @@ export async function update(options = {}) {
|
|
|
134
246
|
s.stop('');
|
|
135
247
|
p.log.warn(`Sem token do GitHub (${tokenError?.message ?? 'indisponível'}); pulando verificação do repo.`);
|
|
136
248
|
} else {
|
|
137
|
-
const { owner, repo } = config;
|
|
138
249
|
try {
|
|
139
250
|
for (const f of REPO_FILES) {
|
|
140
251
|
const remote = await getFileContent(tk, owner, repo, f.repoPath);
|
|
@@ -152,9 +263,67 @@ export async function update(options = {}) {
|
|
|
152
263
|
}
|
|
153
264
|
}
|
|
154
265
|
|
|
266
|
+
// 2c) Modo PR: base remota, estado do config na base e situação da branch.
|
|
267
|
+
// TUDO leitura — precisa acontecer aqui porque o resumo e o --dry-run já
|
|
268
|
+
// mostram a branch, a base e se o config vai no PR.
|
|
269
|
+
let prMode = prRequested && doRepo && repoChecked;
|
|
270
|
+
let base = null;
|
|
271
|
+
let remoteConfigRaw = null;
|
|
272
|
+
let branchExists = false;
|
|
273
|
+
if (prMode) {
|
|
274
|
+
const tk = await getToken();
|
|
275
|
+
try {
|
|
276
|
+
base = await getRepoDefaultBranch(tk, owner, repo);
|
|
277
|
+
if (branch === base) {
|
|
278
|
+
p.log.error(
|
|
279
|
+
`--branch "${branch}" é a própria branch default do repositório; ` +
|
|
280
|
+
'um Pull Request precisa de uma branch diferente da base.'
|
|
281
|
+
);
|
|
282
|
+
p.cancel('Update cancelado.');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
remoteConfigRaw = await getFileContent(tk, owner, repo, CONFIG_FILE, base);
|
|
286
|
+
const status = await compareBranches(tk, owner, repo, base, branch);
|
|
287
|
+
branchExists = status !== null;
|
|
288
|
+
if (status === 'behind' || status === 'identical') {
|
|
289
|
+
// Não fazemos force-push: só avisamos. Empilhar um commit numa branch já
|
|
290
|
+
// mergeada funciona, mas o diff do PR pode reexibir arquivos que já estão
|
|
291
|
+
// na base (caso do squash merge).
|
|
292
|
+
p.log.warn(
|
|
293
|
+
`A branch "${branch}" já existe e não tem nenhum commit além de "${base}" ` +
|
|
294
|
+
'(provavelmente de um PR já mergeado). O commit novo seria empilhado nela — ' +
|
|
295
|
+
'use `--branch <outro-nome>` para começar do zero.'
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
p.log.warn(
|
|
300
|
+
`Não foi possível preparar o modo PR: ${err.message} — ` +
|
|
301
|
+
'os arquivos seguiriam por commit direto.'
|
|
302
|
+
);
|
|
303
|
+
prMode = false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Decisão preliminar sobre o config. O conteúdo regenerado ainda não existe;
|
|
308
|
+
// `willRegenerate` cobre isso, porque o regenerado SEMPRE difere do remoto
|
|
309
|
+
// (refreshedAt muda a cada execução). Na aplicação a decisão é recalculada com
|
|
310
|
+
// o conteúdo real.
|
|
311
|
+
const configDecision = prMode && doConfig
|
|
312
|
+
? decideConfigInPr({
|
|
313
|
+
remote: remoteConfigRaw,
|
|
314
|
+
desired: rawConfig,
|
|
315
|
+
willRegenerate: !!configStale?.canApply,
|
|
316
|
+
force: options.configInPr,
|
|
317
|
+
})
|
|
318
|
+
: { included: false, reason: '' };
|
|
319
|
+
|
|
155
320
|
// ---------- Resumo ----------
|
|
156
|
-
const labelTotal = labelDiff.missing.length + labelDiff.changed.length;
|
|
157
|
-
|
|
321
|
+
const labelTotal = labelDiff.missing.length + labelDiff.changed.length + labelDiff.orphan.length;
|
|
322
|
+
// Config em dia pela versão, mas divergente do que está na base: antes isso dava
|
|
323
|
+
// "tudo atualizado" e o repositório ficava com um config velho indefinidamente.
|
|
324
|
+
const configOnlyPr = configDecision.included && !configStale;
|
|
325
|
+
const total = skillJobs.length + (configStale ? 1 : 0) + repoFiles.length + labelTotal
|
|
326
|
+
+ (configOnlyPr ? 1 : 0);
|
|
158
327
|
|
|
159
328
|
if (total === 0) {
|
|
160
329
|
p.log.success('Tudo já está atualizado para a versão atual da CLI.');
|
|
@@ -173,23 +342,72 @@ export async function update(options = {}) {
|
|
|
173
342
|
(configStale.canApply ? '' : chalk.dim(' (sem project.id — rode `init` sem --skip-project)')));
|
|
174
343
|
}
|
|
175
344
|
if (repoFiles.length) {
|
|
176
|
-
lines.push(
|
|
345
|
+
lines.push(prMode
|
|
346
|
+
? chalk.bold(`Arquivos do repo → Pull Request (${branch} → ${base}):`)
|
|
347
|
+
: chalk.bold('Arquivos do repo:'));
|
|
177
348
|
for (const f of repoFiles) lines.push(` ${chalk.yellow('↻')} ${f.repoPath} (${f.reason})`);
|
|
178
349
|
}
|
|
350
|
+
if (prMode && configDecision.included) {
|
|
351
|
+
if (!repoFiles.length) lines.push(chalk.bold(`Pull Request (${branch} → ${base}):`));
|
|
352
|
+
lines.push(` ${chalk.yellow('↻')} ${CONFIG_FILE} (${configDecision.reason})`);
|
|
353
|
+
}
|
|
354
|
+
if (prMode && doConfig && !configDecision.included && configDecision.reason) {
|
|
355
|
+
lines.push(chalk.dim(` · ${CONFIG_FILE} fora do PR — ${configDecision.reason}`));
|
|
356
|
+
}
|
|
179
357
|
if (labelTotal) {
|
|
180
|
-
lines.push(
|
|
358
|
+
lines.push(prMode
|
|
359
|
+
? chalk.bold(`Labels (direto em ${base} — metadado do repo, não versionável):`)
|
|
360
|
+
: chalk.bold('Labels:'));
|
|
181
361
|
if (labelDiff.missing.length) lines.push(` ${chalk.yellow('+')} criar: ${labelDiff.missing.map(l => l.name).join(', ')}`);
|
|
182
362
|
if (labelDiff.changed.length) lines.push(` ${chalk.yellow('↻')} atualizar: ${labelDiff.changed.map(l => l.name).join(', ')}`);
|
|
363
|
+
if (labelDiff.orphan.length) {
|
|
364
|
+
lines.push(` ${chalk.red('−')} remover (descontinuadas): ${labelDiff.orphan.map(l => l.name).join(', ')}`);
|
|
365
|
+
}
|
|
183
366
|
}
|
|
184
367
|
p.note(lines.join('\n'), `${total} item(ns) desatualizado(s)`);
|
|
185
368
|
|
|
369
|
+
// --branch sem efeito: melhor dizer POR QUE do que criar uma branch inútil.
|
|
370
|
+
const prHasPayload = prMode && (repoFiles.length > 0 || configDecision.included);
|
|
371
|
+
if (prRequested && options.skipRepo) {
|
|
372
|
+
p.log.warn(
|
|
373
|
+
'--branch ignorado junto com --skip-repo: o Pull Request existe justamente para ' +
|
|
374
|
+
'levar os arquivos do repo.'
|
|
375
|
+
);
|
|
376
|
+
} else if (prRequested && !prMode) {
|
|
377
|
+
p.log.warn(
|
|
378
|
+
'--branch ignorado: sem token, sem owner/repo no .spec-wave.json ou a comparação com ' +
|
|
379
|
+
'o repositório falhou — não há como abrir o Pull Request.'
|
|
380
|
+
);
|
|
381
|
+
} else if (prMode && !prHasPayload) {
|
|
382
|
+
p.log.warn(
|
|
383
|
+
'--branch sem efeito: nenhum arquivo do repositório para enviar (as mudanças são ' +
|
|
384
|
+
'locais e/ou de labels). Nenhuma branch será criada.'
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
186
388
|
if (options.dryRun) {
|
|
389
|
+
if (prHasPayload) {
|
|
390
|
+
const n = repoFiles.length + (configDecision.included ? 1 : 0);
|
|
391
|
+
p.note(
|
|
392
|
+
`Branch: ${branch}${branchExists ? ' (já existe — o commit seria empilhado nela)' : ' (seria criada)'}\n` +
|
|
393
|
+
`Base: ${base}\n` +
|
|
394
|
+
`Título: ${composePrTitle({ version: CLI_VERSION })}\n` +
|
|
395
|
+
`Arquivos: ${n} em 1 commit único\n\n` +
|
|
396
|
+
'Nada foi criado: nem branch, nem commit, nem Pull Request.',
|
|
397
|
+
'Dry-run: modo Pull Request'
|
|
398
|
+
);
|
|
399
|
+
}
|
|
187
400
|
p.outro('Dry-run: nada foi alterado.');
|
|
188
401
|
return;
|
|
189
402
|
}
|
|
190
403
|
|
|
191
404
|
if (!options.yes) {
|
|
192
|
-
const ok = await p.confirm({
|
|
405
|
+
const ok = await p.confirm({
|
|
406
|
+
message: prHasPayload
|
|
407
|
+
? `Aplicar as ${total} atualização(ões) e abrir um Pull Request em "${branch}"?`
|
|
408
|
+
: `Aplicar as ${total} atualização(ões)?`,
|
|
409
|
+
initialValue: true,
|
|
410
|
+
});
|
|
193
411
|
if (p.isCancel(ok) || !ok) {
|
|
194
412
|
p.cancel('Update cancelado.');
|
|
195
413
|
return;
|
|
@@ -197,6 +415,14 @@ export async function update(options = {}) {
|
|
|
197
415
|
}
|
|
198
416
|
|
|
199
417
|
// ---------- Aplicação ----------
|
|
418
|
+
// Ordem: skill (local) → config GERADO em memória → labels (sempre direto) →
|
|
419
|
+
// arquivos do repo (PR ou commits diretos) → gravação local do config.
|
|
420
|
+
//
|
|
421
|
+
// As labels vêm ANTES dos arquivos porque o corpo do PR precisa dizer quais
|
|
422
|
+
// labels JÁ mudaram na base — é a única coisa que o update faz e o diff do PR
|
|
423
|
+
// não mostra. (Isso muda a ordem dos logs também no modo direto; os dois blocos
|
|
424
|
+
// são independentes.)
|
|
425
|
+
|
|
200
426
|
// Skill
|
|
201
427
|
for (const job of skillJobs) {
|
|
202
428
|
try {
|
|
@@ -207,7 +433,8 @@ export async function update(options = {}) {
|
|
|
207
433
|
}
|
|
208
434
|
}
|
|
209
435
|
|
|
210
|
-
// Config (.spec-wave.json) —
|
|
436
|
+
// Config (.spec-wave.json) — regenera o conteúdo, ainda sem tocar o disco.
|
|
437
|
+
let configContent = null;
|
|
211
438
|
if (configStale) {
|
|
212
439
|
if (!configStale.canApply) {
|
|
213
440
|
p.log.warn(`${CONFIG_FILE}: sem project.id — pulei. Rode \`npx @spec-wave/cli@latest init\` (sem --skip-project).`);
|
|
@@ -217,75 +444,149 @@ export async function update(options = {}) {
|
|
|
217
444
|
p.log.warn(`${CONFIG_FILE}: sem token — pulei. (${tokenError?.message ?? ''})`);
|
|
218
445
|
} else {
|
|
219
446
|
const s = p.spinner();
|
|
220
|
-
s.start('
|
|
447
|
+
s.start('Consultando o Project para atualizar o .spec-wave.json...');
|
|
221
448
|
try {
|
|
222
|
-
const
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
...config,
|
|
227
|
-
version: CLI_VERSION,
|
|
228
|
-
project: {
|
|
229
|
-
...projectRest,
|
|
230
|
-
title: snapshot.title,
|
|
231
|
-
url: snapshot.url,
|
|
232
|
-
id: snapshot.id,
|
|
233
|
-
number: snapshot.number,
|
|
234
|
-
fields: snapshot.fields,
|
|
235
|
-
},
|
|
236
|
-
refreshedAt: new Date().toISOString(),
|
|
237
|
-
};
|
|
238
|
-
writeFileSync(configPath, JSON.stringify(updated, null, 2) + '\n');
|
|
239
|
-
s.stop(`${CONFIG_FILE} atualizado (v${CLI_VERSION}).`);
|
|
449
|
+
const { content, error } = await buildConfigContent(tk, config);
|
|
450
|
+
if (error) throw new Error(error);
|
|
451
|
+
configContent = content;
|
|
452
|
+
s.stop(`${CONFIG_FILE} regenerado (v${CLI_VERSION}).`);
|
|
240
453
|
} catch (err) {
|
|
241
454
|
s.stop('');
|
|
242
|
-
p.log.error(`Falha ao
|
|
455
|
+
p.log.error(`Falha ao regerar ${CONFIG_FILE}: ${err.message}`);
|
|
243
456
|
}
|
|
244
457
|
}
|
|
245
458
|
}
|
|
246
459
|
}
|
|
247
460
|
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
461
|
+
// Recalcula a decisão com o conteúdo REAL (a preliminar usava willRegenerate).
|
|
462
|
+
const finalConfigDecision = prMode && doConfig
|
|
463
|
+
? decideConfigInPr({
|
|
464
|
+
remote: remoteConfigRaw,
|
|
465
|
+
desired: configContent ?? rawConfig,
|
|
466
|
+
force: options.configInPr,
|
|
467
|
+
})
|
|
468
|
+
: { included: false, reason: '' };
|
|
469
|
+
|
|
470
|
+
// Gravação local do config, feita UMA vez só. No modo PR com o config a bordo
|
|
471
|
+
// ela é adiada para depois do commit remoto: assim uma falha no envio não deixa
|
|
472
|
+
// o arquivo local adiantado em relação ao repositório.
|
|
473
|
+
let configFlushed = false;
|
|
474
|
+
const flushConfig = () => {
|
|
475
|
+
if (configFlushed || !configContent) return;
|
|
476
|
+
try {
|
|
477
|
+
writeFileSync(configPath, configContent);
|
|
478
|
+
configFlushed = true;
|
|
479
|
+
p.log.success(`${CONFIG_FILE} atualizado localmente (v${CLI_VERSION}).`);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
p.log.error(`Falha ao gravar ${CONFIG_FILE}: ${err.message}`);
|
|
259
482
|
}
|
|
260
|
-
}
|
|
483
|
+
};
|
|
484
|
+
if (!finalConfigDecision.included) flushConfig();
|
|
261
485
|
|
|
262
|
-
// Labels
|
|
486
|
+
// Labels — sempre direto na base (metadado do repo, não versionável).
|
|
487
|
+
let labelResult = { created: [], updated: [], removed: [] };
|
|
263
488
|
if (labelTotal) {
|
|
489
|
+
labelResult = await applyLabels(await getToken(), owner, repo, labelDiff);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Arquivos do repo
|
|
493
|
+
let prUrl = null;
|
|
494
|
+
if (prMode) {
|
|
264
495
|
const tk = await getToken();
|
|
265
|
-
const
|
|
266
|
-
|
|
496
|
+
const treeFiles = [
|
|
497
|
+
...repoFiles.map(f => ({ path: f.repoPath, reason: f.reason, content: f.local })),
|
|
498
|
+
...(finalConfigDecision.included
|
|
499
|
+
? [{
|
|
500
|
+
path: CONFIG_FILE,
|
|
501
|
+
reason: finalConfigDecision.reason,
|
|
502
|
+
content: configContent ?? rawConfig,
|
|
503
|
+
}]
|
|
504
|
+
: []),
|
|
505
|
+
];
|
|
506
|
+
if (!treeFiles.length) {
|
|
507
|
+
p.log.warn('Nada para enviar em um Pull Request: nenhum arquivo do repositório mudou.');
|
|
508
|
+
} else {
|
|
509
|
+
const s = p.spinner();
|
|
510
|
+
s.start(`Enviando ${treeFiles.length} arquivo(s) em um único commit para "${branch}"...`);
|
|
511
|
+
let commitOk = false;
|
|
267
512
|
try {
|
|
268
|
-
await
|
|
269
|
-
|
|
513
|
+
const res = await commitFilesToBranch(tk, owner, repo, {
|
|
514
|
+
branch,
|
|
515
|
+
base,
|
|
516
|
+
files: treeFiles.map(f => ({ path: f.path, content: f.content })),
|
|
517
|
+
message: buildCommitMessage({ version: CLI_VERSION, files: treeFiles }),
|
|
518
|
+
});
|
|
519
|
+
commitOk = true;
|
|
520
|
+
s.stop(res.unchanged
|
|
521
|
+
? `"${branch}" já contém estas alterações — nenhum commit novo.`
|
|
522
|
+
: `Commit ${res.commitSha.slice(0, 7)} enviado para "${branch}" (${treeFiles.length} arquivo(s)).`);
|
|
270
523
|
} catch (err) {
|
|
271
|
-
|
|
524
|
+
s.stop('');
|
|
525
|
+
p.log.error(`Falha ao enviar os arquivos para "${branch}": ${explainGitWriteError(err)}`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// O PR fica num try SEPARADO: a falha aqui não invalida o commit, e a
|
|
529
|
+
// mensagem precisa dizer que a branch existe. Um token com "Contents:
|
|
530
|
+
// write" pode não ter "Pull requests: write" — permissão NOVA neste modo.
|
|
531
|
+
if (commitOk) {
|
|
532
|
+
try {
|
|
533
|
+
const pr = await ensurePullRequest(tk, owner, repo, {
|
|
534
|
+
branch,
|
|
535
|
+
base,
|
|
536
|
+
title: composePrTitle({ version: CLI_VERSION }),
|
|
537
|
+
body: composePrBody({
|
|
538
|
+
version: CLI_VERSION,
|
|
539
|
+
base,
|
|
540
|
+
branch,
|
|
541
|
+
files: treeFiles,
|
|
542
|
+
config: doConfig ? finalConfigDecision : null,
|
|
543
|
+
labels: labelResult,
|
|
544
|
+
skill: skillJobs.map(j => j.target.name),
|
|
545
|
+
}),
|
|
546
|
+
});
|
|
547
|
+
if (!pr) {
|
|
548
|
+
p.log.warn(
|
|
549
|
+
`A branch "${branch}" está em dia com "${base}" e não há PR aberto para ela — ` +
|
|
550
|
+
'nada a revisar.'
|
|
551
|
+
);
|
|
552
|
+
} else {
|
|
553
|
+
prUrl = pr.url;
|
|
554
|
+
p.log.success(pr.created
|
|
555
|
+
? `Pull Request aberto: #${pr.number} — ${pr.url}`
|
|
556
|
+
: `Pull Request já aberto para "${branch}": #${pr.number} — ${pr.url}`);
|
|
557
|
+
}
|
|
558
|
+
flushConfig();
|
|
559
|
+
} catch (err) {
|
|
560
|
+
p.log.error(`Commit enviado, mas não foi possível abrir o PR: ${explainGitWriteError(err)}`);
|
|
561
|
+
p.log.info(
|
|
562
|
+
'Abra manualmente: ' +
|
|
563
|
+
`https://github.com/${owner}/${repo}/compare/${base}...${encodeURIComponent(branch)}?expand=1`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
272
566
|
}
|
|
273
567
|
}
|
|
274
|
-
|
|
568
|
+
} else if (repoFiles.length) {
|
|
569
|
+
const tk = await getToken();
|
|
570
|
+
for (const f of repoFiles) {
|
|
275
571
|
try {
|
|
276
|
-
await
|
|
277
|
-
p.log.success(`
|
|
572
|
+
await upsertFile(tk, owner, repo, f.repoPath, f.local, `chore: update ${path.basename(f.repoPath)} [spec-wave]`);
|
|
573
|
+
p.log.success(`Arquivo atualizado no repo: ${f.repoPath}`);
|
|
278
574
|
} catch (err) {
|
|
279
|
-
p.log.error(`Falha ao atualizar
|
|
575
|
+
p.log.error(`Falha ao atualizar ${f.repoPath}: ${err.message}`);
|
|
280
576
|
}
|
|
281
577
|
}
|
|
282
578
|
}
|
|
283
579
|
|
|
284
|
-
const
|
|
580
|
+
const configPending = configFlushed && !finalConfigDecision.included;
|
|
285
581
|
p.outro(
|
|
286
582
|
'Update concluído.' +
|
|
287
583
|
(skillJobs.length ? ' Recarregue o agente para pegar a skill nova.' : '') +
|
|
288
|
-
(
|
|
289
|
-
(
|
|
584
|
+
(prUrl ? ` Revise e faça o merge do Pull Request: ${prUrl}` : '') +
|
|
585
|
+
(!prMode && repoFiles.length ? ' Arquivos do repo foram commitados no remoto.' : '') +
|
|
586
|
+
(configPending ? ` Faça commit do ${CONFIG_FILE}.` : '') +
|
|
587
|
+
(finalConfigDecision.included
|
|
588
|
+
? ` O ${CONFIG_FILE} local ficou igual ao do PR — depois do merge, descarte a cópia ` +
|
|
589
|
+
`local com \`git checkout -- ${CONFIG_FILE}\`.`
|
|
590
|
+
: '')
|
|
290
591
|
);
|
|
291
592
|
}
|