@spec-wave/cli 0.14.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/bin/spec-wave.mjs CHANGED
@@ -93,6 +93,9 @@ program
93
93
  .option('--skip-skill', 'Não verifica/atualiza a skill instalada')
94
94
  .option('--skip-config', 'Não verifica/atualiza o .spec-wave.json local')
95
95
  .option('--skip-repo', 'Não verifica/atualiza workflows e labels do repo')
96
+ .option('--branch [nome]', 'Envia os arquivos do repo como Pull Request numa branch, em um único commit (sem valor: spec-wave/update-v<versão>)')
97
+ .option('--config-in-pr', 'Força incluir o .spec-wave.json no Pull Request')
98
+ .option('--no-config-in-pr', 'Força manter o .spec-wave.json fora do Pull Request')
96
99
  .option('--dry-run', 'Mostra o que seria atualizado sem alterar nada')
97
100
  .option('--yes', 'Aplica sem pedir confirmação')
98
101
  .action(async (options) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Setup spec-driven GitHub workflow with Projects v2, labels, issue templates, and AI-powered Actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,15 @@ function makeOctokit(token) {
4
4
  return new Octokit({ auth: token });
5
5
  }
6
6
 
7
+ // Para as consultas em que 404 é um RESULTADO ESPERADO ("a branch ainda não
8
+ // existe", "não há o que comparar") e não um erro: o logger padrão do Octokit
9
+ // imprime a linha `GET ... - 404` no stderr, que no meio de um spinner parece
10
+ // falha. Mesmo padrão do doctor.mjs, onde 404/403 também são respostas válidas.
11
+ const silentLog = { debug() {}, info() {}, warn() {}, error() {} };
12
+ function makeQuietOctokit(token) {
13
+ return new Octokit({ auth: token, log: silentLog });
14
+ }
15
+
7
16
  export async function getOwnerNodeId(token, owner) {
8
17
  const octokit = makeOctokit(token);
9
18
  try {
@@ -87,6 +96,189 @@ export async function upsertFile(token, owner, repo, path, content, message) {
87
96
  });
88
97
  }
89
98
 
99
+ // ---------------------------------------------------------------------------
100
+ // Git Data API — commit ATÔMICO + Pull Request
101
+ //
102
+ // O upsertFile acima faz UM COMMIT POR ARQUIVO direto na branch default: oito
103
+ // arquivos = oito commits, e uma falha no quinto deixa o repositório num estado
104
+ // intermediário que ninguém pediu. Aqui a árvore inteira é montada primeiro e só
105
+ // então nascem o commit e a ref — antes da createRef/updateRef nada é
106
+ // ALCANÇÁVEL (os objetos existem no banco, mas nenhuma ref aponta para eles),
107
+ // então uma falha no meio não muda o repositório observável.
108
+ //
109
+ // Três pegadinhas da API, todas documentadas porque custam tempo de depuração:
110
+ // • createTree SEM `base_tree` produz uma árvore com SÓ os arquivos enviados —
111
+ // o commit resultante APAGA todo o resto do repositório, e a API responde 201.
112
+ // • getRef/getCommit/updateRef querem o ref SEM o prefixo `refs/`
113
+ // ('heads/main'); createRef quer COM ('refs/heads/x'). Trocar dá 404/422 sem
114
+ // explicação.
115
+ // • pulls.list exige `head` qualificado com o owner ('owner:branch'): sem o
116
+ // prefixo a API IGNORA o filtro e devolve todos os PRs abertos.
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Lê a ponta de uma branch: sha do commit e sha da ÁRVORE desse commit.
121
+ *
122
+ * A árvore é o que interessa para o `base_tree` e para detectar "nada mudou":
123
+ * git é endereçado por conteúdo, então árvore igual = conteúdo igual, sem
124
+ * precisar pedir diff.
125
+ *
126
+ * @returns {Promise<{commitSha: string, treeSha: string}|null>} null quando a
127
+ * branch não existe (404) — o chamador decide criar vs. atualizar a ref.
128
+ */
129
+ export async function getBranchHead(token, owner, repo, branch) {
130
+ const octokit = makeQuietOctokit(token); // 404 = branch inexistente, não erro
131
+ let commitSha;
132
+ try {
133
+ const ref = await octokit.rest.git.getRef({ owner, repo, ref: `heads/${branch}` });
134
+ commitSha = ref.data.object.sha;
135
+ } catch (err) {
136
+ if (err.status === 404) return null;
137
+ throw err;
138
+ }
139
+ const commit = await octokit.rest.git.getCommit({ owner, repo, commit_sha: commitSha });
140
+ return { commitSha, treeSha: commit.data.tree.sha };
141
+ }
142
+
143
+ /**
144
+ * Compara `base...branch`.
145
+ *
146
+ * 'behind'/'identical' significa que a branch não tem nenhum commit que a base
147
+ * já não tenha — sinal típico de branch de um PR já mergeado com squash. Serve
148
+ * só para AVISAR antes de empilhar um commit numa branch obsoleta; nunca para
149
+ * decidir um force-push.
150
+ *
151
+ * @returns {Promise<'identical'|'ahead'|'behind'|'diverged'|null>} null se a
152
+ * branch não existe ou a comparação falha (é só um aviso — não pode
153
+ * derrubar o fluxo).
154
+ */
155
+ export async function compareBranches(token, owner, repo, base, branch) {
156
+ const octokit = makeQuietOctokit(token); // 404 = branch inexistente, não erro
157
+ try {
158
+ const res = await octokit.rest.repos.compareCommitsWithBasehead({
159
+ owner, repo, basehead: `${base}...${branch}`,
160
+ });
161
+ return res.data.status;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Grava N arquivos em UM único commit numa branch (criando-a a partir de `base`
169
+ * quando ainda não existe).
170
+ *
171
+ * Idempotente de propósito: rodar `update --branch fix/x` duas vezes não gera um
172
+ * segundo commit. Quando a branch já existe o pai é a PONTA DELA — não a base —
173
+ * então a atualização da ref é sempre fast-forward e `force` fica em false (uma
174
+ * corrida com outro push falha em vez de sobrescrever trabalho alheio).
175
+ *
176
+ * Se a árvore montada tiver o MESMO sha da árvore do pai, nada mudou de fato e o
177
+ * commit é pulado: commit vazio num PR só confunde quem revisa.
178
+ *
179
+ * @param {object} opts
180
+ * @param {string} opts.branch branch de trabalho (head do PR)
181
+ * @param {string} opts.base branch de partida quando `branch` ainda não existe
182
+ * @param {Array<{path: string, content: string}>} opts.files conteúdo FINAL de cada arquivo
183
+ * @param {string} opts.message mensagem do commit único
184
+ * @returns {Promise<{branch, base, commitSha, createdBranch, unchanged, baseSha}>}
185
+ */
186
+ export async function commitFilesToBranch(token, owner, repo, { branch, base, files, message }) {
187
+ const octokit = makeOctokit(token);
188
+ if (!files?.length) throw new Error('commitFilesToBranch: nenhum arquivo a enviar.');
189
+
190
+ const baseHead = await getBranchHead(token, owner, repo, base);
191
+ if (!baseHead) throw new Error(`Branch base "${base}" não encontrada em ${owner}/${repo}.`);
192
+
193
+ const head = await getBranchHead(token, owner, repo, branch);
194
+ const parent = head ?? baseHead;
195
+
196
+ // `content` inline: a própria API cria o blob, poupando um createBlob por
197
+ // arquivo. Vale só para texto UTF-8 — todos os nossos templates são.
198
+ // mode: 100644 = arquivo comum (100755 executável, 040000 subárvore).
199
+ const tree = await octokit.rest.git.createTree({
200
+ owner,
201
+ repo,
202
+ base_tree: parent.treeSha, // SEM isto o commit apagaria o repositório inteiro
203
+ tree: files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })),
204
+ });
205
+
206
+ if (tree.data.sha === parent.treeSha) {
207
+ return {
208
+ branch, base, commitSha: parent.commitSha,
209
+ createdBranch: false, unchanged: true, baseSha: baseHead.commitSha,
210
+ };
211
+ }
212
+
213
+ const commit = await octokit.rest.git.createCommit({
214
+ owner, repo, message, tree: tree.data.sha, parents: [parent.commitSha],
215
+ });
216
+
217
+ if (head) {
218
+ await octokit.rest.git.updateRef({
219
+ owner, repo, ref: `heads/${branch}`, sha: commit.data.sha, force: false,
220
+ });
221
+ } else {
222
+ await octokit.rest.git.createRef({
223
+ owner, repo, ref: `refs/heads/${branch}`, sha: commit.data.sha,
224
+ });
225
+ }
226
+
227
+ return {
228
+ branch, base, commitSha: commit.data.sha,
229
+ createdBranch: !head, unchanged: false, baseSha: baseHead.commitSha,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * PR ABERTO cuja head é `branch` neste mesmo repositório.
235
+ *
236
+ * @returns {Promise<{number, url, title, base}|null>}
237
+ */
238
+ export async function findOpenPR(token, owner, repo, branch) {
239
+ const octokit = makeOctokit(token);
240
+ const res = await octokit.rest.pulls.list({
241
+ owner, repo, state: 'open', head: `${owner}:${branch}`, per_page: 1,
242
+ });
243
+ const pr = res.data[0];
244
+ return pr
245
+ ? { number: pr.number, url: pr.html_url, title: pr.title, base: pr.base.ref }
246
+ : null;
247
+ }
248
+
249
+ /**
250
+ * Abre o PR da branch — ou devolve o que já está aberto para ela.
251
+ *
252
+ * Rodar o update duas vezes não pode virar dois PRs. O findOpenPR cobre o caso
253
+ * normal; o catch do 422 cobre a corrida e o "No commits between base and head"
254
+ * (branch sem nada novo em relação à base → não existe PR a abrir → null).
255
+ * Qualquer outro 422 propaga com a mensagem real da API, senão uma base inválida
256
+ * viraria um silencioso "nada a revisar".
257
+ *
258
+ * @returns {Promise<{number, url, created}|null>}
259
+ */
260
+ export async function ensurePullRequest(
261
+ token, owner, repo, { branch, base, title, body, draft = false }
262
+ ) {
263
+ const octokit = makeOctokit(token);
264
+ const existing = await findOpenPR(token, owner, repo, branch);
265
+ if (existing) return { ...existing, created: false };
266
+ try {
267
+ const res = await octokit.rest.pulls.create({
268
+ owner, repo, title, body, head: branch, base, draft,
269
+ });
270
+ return { number: res.data.number, url: res.data.html_url, created: true };
271
+ } catch (err) {
272
+ if (err.status !== 422) throw err;
273
+ const again = await findOpenPR(token, owner, repo, branch);
274
+ if (again) return { ...again, created: false };
275
+ const detail = err.response?.data?.errors?.map(e => e.message).filter(Boolean).join('; ')
276
+ || err.message;
277
+ if (/No commits between/i.test(detail)) return null;
278
+ throw new Error(detail);
279
+ }
280
+ }
281
+
90
282
  // `id` é o database id da issue — exigido pela API de dependências
91
283
  // (blocked_by), que não aceita number nem node id.
92
284
  export async function createIssue(token, owner, repo, title, body, labels) {
@@ -235,10 +427,14 @@ export async function getPR(token, owner, repo, prNumber) {
235
427
  return res.data;
236
428
  }
237
429
 
238
- export async function getFileContent(token, owner, repo, path) {
430
+ // `ref` é opcional (compatível com os chamadores de 4 argumentos): o modo PR do
431
+ // `update` precisa ler o .spec-wave.json na BASE, não no que a API escolher.
432
+ export async function getFileContent(token, owner, repo, path, ref) {
239
433
  const octokit = makeOctokit(token);
240
434
  try {
241
- const res = await octokit.rest.repos.getContent({ owner, repo, path });
435
+ const res = await octokit.rest.repos.getContent({
436
+ owner, repo, path, ...(ref ? { ref } : {}),
437
+ });
242
438
  return Buffer.from(res.data.content, 'base64').toString('utf-8');
243
439
  } catch (err) {
244
440
  if (err.status === 404) return null;
@@ -8,7 +8,12 @@ import { CONFIG_FILE, WORKFLOW_FILES, ISSUE_TEMPLATE_FILES, ALL_LABELS } from '.
8
8
  import { getProjectSnapshot } from '../api/github-graphql.mjs';
9
9
  import {
10
10
  getFileContent, upsertFile, listLabels, createLabel, updateLabel, deleteLabel,
11
+ getRepoDefaultBranch, compareBranches, commitFilesToBranch, ensurePullRequest,
11
12
  } from '../api/github-rest.mjs';
13
+ import {
14
+ resolveBranchName, composePrTitle, composePrBody, buildCommitMessage,
15
+ decideConfigInPr, explainGitWriteError,
16
+ } from '../lib/pr-branch.mjs';
12
17
  // MESMO readTemplate do init: resolve {{CLI_VERSION}} antes da comparação byte a
13
18
  // byte com o remoto. Se só o init resolvesse, todo update veria os workflows
14
19
  // como desatualizados para sempre.
@@ -85,12 +90,102 @@ export function diffLabels(existing) {
85
90
  return { missing, changed, orphan };
86
91
  }
87
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;
166
+ }
167
+
88
168
  export async function update(options = {}) {
89
169
  p.intro(chalk.bold(`spec-wave update (CLI v${CLI_VERSION})`));
90
170
 
91
171
  const isGlobal = !!options.global;
92
172
  const baseDir = isGlobal ? homedir() : process.cwd();
93
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
+
94
189
  // ---------- Detecção ----------
95
190
  // 1) Skill (por agente detectado).
96
191
  const parsed = parseSkill(readFileSync(SKILL_SOURCE, 'utf-8'));
@@ -99,9 +194,11 @@ export async function update(options = {}) {
99
194
  // 2) Config + repo dependem do .spec-wave.json local do repo atual.
100
195
  const configPath = findConfigPath();
101
196
  let config = null;
197
+ let rawConfig = null; // texto cru: comparado com o remoto em decideConfigInPr
102
198
  if (configPath) {
103
199
  try {
104
- config = JSON.parse(readFileSync(configPath, 'utf-8'));
200
+ rawConfig = readFileSync(configPath, 'utf-8');
201
+ config = JSON.parse(rawConfig);
105
202
  } catch (err) {
106
203
  p.log.warn(`${CONFIG_FILE} corrompido (${err.message}); pulando config/repo.`);
107
204
  }
@@ -136,6 +233,8 @@ export async function update(options = {}) {
136
233
  }
137
234
 
138
235
  // 2b) Arquivos do repo e labels divergentes (exige token + rede).
236
+ const owner = config?.owner;
237
+ const repo = config?.repo;
139
238
  let repoFiles = [];
140
239
  let labelDiff = { missing: [], changed: [], orphan: [] };
141
240
  let repoChecked = false;
@@ -147,7 +246,6 @@ export async function update(options = {}) {
147
246
  s.stop('');
148
247
  p.log.warn(`Sem token do GitHub (${tokenError?.message ?? 'indisponível'}); pulando verificação do repo.`);
149
248
  } else {
150
- const { owner, repo } = config;
151
249
  try {
152
250
  for (const f of REPO_FILES) {
153
251
  const remote = await getFileContent(tk, owner, repo, f.repoPath);
@@ -165,9 +263,67 @@ export async function update(options = {}) {
165
263
  }
166
264
  }
167
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
+
168
320
  // ---------- Resumo ----------
169
321
  const labelTotal = labelDiff.missing.length + labelDiff.changed.length + labelDiff.orphan.length;
170
- const total = skillJobs.length + (configStale ? 1 : 0) + repoFiles.length + labelTotal;
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);
171
327
 
172
328
  if (total === 0) {
173
329
  p.log.success('Tudo já está atualizado para a versão atual da CLI.');
@@ -186,11 +342,22 @@ export async function update(options = {}) {
186
342
  (configStale.canApply ? '' : chalk.dim(' (sem project.id — rode `init` sem --skip-project)')));
187
343
  }
188
344
  if (repoFiles.length) {
189
- lines.push(chalk.bold('Arquivos do repo:'));
345
+ lines.push(prMode
346
+ ? chalk.bold(`Arquivos do repo → Pull Request (${branch} → ${base}):`)
347
+ : chalk.bold('Arquivos do repo:'));
190
348
  for (const f of repoFiles) lines.push(` ${chalk.yellow('↻')} ${f.repoPath} (${f.reason})`);
191
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
+ }
192
357
  if (labelTotal) {
193
- lines.push(chalk.bold('Labels:'));
358
+ lines.push(prMode
359
+ ? chalk.bold(`Labels (direto em ${base} — metadado do repo, não versionável):`)
360
+ : chalk.bold('Labels:'));
194
361
  if (labelDiff.missing.length) lines.push(` ${chalk.yellow('+')} criar: ${labelDiff.missing.map(l => l.name).join(', ')}`);
195
362
  if (labelDiff.changed.length) lines.push(` ${chalk.yellow('↻')} atualizar: ${labelDiff.changed.map(l => l.name).join(', ')}`);
196
363
  if (labelDiff.orphan.length) {
@@ -199,13 +366,48 @@ export async function update(options = {}) {
199
366
  }
200
367
  p.note(lines.join('\n'), `${total} item(ns) desatualizado(s)`);
201
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
+
202
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
+ }
203
400
  p.outro('Dry-run: nada foi alterado.');
204
401
  return;
205
402
  }
206
403
 
207
404
  if (!options.yes) {
208
- const ok = await p.confirm({ message: `Aplicar as ${total} atualização(ões)?`, initialValue: true });
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
+ });
209
411
  if (p.isCancel(ok) || !ok) {
210
412
  p.cancel('Update cancelado.');
211
413
  return;
@@ -213,6 +415,14 @@ export async function update(options = {}) {
213
415
  }
214
416
 
215
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
+
216
426
  // Skill
217
427
  for (const job of skillJobs) {
218
428
  try {
@@ -223,7 +433,8 @@ export async function update(options = {}) {
223
433
  }
224
434
  }
225
435
 
226
- // Config (.spec-wave.json) — reconsulta o Project e reescreve local.
436
+ // Config (.spec-wave.json) — regenera o conteúdo, ainda sem tocar o disco.
437
+ let configContent = null;
227
438
  if (configStale) {
228
439
  if (!configStale.canApply) {
229
440
  p.log.warn(`${CONFIG_FILE}: sem project.id — pulei. Rode \`npx @spec-wave/cli@latest init\` (sem --skip-project).`);
@@ -233,83 +444,149 @@ export async function update(options = {}) {
233
444
  p.log.warn(`${CONFIG_FILE}: sem token — pulei. (${tokenError?.message ?? ''})`);
234
445
  } else {
235
446
  const s = p.spinner();
236
- s.start('Atualizando .spec-wave.json...');
447
+ s.start('Consultando o Project para atualizar o .spec-wave.json...');
237
448
  try {
238
- const snapshot = await getProjectSnapshot(tk, config.project.id);
239
- if (!snapshot) throw new Error('Project não encontrado');
240
- const { etapaFieldId: _e, stageOptions: _s, ...projectRest } = config.project;
241
- const updated = {
242
- ...config,
243
- version: CLI_VERSION,
244
- project: {
245
- ...projectRest,
246
- title: snapshot.title,
247
- url: snapshot.url,
248
- id: snapshot.id,
249
- number: snapshot.number,
250
- fields: snapshot.fields,
251
- },
252
- refreshedAt: new Date().toISOString(),
253
- };
254
- writeFileSync(configPath, JSON.stringify(updated, null, 2) + '\n');
255
- 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}).`);
256
453
  } catch (err) {
257
454
  s.stop('');
258
- p.log.error(`Falha ao atualizar ${CONFIG_FILE}: ${err.message}`);
455
+ p.log.error(`Falha ao regerar ${CONFIG_FILE}: ${err.message}`);
259
456
  }
260
457
  }
261
458
  }
262
459
  }
263
460
 
264
- // Arquivos do repo
265
- if (repoFiles.length) {
266
- const tk = await getToken();
267
- const { owner, repo } = config;
268
- for (const f of repoFiles) {
269
- try {
270
- await upsertFile(tk, owner, repo, f.repoPath, f.local, `chore: update ${path.basename(f.repoPath)} [spec-wave]`);
271
- p.log.success(`Arquivo atualizado no repo: ${f.repoPath}`);
272
- } catch (err) {
273
- p.log.error(`Falha ao atualizar ${f.repoPath}: ${err.message}`);
274
- }
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}`);
275
482
  }
276
- }
483
+ };
484
+ if (!finalConfigDecision.included) flushConfig();
277
485
 
278
- // Labels
486
+ // Labels — sempre direto na base (metadado do repo, não versionável).
487
+ let labelResult = { created: [], updated: [], removed: [] };
279
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) {
280
495
  const tk = await getToken();
281
- const { owner, repo } = config;
282
- for (const label of labelDiff.missing) {
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;
283
512
  try {
284
- await createLabel(tk, owner, repo, label);
285
- p.log.success(`Label criada: ${label.name}`);
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)).`);
286
523
  } catch (err) {
287
- p.log.error(`Falha ao criar label ${label.name}: ${err.message}`);
524
+ s.stop('');
525
+ p.log.error(`Falha ao enviar os arquivos para "${branch}": ${explainGitWriteError(err)}`);
288
526
  }
289
- }
290
- for (const label of labelDiff.changed) {
291
- try {
292
- await updateLabel(tk, owner, repo, label);
293
- p.log.success(`Label atualizada: ${label.name}`);
294
- } catch (err) {
295
- p.log.error(`Falha ao atualizar label ${label.name}: ${err.message}`);
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
+ }
296
566
  }
297
567
  }
298
- for (const label of labelDiff.orphan) {
568
+ } else if (repoFiles.length) {
569
+ const tk = await getToken();
570
+ for (const f of repoFiles) {
299
571
  try {
300
- await deleteLabel(tk, owner, repo, label.name);
301
- p.log.success(`Label descontinuada removida: ${label.name}`);
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}`);
302
574
  } catch (err) {
303
- p.log.error(`Falha ao remover label ${label.name}: ${err.message}`);
575
+ p.log.error(`Falha ao atualizar ${f.repoPath}: ${err.message}`);
304
576
  }
305
577
  }
306
578
  }
307
579
 
308
- const committedRepo = repoFiles.length > 0;
580
+ const configPending = configFlushed && !finalConfigDecision.included;
309
581
  p.outro(
310
582
  'Update concluído.' +
311
583
  (skillJobs.length ? ' Recarregue o agente para pegar a skill nova.' : '') +
312
- (configStale?.canApply ? ` Faça commit do ${CONFIG_FILE}.` : '') +
313
- (committedRepo ? ' Arquivos do repo foram commitados no remoto.' : '')
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
+ : '')
314
591
  );
315
592
  }
@@ -0,0 +1,267 @@
1
+ // Regras PURAS do modo `--branch` do update (arquivos do repo via Pull Request).
2
+ //
3
+ // Até aqui o `update` era a única exceção ao fluxo de PR: commitava os arquivos
4
+ // do repo direto na branch default, um commit por arquivo. Em repositório com
5
+ // proteção de branch isso falha no meio da execução e deixa parte aplicada; e
6
+ // mesmo sem proteção, contorna a revisão que todo o resto do fluxo tem.
7
+ //
8
+ // Tudo aqui é puro de propósito. A suíte não tem nenhuma infraestrutura de mock
9
+ // de HTTP, então a única forma de testar o modo PR é manter as DECISÕES (nome da
10
+ // branch, o que entra no PR, o que o corpo do PR conta a quem revisa) separadas
11
+ // das chamadas de rede, que ficam em api/github-rest.mjs.
12
+
13
+ import { CLI_VERSION } from './templates.mjs';
14
+ import { CONFIG_FILE } from '../config.mjs';
15
+
16
+ /**
17
+ * Nome automático da branch quando `--branch` vem sem valor.
18
+ *
19
+ * Deliberadamente SEM timestamp: rodar o update duas vezes para a MESMA versão
20
+ * da CLI tem que reaproveitar o mesmo PR, não espalhar uma branch nova por
21
+ * tentativa. O bump de versão é o que separa uma atualização da próxima, e ele
22
+ * já está no nome.
23
+ *
24
+ * @param {string} [version]
25
+ * @returns {string}
26
+ */
27
+ export function autoBranchName(version = CLI_VERSION) {
28
+ return `spec-wave/update-v${version}`;
29
+ }
30
+
31
+ // Proibições de `git check-ref-format` para refs/heads/<nome>: caractere
32
+ // de controle e espaço (\u0000-\u0020), DEL (\u007f) e os
33
+ // metacaracteres de revisão. Escapes explícitos de propósito: caractere de
34
+ // controle literal no fonte é invisível e não sobrevive a copiar/colar.
35
+ const REF_FORBIDDEN = /[\u0000-\u0020\u007f~^:?*[\\]/;
36
+
37
+ const MAX_BRANCH_LENGTH = 200;
38
+
39
+ /**
40
+ * Resolve e VALIDA o nome da branch (função PURA).
41
+ *
42
+ * Validar aqui, antes de qualquer chamada de rede, evita o pior desperdício
43
+ * possível: gastar meia dúzia de requisições de comparação e só descobrir no
44
+ * `createRef` que o nome tinha um espaço — com a mensagem crua da API, longe da
45
+ * causa.
46
+ *
47
+ * Mesmo contrato de `resolveStageName` em lib/board.mjs: `{valor, error}`.
48
+ *
49
+ * @param {string|boolean|undefined|null} value valor de `options.branch` — o
50
+ * commander entrega `true` para `--branch` sem valor
51
+ * @param {object} [opts]
52
+ * @param {string} [opts.version] versão usada no nome automático
53
+ * @returns {{ branch: string|null, error: string|null }}
54
+ */
55
+ export function resolveBranchName(value, { version = CLI_VERSION } = {}) {
56
+ if (value === true || value === undefined || value === null || String(value).trim() === '') {
57
+ return { branch: autoBranchName(version), error: null };
58
+ }
59
+
60
+ let branch = String(value).trim();
61
+ // Erro comum: colar o ref completo em vez do nome. Corrigir é mais útil que recusar.
62
+ if (branch.startsWith('refs/heads/')) branch = branch.slice('refs/heads/'.length);
63
+
64
+ const bad = (why) => ({ branch: null, error: `Nome de branch inválido ("${branch}"): ${why}.` });
65
+
66
+ if (!branch) return bad('nome vazio');
67
+ if (branch.length > MAX_BRANCH_LENGTH) {
68
+ return bad(`nome longo demais (máximo ${MAX_BRANCH_LENGTH} caracteres)`);
69
+ }
70
+ if (REF_FORBIDDEN.test(branch)) {
71
+ return bad('não pode conter espaço, caractere de controle ou ~ ^ : ? * [ \\');
72
+ }
73
+ if (branch.includes('..')) return bad('não pode conter ".."');
74
+ if (branch.includes('@{')) return bad('não pode conter "@{"');
75
+ if (branch === '@') return bad('não pode ser apenas "@"');
76
+ if (branch.startsWith('/') || branch.endsWith('/')) return bad('não pode começar nem terminar com "/"');
77
+ if (branch.includes('//')) return bad('não pode conter "//"');
78
+ if (branch.startsWith('-')) return bad('não pode começar com "-"');
79
+ if (branch.endsWith('.')) return bad('não pode terminar com "."');
80
+ if (branch.toUpperCase() === 'HEAD') return bad('"HEAD" é reservado');
81
+ for (const part of branch.split('/')) {
82
+ if (part.startsWith('.')) return bad('nenhum componente pode começar com "."');
83
+ if (part.endsWith('.lock')) return bad('nenhum componente pode terminar com ".lock"');
84
+ }
85
+ return { branch, error: null };
86
+ }
87
+
88
+ /**
89
+ * Título do PR — e assunto do commit único (função PURA).
90
+ *
91
+ * A mesma frase nos dois lugares de propósito: no modo PR existe um commit só, e
92
+ * ver textos diferentes para a mesma mudança na lista de commits e no título do
93
+ * PR só gera dúvida.
94
+ */
95
+ export function composePrTitle({ version = CLI_VERSION } = {}) {
96
+ return `chore(spec-wave): atualiza arquivos do repo para v${version}`;
97
+ }
98
+
99
+ /**
100
+ * Mensagem do commit ÚNICO (função PURA).
101
+ *
102
+ * O corpo lista arquivo + motivo porque este é o único commit da branch: ele
103
+ * precisa se explicar sozinho num `git log` feito seis meses depois, sem o PR
104
+ * aberto ao lado.
105
+ *
106
+ * @param {object} [a]
107
+ * @param {string} [a.version]
108
+ * @param {Array<{path: string, reason: string}>} [a.files]
109
+ * @returns {string}
110
+ */
111
+ export function buildCommitMessage({ version = CLI_VERSION, files = [] } = {}) {
112
+ const subject = composePrTitle({ version });
113
+ if (!files.length) return `${subject}\n`;
114
+ return `${subject}\n\n${files.map(f => `- ${f.path} (${f.reason})`).join('\n')}\n`;
115
+ }
116
+
117
+ /**
118
+ * Corpo do PR (função PURA).
119
+ *
120
+ * Quem revisa precisa saber DUAS coisas que o diff não mostra:
121
+ * • as labels já foram aplicadas direto na base — label é metadado do
122
+ * repositório, não existe forma de versioná-la num PR, então ela mudou ANTES
123
+ * de alguém revisar isto;
124
+ * • a skill dos agentes foi atualizada em máquina local, fora do repositório.
125
+ * Sem esses dois blocos o PR parece ser a totalidade do que o update fez — e não é.
126
+ *
127
+ * @param {object} [a]
128
+ * @param {Array<{path: string, reason: string}>} [a.files] arquivos que ENTRARAM no commit
129
+ * @param {{included: boolean, reason: string}|null} [a.config] decisão sobre o .spec-wave.json
130
+ * @param {{created: string[], updated: string[], removed: string[]}|null} [a.labels]
131
+ * labels EFETIVAMENTE aplicadas (não o diff detectado — o corpo não pode
132
+ * prometer o que falhou)
133
+ * @param {string[]} [a.skill] nomes dos agentes cuja skill foi atualizada localmente
134
+ * @returns {string} markdown
135
+ */
136
+ export function composePrBody({
137
+ version = CLI_VERSION, base = '?', branch = '?',
138
+ files = [], config = null, labels = null, skill = [],
139
+ } = {}) {
140
+ const l = [];
141
+ l.push(`Atualização gerada por \`spec-wave update\` (CLI v${version}).`);
142
+ l.push('');
143
+ l.push(`Base: \`${base}\` · Branch: \`${branch}\``);
144
+ l.push('');
145
+ l.push('## Arquivos do repositório');
146
+ l.push('');
147
+ if (files.length) for (const f of files) l.push(`- \`${f.path}\` — ${f.reason}`);
148
+ else l.push('_Nenhum._');
149
+
150
+ if (config) {
151
+ l.push('');
152
+ l.push(`## ${CONFIG_FILE}`);
153
+ l.push('');
154
+ l.push(config.included
155
+ ? `Incluído neste PR — ${config.reason}.`
156
+ : `**Fora** deste PR — ${config.reason}.`);
157
+ }
158
+
159
+ const labelTotal = labels
160
+ ? labels.created.length + labels.updated.length + labels.removed.length
161
+ : 0;
162
+ if (labelTotal) {
163
+ l.push('');
164
+ l.push(`## Labels — JÁ aplicadas em \`${base}\`, fora deste PR`);
165
+ l.push('');
166
+ l.push(
167
+ 'Label é metadado do repositório e não pode ser versionada: estas mudanças já ' +
168
+ 'valem, com ou sem o merge deste PR.'
169
+ );
170
+ l.push('');
171
+ if (labels.created.length) l.push(`- criadas: ${labels.created.map(n => `\`${n}\``).join(', ')}`);
172
+ if (labels.updated.length) l.push(`- atualizadas: ${labels.updated.map(n => `\`${n}\``).join(', ')}`);
173
+ if (labels.removed.length) l.push(`- removidas (descontinuadas): ${labels.removed.map(n => `\`${n}\``).join(', ')}`);
174
+ }
175
+
176
+ if (skill.length) {
177
+ l.push('');
178
+ l.push('## Skill dos agentes — fora deste PR');
179
+ l.push('');
180
+ l.push(
181
+ `Atualizada localmente em: ${skill.map(n => `\`${n}\``).join(', ')}. ` +
182
+ 'Não faz parte do repositório.'
183
+ );
184
+ }
185
+
186
+ l.push('');
187
+ l.push('---');
188
+ l.push(`Depois do merge, \`npx @spec-wave/cli@${version} update --dry-run\` deve reportar tudo em dia.`);
189
+ return `${l.join('\n')}\n`;
190
+ }
191
+
192
+ /**
193
+ * O .spec-wave.json entra no PR? (função PURA)
194
+ *
195
+ * Política herdada do init (ver o comentário em commands/init.mjs): o config é
196
+ * gravado LOCAL de propósito. Quem não o commitou escolheu mantê-lo fora do
197
+ * versionamento, e passar a versioná-lo é decisão de projeto — não pode ser
198
+ * efeito colateral de um `update`. Logo: só entra se JÁ estiver versionado na
199
+ * base. `--config-in-pr` / `--no-config-in-pr` forçam os dois lados.
200
+ *
201
+ * Cobre também o caso em que o config NÃO está desatualizado pela versão mas o
202
+ * arquivo local difere do que está na base (alguém regenerou e esqueceu de
203
+ * commitar): entra igual, porque é exatamente para isso que o PR serve.
204
+ *
205
+ * @param {object} [a]
206
+ * @param {string|null|undefined} [a.remote] conteúdo do config na base (null/undefined = não versionado)
207
+ * @param {string|null} [a.desired] conteúdo que deveria estar lá
208
+ * @param {boolean} [a.willRegenerate] o conteúdo ainda vai ser regenerado (no
209
+ * resumo e no dry-run ele não existe); o regenerado SEMPRE difere do
210
+ * remoto, porque `refreshedAt` muda a cada execução
211
+ * @param {boolean|undefined} [a.force] undefined | true (--config-in-pr) | false (--no-config-in-pr)
212
+ * @returns {{ included: boolean, reason: string }}
213
+ */
214
+ export function decideConfigInPr({ remote, desired, willRegenerate = false, force } = {}) {
215
+ const versioned = remote !== null && remote !== undefined;
216
+ if (force === false) return { included: false, reason: '--no-config-in-pr' };
217
+ if (!desired && !willRegenerate) {
218
+ return { included: false, reason: 'sem conteúdo local para enviar' };
219
+ }
220
+ if (force === true) {
221
+ return versioned
222
+ ? { included: true, reason: '--config-in-pr' }
223
+ : { included: true, reason: `--config-in-pr (passa a versionar o ${CONFIG_FILE})` };
224
+ }
225
+ if (!versioned) {
226
+ return {
227
+ included: false,
228
+ reason: 'não está versionado na base, segue apenas local ' +
229
+ '(use --config-in-pr para versioná-lo)',
230
+ };
231
+ }
232
+ if (!willRegenerate && remote === desired) {
233
+ return { included: false, reason: 'já idêntico na base' };
234
+ }
235
+ return { included: true, reason: 'versionado na base e divergente do local' };
236
+ }
237
+
238
+ /**
239
+ * Traduz o erro cru da Git Data API para uma dica acionável (função PURA).
240
+ *
241
+ * Sem isto o usuário vê "Not Found" num POST e conclui que o repositório não
242
+ * existe, quando o problema é um token sem escrita em Contents — e "Resource not
243
+ * accessible" não diz QUAL permissão falta.
244
+ *
245
+ * @param {{status?: number, message?: string}} err
246
+ * @returns {string}
247
+ */
248
+ export function explainGitWriteError(err) {
249
+ const msg = err?.message || 'erro desconhecido';
250
+ switch (err?.status) {
251
+ case 401:
252
+ return `${msg} — token inválido ou expirado.`;
253
+ case 403:
254
+ return `${msg} — o token não tem permissão de escrita. Precisa de "Contents: write" e ` +
255
+ '"Pull requests: write" (fine-grained) ou do escopo `repo` (classic).';
256
+ case 404:
257
+ return `${msg} — repositório ou branch inexistente, OU token sem acesso a este ` +
258
+ 'repositório (o GitHub responde 404 em vez de 403 para não revelar repos privados).';
259
+ case 409:
260
+ return `${msg} — repositório vazio (sem nenhum commit): rode \`spec-wave init\` antes.`;
261
+ case 422:
262
+ return `${msg} — o GitHub recusou a operação. Causas comuns: regra de proteção/ruleset ` +
263
+ 'na branch (commits da API não são assinados) ou branch já em dia.';
264
+ default:
265
+ return msg;
266
+ }
267
+ }
@@ -180,11 +180,19 @@ Mesmas flags do `issue` (exceto `--type`, fixo em `feature`). Mantido para o flu
180
180
  |------|------|-----------|
181
181
  | `--global` | flag | Verifica a skill no escopo do usuário (padrão: projeto). |
182
182
  | `--skip-skill` / `--skip-config` / `--skip-repo` | flag | Pula a categoria correspondente. |
183
+ | `--branch [nome]` | flag/string | Envia os arquivos do repo como **Pull Request** numa branch, em **um único commit**, em vez de commitar direto na branch default. Sem valor, usa `spec-wave/update-v<versão>`. |
184
+ | `--config-in-pr` / `--no-config-in-pr` | flag | Força incluir/excluir o `.spec-wave.json` do PR (o default decide sozinho — veja abaixo). |
183
185
  | `--dry-run` | flag | Mostra o que seria atualizado sem alterar nada. |
184
186
  | `--yes` | flag | Aplica sem pedir confirmação. |
185
187
 
186
188
  > Detecta e atualiza **somente o que divergiu** da versão atual da CLI: a **skill** instalada (por agente), o **`.spec-wave.json`** local (se versão/formato divergir) e os **workflows/labels** do repo (compara com os templates empacotados). Interativo por padrão (mostra o plano e confirma). É o atalho recomendado após atualizar a CLI.
187
189
 
190
+ > **`--branch` (modo Pull Request).** Sem a flag, os arquivos do repo vão em **commits diretos na branch default** — o que falha no meio da execução em repositório com proteção de branch, deixando parte aplicada, e contorna a revisão. Com a flag, todos os arquivos vão em **um commit atômico** numa branch nova e um PR é aberto: se algo falhar antes da criação da branch, **nada** é alterado no repositório. É **idempotente** — rodar duas vezes não gera um segundo commit nem um segundo PR.
191
+ >
192
+ > Duas coisas **não** entram no PR, porque não são versionáveis: as **labels** (metadado do repositório — já valem na base, com ou sem merge) e a **skill** (arquivo em máquina local). O corpo do PR diz isso explicitamente a quem revisa.
193
+ >
194
+ > ⚠️ O modo PR exige **`pull_requests: write`** além de `contents: write` (ou o escopo `repo` num PAT classic). Sem essa permissão, a branch é criada e o PR falha — a mensagem oferece a URL de `compare` para abrir à mão.
195
+
188
196
  ### `@spec-wave/cli generate-plan` · `generate-spec` · `validate` · `decompose`
189
197
  | Flag | Tipo | Descrição |
190
198
  |------|------|-----------|
@@ -417,12 +425,16 @@ Traz tudo para a versão atual da CLI, atualizando **só o que mudou**: a skill
417
425
  npx @spec-wave/cli@latest update --dry-run
418
426
  ```
419
427
  2. Mostre ao usuário o resumo (skill / config / arquivos do repo / labels que divergiram). Se **nada** estiver desatualizado, informe que já está tudo na versão atual e encerre.
420
- 3. Se o usuário aprovar, aplique:
428
+ 3. **Pergunte como os arquivos do repo devem sair** — e prefira o Pull Request:
421
429
  ```bash
422
- npx @spec-wave/cli@latest update --yes
430
+ npx @spec-wave/cli@latest update --yes --branch # 1 commit atômico + PR (recomendado)
431
+ npx @spec-wave/cli@latest update --yes # commits diretos na branch default
423
432
  ```
424
433
  - Escopos podem ser limitados com `--skip-skill`, `--skip-config`, `--skip-repo`.
425
- - Atualizações de **arquivos do repo** são commitadas no remoto; o **`.spec-wave.json`** é local (lembre o usuário de commitá-lo).
434
+ - **Com `--branch`:** os arquivos do repo vão em **um único commit** numa branch nova e um PR é aberto nada é escrito na branch default. Passe o link do PR ao usuário e lembre que o merge é dele. Exige `pull_requests: write` no token.
435
+ - **Sem `--branch`:** cada arquivo é um commit direto na branch default. Em repositório com **proteção de branch** isso falha no meio e deixa parte aplicada — nesses casos use `--branch`.
436
+ - **Nos dois modos**, as **labels** são aplicadas direto na base (metadado do repositório, não versionável) e a **skill** é gravada em máquina local. Nenhuma das duas entra no PR.
437
+ - **`.spec-wave.json`:** com `--branch`, ele entra no PR se o repositório **já o versiona**; se não versiona, segue apenas local e o usuário precisa commitá-lo (ou use `--config-in-pr` para passar a versioná-lo). Sem `--branch`, é sempre só local.
426
438
  4. Se a skill foi atualizada, oriente recarregar/reiniciar o agente para pegar a nova versão.
427
439
 
428
440
  ---