@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Descoberta da raiz do projeto spec-wave (o diretório com .spec-wave.json).
|
|
2
|
+
//
|
|
3
|
+
// Antes, cada comando fazia `path.join(process.cwd(), CONFIG_FILE)` — mais de
|
|
4
|
+
// dez cópias da mesma linha, e nenhuma subia na árvore. O efeito prático era que
|
|
5
|
+
// qualquer comando rodado de dentro de `apps/web` falhava com "repositório não
|
|
6
|
+
// inicializado", mesmo com o config uma pasta acima. Aqui o config é procurado
|
|
7
|
+
// subindo até a raiz do filesystem, como o git faz com o .git.
|
|
8
|
+
//
|
|
9
|
+
// Achar o config é metade do problema: os documentos gerados vivem em
|
|
10
|
+
// `docs/features/<slug>/`, relativo à RAIZ, não ao cwd. Por isso `loadConfig`
|
|
11
|
+
// devolve `root` e existe `resolveFromRoot` — sem eles, rodar de um
|
|
12
|
+
// subdiretório acharia o config e erraria todos os caminhos de documento.
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { CONFIG_FILE } from '../config.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Procura o .spec-wave.json em `cwd` e nos diretórios acima (função quase pura:
|
|
20
|
+
* só toca o filesystem via existsSync).
|
|
21
|
+
*
|
|
22
|
+
* @param {string} [cwd=process.cwd()] diretório onde começar a busca
|
|
23
|
+
* @returns {string|null} caminho absoluto do arquivo, ou null se não houver
|
|
24
|
+
*/
|
|
25
|
+
export function findConfigPath(cwd = process.cwd()) {
|
|
26
|
+
let dir = path.resolve(cwd);
|
|
27
|
+
// path.dirname('/') === '/': a igualdade é o que encerra o laço na raiz.
|
|
28
|
+
for (;;) {
|
|
29
|
+
const candidate = path.join(dir, CONFIG_FILE);
|
|
30
|
+
if (existsSync(candidate)) return candidate;
|
|
31
|
+
const parent = path.dirname(dir);
|
|
32
|
+
if (parent === dir) return null;
|
|
33
|
+
dir = parent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Localiza e lê o .spec-wave.json.
|
|
39
|
+
*
|
|
40
|
+
* Nunca lança: devolve `error` legível para o chamador compor a mensagem que já
|
|
41
|
+
* exibia ("… — board não atualizado.", "Rode spec-wave init primeiro.").
|
|
42
|
+
*
|
|
43
|
+
* @param {string} [cwd=process.cwd()]
|
|
44
|
+
* @returns {{ config: object|null, root: string|null, configPath: string|null, error: string|null }}
|
|
45
|
+
* root = diretório que contém o config (âncora dos caminhos de documento)
|
|
46
|
+
*/
|
|
47
|
+
export function loadConfig(cwd = process.cwd()) {
|
|
48
|
+
const configPath = findConfigPath(cwd);
|
|
49
|
+
if (!configPath) {
|
|
50
|
+
return { config: null, root: null, configPath: null, error: `${CONFIG_FILE} não encontrado` };
|
|
51
|
+
}
|
|
52
|
+
const root = path.dirname(configPath);
|
|
53
|
+
try {
|
|
54
|
+
return { config: JSON.parse(readFileSync(configPath, 'utf-8')), root, configPath, error: null };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { config: null, root, configPath, error: `${CONFIG_FILE} corrompido (${err.message})` };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve um caminho relativo do projeto (ex.: `docs/features/<slug>`) contra a
|
|
62
|
+
* raiz descoberta, com fallback para o cwd quando não há config — assim os
|
|
63
|
+
* comandos que rodam sem `.spec-wave.json` (ou em teste) seguem funcionando.
|
|
64
|
+
*
|
|
65
|
+
* @param {string|null} root raiz devolvida por loadConfig
|
|
66
|
+
* @param {...string} parts segmentos relativos
|
|
67
|
+
* @returns {string} caminho absoluto
|
|
68
|
+
*/
|
|
69
|
+
export function resolveFromRoot(root, ...parts) {
|
|
70
|
+
return path.resolve(root || process.cwd(), ...parts);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve owner/repo: env GITHUB_REPOSITORY (padrão dos comandos rodados em
|
|
75
|
+
* Action) com fallback no .spec-wave.json — comandos locais rodam sem essa env.
|
|
76
|
+
*
|
|
77
|
+
* Era o mesmo bloco copiado em story/task/order/qa/code-review/validate.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} [cwd=process.cwd()]
|
|
80
|
+
* @returns {{ owner: string|undefined, repo: string|undefined,
|
|
81
|
+
* root: string|null, config: object|null, error: string|null }}
|
|
82
|
+
*/
|
|
83
|
+
export function resolveRepoContext(cwd = process.cwd()) {
|
|
84
|
+
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY || '').split('/');
|
|
85
|
+
const { config, root, error } = loadConfig(cwd);
|
|
86
|
+
return {
|
|
87
|
+
owner: envOwner || config?.owner,
|
|
88
|
+
repo: envRepo || config?.repo,
|
|
89
|
+
root,
|
|
90
|
+
config,
|
|
91
|
+
error,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Leitura dos templates empacotados, com resolução de placeholders.
|
|
2
|
+
//
|
|
3
|
+
// Os workflows chamavam `npx @spec-wave/cli@latest`, o que significava que uma
|
|
4
|
+
// release da CLI mudava o comportamento de pipelines já em andamento — foi o que
|
|
5
|
+
// aconteceu quando a 0.13.0 removeu o `--force` sem que nada no repositório
|
|
6
|
+
// mudasse. Agora o template traz `{{CLI_VERSION}}` e o `init`/`update` gravam a
|
|
7
|
+
// versão fixa, tornando o bump um diff explícito e revisável.
|
|
8
|
+
//
|
|
9
|
+
// A substituição PRECISA passar por aqui nos dois lados: o `init`/`setupFiles`
|
|
10
|
+
// que escreve, e o `update` que compara byte a byte com o remoto. Se só um lado
|
|
11
|
+
// resolvesse o placeholder, todo `update` veria os seis workflows como
|
|
12
|
+
// "desatualizados" para sempre.
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
export const TEMPLATES_DIR = path.join(__dir, '..', 'templates');
|
|
20
|
+
|
|
21
|
+
const pkg = JSON.parse(readFileSync(path.join(__dir, '..', '..', 'package.json'), 'utf-8'));
|
|
22
|
+
export const CLI_VERSION = pkg.version;
|
|
23
|
+
|
|
24
|
+
const PLACEHOLDERS = { CLI_VERSION };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve os placeholders `{{NOME}}` de um template (função PURA).
|
|
28
|
+
*
|
|
29
|
+
* Placeholder desconhecido é deixado intacto em vez de virar string vazia: um
|
|
30
|
+
* `{{TYPO}}` visível no arquivo gerado é muito mais fácil de diagnosticar do que
|
|
31
|
+
* um trecho que simplesmente desapareceu.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} content conteúdo cru do template
|
|
34
|
+
* @param {object} [values] sobrescreve/estende os placeholders padrão
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function renderTemplate(content, values = {}) {
|
|
38
|
+
const table = { ...PLACEHOLDERS, ...values };
|
|
39
|
+
return String(content ?? '').replace(
|
|
40
|
+
/\{\{(\w+)\}\}/g,
|
|
41
|
+
(match, key) => (key in table ? String(table[key]) : match)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Lê um template empacotado com os placeholders já resolvidos.
|
|
47
|
+
*
|
|
48
|
+
* @param {...string} parts caminho relativo dentro de src/templates
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function readTemplate(...parts) {
|
|
52
|
+
return renderTemplate(readFileSync(path.join(TEMPLATES_DIR, ...parts), 'utf-8'));
|
|
53
|
+
}
|
package/src/setup/files.mjs
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import path from 'node:path';
|
|
4
1
|
import { upsertFile, getFileContent, isRepoInitialized } from '../api/github-rest.mjs';
|
|
5
2
|
import { WORKFLOW_FILES } from '../config.mjs';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
function readTemplate(...parts) {
|
|
11
|
-
return readFileSync(path.join(TEMPLATES_DIR, ...parts), 'utf-8');
|
|
12
|
-
}
|
|
3
|
+
// readTemplate resolve {{CLI_VERSION}} — é o que fixa a versão da CLI nos
|
|
4
|
+
// workflows gravados no repo-alvo (ver src/lib/templates.mjs).
|
|
5
|
+
import { readTemplate } from '../lib/templates.mjs';
|
|
13
6
|
|
|
14
7
|
export async function setupFiles(token, owner, repo, spinner) {
|
|
15
8
|
spinner.message('Verificando repositório...');
|