@spec-wave/cli 0.24.0 → 0.25.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 CHANGED
@@ -113,6 +113,24 @@ Arquivo em `.github/config/tech_context.yml` que descreve a stack tecnológica d
113
113
 
114
114
  ---
115
115
 
116
+ ## Modo de execução: GitHub Actions ou local
117
+
118
+ Todo passo do fluxo roda nos dois lugares — os workflows só instalam a CLI e
119
+ chamam um comando. Para conduzir tudo sem consumir minutos de Actions:
120
+
121
+ ```bash
122
+ npx @spec-wave/cli@latest mode local # desarma os workflows (variável SPEC_WAVE_EXECUTION)
123
+ npx @spec-wave/cli@latest run <issue> # executa aqui o passo que a label dispararia
124
+ npx @spec-wave/cli@latest mode actions # volta ao CI
125
+ ```
126
+
127
+ Com a variável setada, cada job é pulado e o run aparece como *skipped* — job que
128
+ não roda não é faturado. `run <issue> --dry-run` explica o próximo passo (e o
129
+ porquê) sem executar nada. Detalhes em
130
+ [`packages/spec-wave/README.md`](packages/spec-wave/README.md#modo-de-execução-actions-ou-local).
131
+
132
+ ---
133
+
116
134
  ## Instalação da CLI
117
135
 
118
136
  Não é necessário instalar globalmente — use `npx`:
@@ -130,6 +148,24 @@ spec-wave --help
130
148
 
131
149
  ---
132
150
 
151
+ ## Modo de execução: GitHub Actions ou local
152
+
153
+ Todo passo do fluxo roda nos dois lugares — os workflows só instalam a CLI e
154
+ chamam um comando. Para conduzir tudo sem consumir minutos de Actions:
155
+
156
+ ```bash
157
+ npx @spec-wave/cli@latest mode local # desarma os workflows (variável SPEC_WAVE_EXECUTION)
158
+ npx @spec-wave/cli@latest run <issue> # executa aqui o passo que a label dispararia
159
+ npx @spec-wave/cli@latest mode actions # volta ao CI
160
+ ```
161
+
162
+ Com a variável setada, cada job é pulado e o run aparece como *skipped* — job que
163
+ não roda não é faturado. `run <issue> --dry-run` explica o próximo passo (e o
164
+ porquê) sem executar nada. Detalhes em
165
+ [`packages/spec-wave/README.md`](packages/spec-wave/README.md#modo-de-execução-actions-ou-local).
166
+
167
+ ---
168
+
133
169
  ## Instalação da Skill
134
170
 
135
171
  A skill permite usar o fluxo diretamente no seu agente via `/spec-wave`.
@@ -438,6 +474,54 @@ job falha por indisponibilidade, não por erro de configuração.
438
474
 
439
475
  ---
440
476
 
477
+ ## Modo de execução: Actions ou local
478
+
479
+ Os workflows nunca fizeram o trabalho — eles instalam a CLI e chamam um comando.
480
+ Por isso o fluxo inteiro roda igual na sua máquina, e dá para **desligar o CI**
481
+ quando o que incomoda é o consumo de minutos.
482
+
483
+ ```bash
484
+ npx @spec-wave/cli@latest mode # estado atual
485
+ npx @spec-wave/cli@latest mode local # desarma os workflows
486
+ npx @spec-wave/cli@latest run <issue> --dry-run # explica o próximo passo
487
+ npx @spec-wave/cli@latest run <issue> # executa aqui
488
+ npx @spec-wave/cli@latest mode actions # volta ao CI
489
+ ```
490
+
491
+ `mode` escreve os **dois** lados do interruptor: `execution.mode` no
492
+ `.spec-wave.json` (o que a CLI e as skills leem) e a variável de repositório
493
+ `SPEC_WAVE_EXECUTION` (o que o `if:` de cada job avalia). Com ela setada, o run
494
+ aparece como *skipped* — **job que não roda não é faturado**. A variável exige
495
+ admin no repositório; sem permissão o comando avisa em vez de fingir que aplicou.
496
+
497
+ `run <issue>` executa o passo que a label dispararia, decidido pelo estado da
498
+ issue: documentos que existem + labels presentes.
499
+
500
+ | Estado | Passo |
501
+ |---|---|
502
+ | Feature sem `spec.md` | `generate-spec` |
503
+ | spec pronta, sem `plan.md` | `generate-plan` (crítica embutida) |
504
+ | sem `spec-wave:plan-approved` | `validate` |
505
+ | validada, sem `decomposition.md` | `decompose` (rascunho) |
506
+ | `spec-wave:decompose-ready` | `decompose-apply` — exige `--apply` |
507
+ | Bug | `generate-bug` → `validate` → triagem (humana) |
508
+
509
+ `run --pr <n>` cobre o lado do PR: `code-review` e, havendo review aprovada,
510
+ também `qa`.
511
+
512
+ Ele **se recusa** a rodar (saindo com código 2) quando há label de gatilho
513
+ pendente na issue — Action em voo, e rodar por cima duplicaria o documento —,
514
+ quando um portão humano da crítica está aplicado (`needs-human`,
515
+ `critique-failed`), quando o documento existe no repositório mas não no seu
516
+ clone (`git pull` primeiro) e quando o passo cria issues sem confirmação
517
+ explícita. `--dry-run` mostra a decisão sem executar nada; `--force`, `--yes`,
518
+ `--apply` e `--step` cobrem os casos em que você sabe o que está fazendo.
519
+
520
+ O `doctor` tem um check para o par config × variável: divergir é o estado
521
+ perigoso — é achar que desligou o CI e continuar pagando por ele.
522
+
523
+ ---
524
+
441
525
  ## Licença
442
526
 
443
527
  MIT
package/bin/spec-wave.mjs CHANGED
@@ -128,6 +128,35 @@ program
128
128
  .catch(err => { console.error(err.message); process.exit(1); });
129
129
  });
130
130
 
131
+ program
132
+ .command('run')
133
+ .description('Executa LOCALMENTE o próximo passo do fluxo (o que a label dispararia no Actions)')
134
+ .argument('[issue]', 'Número da issue (Feature, Bug ou RFC)')
135
+ .option('--pr <n>', 'Modo PR: decide entre code-review e qa pelo estado das reviews')
136
+ .option('--dry-run', 'Decide e explica sem executar nada')
137
+ .option('--yes', 'Confirma o passo que exige confirmação')
138
+ .option('--apply', 'Autoriza especificamente o decompose-apply (erra se o passo pendente for outro)')
139
+ .option('--step <nome>', 'Força um passo: spec | plan | critique | validate | decompose | decompose-apply | bug')
140
+ .option('--max-steps <n>', 'Encadeia até N passos (padrão: 1)', '1')
141
+ .option('--force', 'Ignora o portão de label de gatilho pendente')
142
+ .option('--no-remote-check', 'Não consulta o remoto pelos documentos ausentes (offline)')
143
+ .option('--only <passo>', 'No modo --pr: roda só code-review ou só qa')
144
+ .option('--json', 'Imprime a decisão em JSON')
145
+ .action(async (issue, options) => {
146
+ const { run } = await import('../src/commands/run.mjs');
147
+ await run(issue, options).catch(err => { console.error(err.message); process.exit(1); });
148
+ });
149
+
150
+ program
151
+ .command('mode')
152
+ .description('Mostra ou alterna o modo de execução: `actions` (workflows) ou `local` (esta máquina)')
153
+ .argument('[modo]', 'actions | local (sem argumento: só mostra o estado)')
154
+ .option('--dry-run', 'Mostra o que mudaria sem alterar nada')
155
+ .action(async (target, options) => {
156
+ const { mode } = await import('../src/commands/mode.mjs');
157
+ await mode({ target, ...options }).catch(err => { console.error(err.message); process.exit(1); });
158
+ });
159
+
131
160
  program
132
161
  .command('update')
133
162
  .description('Detecta o que está desatualizado (skill, .spec-wave.json, workflows/labels do repo) e atualiza só o que mudou')
@@ -205,7 +234,7 @@ program
205
234
 
206
235
  program
207
236
  .command('generate-bug')
208
- .description('Gera bug.md para um Bug (usado pelo GitHub Action)')
237
+ .description('Gera bug.md para um Bug roda no GitHub Action ou localmente')
209
238
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
210
239
  .action(async (options) => {
211
240
  const { generateBug } = await import('../src/commands/generate-bug.mjs');
@@ -218,7 +247,11 @@ program
218
247
  .requiredOption('--issue-number <n>', 'Número da issue no GitHub')
219
248
  .action(async (options) => {
220
249
  const { validate } = await import('../src/commands/validate.mjs');
221
- await validate(options).catch(err => { console.error(err.message); process.exit(1); });
250
+ // A reprova é um desfecho esperado do comando, não uma exceção — mas o exit
251
+ // code precisa continuar 1 para o job do Actions ficar vermelho.
252
+ const result = await validate(options)
253
+ .catch(err => { console.error(err.message); process.exit(1); });
254
+ if (result?.ok === false) process.exit(1);
222
255
  });
223
256
 
224
257
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spec-wave/cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.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": {
@@ -445,7 +445,9 @@ export async function getPR(token, owner, repo, prNumber) {
445
445
  // `ref` é opcional (compatível com os chamadores de 4 argumentos): o modo PR do
446
446
  // `update` precisa ler o .spec-wave.json na BASE, não no que a API escolher.
447
447
  export async function getFileContent(token, owner, repo, path, ref) {
448
- const octokit = makeOctokit(token);
448
+ // Quiet: 404 é resultado esperado aqui (o arquivo pode não existir), e a linha
449
+ // `GET ... - 404` no stderr parecia falha no meio da saída do `run`.
450
+ const octokit = makeQuietOctokit(token);
449
451
  try {
450
452
  const res = await octokit.rest.repos.getContent({
451
453
  owner, repo, path, ...(ref ? { ref } : {}),
@@ -456,3 +458,63 @@ export async function getFileContent(token, owner, repo, path, ref) {
456
458
  throw err;
457
459
  }
458
460
  }
461
+
462
+ export async function listPullRequestReviews(token, owner, repo, prNumber) {
463
+ const octokit = makeOctokit(token);
464
+ return await octokit.paginate(octokit.rest.pulls.listReviews, {
465
+ owner, repo, pull_number: prNumber, per_page: 100,
466
+ });
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // Variables do Actions — a chave que desarma os workflows.
471
+ //
472
+ // O `if:` de cada job consulta `vars.SPEC_WAVE_EXECUTION`, e é aqui que a CLI
473
+ // escreve esse valor. Exigem permissão de administração no repositório: um 403
474
+ // não é falha do comando, é informação para o usuário (e o `doctor` a repete).
475
+ // ---------------------------------------------------------------------------
476
+
477
+ /** Valor da variável, ou null se ela não existe. */
478
+ export async function getRepoVariable(token, owner, repo, name) {
479
+ const octokit = makeQuietOctokit(token);
480
+ try {
481
+ const res = await octokit.request('GET /repos/{owner}/{repo}/actions/variables/{name}', {
482
+ owner, repo, name,
483
+ });
484
+ return res.data?.value ?? null;
485
+ } catch (err) {
486
+ if (err.status === 404) return null;
487
+ throw err;
488
+ }
489
+ }
490
+
491
+ /** Cria ou atualiza a variável. Devolve 'created' | 'updated'. */
492
+ export async function setRepoVariable(token, owner, repo, name, value) {
493
+ const octokit = makeQuietOctokit(token);
494
+ try {
495
+ await octokit.request('POST /repos/{owner}/{repo}/actions/variables', { owner, repo, name, value });
496
+ return 'created';
497
+ } catch (err) {
498
+ // 409 = já existe. Criar-ou-atualizar em duas chamadas é o contrato da API:
499
+ // não há PUT idempotente para variables.
500
+ if (err.status !== 409) throw err;
501
+ await octokit.request('PATCH /repos/{owner}/{repo}/actions/variables/{name}', {
502
+ owner, repo, name, value,
503
+ });
504
+ return 'updated';
505
+ }
506
+ }
507
+
508
+ /** Remove a variável. Ausente já é o estado desejado: 404 é no-op. */
509
+ export async function deleteRepoVariable(token, owner, repo, name) {
510
+ const octokit = makeQuietOctokit(token);
511
+ try {
512
+ await octokit.request('DELETE /repos/{owner}/{repo}/actions/variables/{name}', {
513
+ owner, repo, name,
514
+ });
515
+ return true;
516
+ } catch (err) {
517
+ if (err.status === 404) return false;
518
+ throw err;
519
+ }
520
+ }
@@ -33,7 +33,8 @@ import {
33
33
  import { recordUsage } from '../lib/usage-report.mjs';
34
34
  import { formatDependencyLine, orderStories, renderOrderComment } from '../lib/dependencies.mjs';
35
35
  import { lintLanguage } from '../lib/output-lint.mjs';
36
- import { slugify } from '../lib/slugify.mjs';
36
+ import { resolveDocDir } from '../lib/doc-paths.mjs';
37
+ import { docBlobUrl } from '../lib/repo-links.mjs';
37
38
  import { detectIssueType } from '../lib/issue-type.mjs';
38
39
  import { loadConfig } from '../lib/project-root.mjs';
39
40
  import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
@@ -222,14 +223,6 @@ export function resolveInheritedMilestone(issue) {
222
223
  return Number.isInteger(number) && number > 0 ? number : undefined;
223
224
  }
224
225
 
225
- // Diretório do documento por tipo. Feature usa o mesmo docs/features/<slug> da
226
- // spec/plan; RFC ganha o seu, já que não passa por spec/plan.
227
- function resolveDocDir(root, issue, type) {
228
- const slug = slugify(issue.title);
229
- const rel = type === 'RFC' ? `docs/rfcs/${slug}` : `docs/features/${slug}`;
230
- return { slug, rel, dir: path.resolve(root || process.cwd(), rel) };
231
- }
232
-
233
226
  // Lint de idioma sobre títulos+corpos gerados; retorna aviso pronto para
234
227
  // anexar ao comentário final ('' se limpo).
235
228
  function formatItemsLintWarning(texts) {
@@ -262,7 +255,7 @@ async function draftDecomposition(ctx) {
262
255
  const { token, owner, repo, issue, issueNumber, type, labels, usage, root, runMode, docDir, docPath, docRel } = ctx;
263
256
  const number = parseInt(issueNumber, 10);
264
257
  const kind = DECOMPOSE_TARGETS[type]; // Feature → 'stories'; RFC → 'tasks'
265
- const blobUrl = `https://github.com/${owner}/${repo}/blob/main/${docRel}`;
258
+ const blobUrl = docBlobUrl({ owner, repo, pathRel: docRel, mode: runMode, root });
266
259
 
267
260
  const specPath = path.join(docDir, 'spec.md');
268
261
  const planPath = path.join(docDir, 'plan.md');
@@ -13,6 +13,7 @@ import {
13
13
  tokenMismatchWarning, parseActiveAccount,
14
14
  } from '../api/auth.mjs';
15
15
  import { getProjectSnapshot, listSubIssues } from '../api/github-graphql.mjs';
16
+ import { getRepoVariable } from '../api/github-rest.mjs';
16
17
  import {
17
18
  CONFIG_FILE, WORKFLOW_FILES, getProvider, DEFAULT_PROVIDER, AI_PROVIDERS, STATUS_OPTIONS,
18
19
  RETIRED_STAGES, ALL_LABELS, allLabelsFor, LABEL_NEEDS_HUMAN, MODEL_LABEL_PREFIX,
@@ -20,6 +21,8 @@ import {
20
21
  modelLabels,
21
22
  } from '../config.mjs';
22
23
  import { findConfigPath } from '../lib/project-root.mjs';
24
+ import { configuredMode, describeModeState, EXECUTION_VARIABLE } from '../lib/execution-mode.mjs';
25
+ import { unguardedWorkflows } from './mode.mjs';
23
26
  import {
24
27
  DEFAULT_MAX_TOKENS, supportsStrictSchema, resolveAiConfig,
25
28
  } from '../lib/claude.mjs';
@@ -936,6 +939,34 @@ export function checkSpecKit(ctx) {
936
939
  };
937
940
  }
938
941
 
942
+ // Modo de execução: o config e a variável do repositório precisam concordar.
943
+ // Discordar não é detalhe — é o usuário achando que desligou os workflows e
944
+ // continuando a pagar minutos (ou o contrário: tudo pulado e nada rodando).
945
+ async function checkExecutionMode(ctx) {
946
+ const name = 'Modo de execução (Actions × local)';
947
+ const configured = configuredMode(ctx.cfg);
948
+
949
+ let variable; // undefined = não verificável
950
+ if (ctx.token && ctx.cfg?.owner && ctx.cfg?.repo) {
951
+ try {
952
+ variable = await getRepoVariable(ctx.token, ctx.cfg.owner, ctx.cfg.repo, EXECUTION_VARIABLE);
953
+ } catch (err) {
954
+ variable = undefined;
955
+ if (err.status !== 403 && err.status !== 404) {
956
+ return { name, status: 'warn', detail: `Variável não verificável agora: ${err.message}` };
957
+ }
958
+ }
959
+ }
960
+
961
+ const estado = describeModeState({
962
+ configured,
963
+ variable,
964
+ unguardedWorkflows: unguardedWorkflows(ctx.root || ctx.cwd),
965
+ });
966
+ const status = estado.status === 'problem' ? 'fail' : estado.status;
967
+ return { name, status, detail: [estado.summary, ...estado.notes, ...estado.fixes].join('\n') };
968
+ }
969
+
939
970
  async function checkWorkflows(ctx) {
940
971
  const name = 'Workflows do Actions';
941
972
  // Ancorado na raiz do projeto, não no cwd: rodar o doctor de um subdiretório
@@ -1037,6 +1068,7 @@ export async function doctor() {
1037
1068
  checkDecompositions,
1038
1069
  checkSpecKit,
1039
1070
  checkWorkflows,
1071
+ checkExecutionMode,
1040
1072
  ];
1041
1073
  const results = [];
1042
1074
  const spinner = p.spinner();
@@ -15,11 +15,11 @@ import {
15
15
  import { generateDocument } from '../lib/claude.mjs';
16
16
  import { unwrapGeneratedDoc } from '../lib/unwrap-doc.mjs';
17
17
  import { recordUsage } from '../lib/usage-report.mjs';
18
- import { loadConfig } from '../lib/project-root.mjs';
19
18
  import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
20
19
  import { detectIssueType } from '../lib/issue-type.mjs';
21
20
  import { bugDocPaths } from '../lib/bug-doc.mjs';
22
- import { commitGenerated, executionMode } from '../lib/flow-run.mjs';
21
+ import { commitGenerated, resolveFlowContext } from '../lib/flow-run.mjs';
22
+ import { docBlobUrl } from '../lib/repo-links.mjs';
23
23
  import {
24
24
  runCritique, resolveCritiqueAttempt, renderNeedsHumanComment,
25
25
  } from '../lib/critique.mjs';
@@ -48,18 +48,12 @@ function isSpecWaveComment(body) {
48
48
 
49
49
  export async function generateBug({ issueNumber }) {
50
50
  const token = await resolveToken();
51
- const [owner, repo] = (process.env.GITHUB_REPOSITORY || '').split('/');
52
- const { root } = loadConfig();
51
+ // Mesmo contexto dos outros geradores: GITHUB_REPOSITORY quando existe, o
52
+ // `.spec-wave.json` quando não. Este comando exigia a env crua e morria fora
53
+ // do runner mandando exportá-la — dentro de um repo que já sabe seu owner/repo.
54
+ const { owner, repo, root, config, mode } = resolveFlowContext({ command: 'generate-bug' });
53
55
  const n = parseInt(issueNumber, 10);
54
56
 
55
- if (!owner || !repo) {
56
- throw new Error(
57
- 'GITHUB_REPOSITORY env var não definida.\n' +
58
- 'Este comando roda no GitHub Actions. Para testar localmente:\n' +
59
- ' GITHUB_REPOSITORY=owner/repo spec-wave generate-bug --issue-number 1'
60
- );
61
- }
62
-
63
57
  console.log(`Buscando issue #${n}...`);
64
58
  const issue = await getIssue(token, owner, repo, n);
65
59
 
@@ -126,7 +120,7 @@ export async function generateBug({ issueNumber }) {
126
120
  filePath: fileAbs,
127
121
  content,
128
122
  message: `docs: generate bug.md for ${slug} [spec-wave]`,
129
- mode: executionMode(),
123
+ mode,
130
124
  });
131
125
  if (published.warning) console.warn(`⚠️ ${published.warning}`);
132
126
 
@@ -142,7 +136,7 @@ export async function generateBug({ issueNumber }) {
142
136
  await commentOnIssue(
143
137
  token, owner, repo, n,
144
138
  '🐞 **bug.md gerado automaticamente!**\n\n' +
145
- `📄 Arquivo: [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
139
+ `📄 Arquivo: [\`${fileRel}\`](${docBlobUrl({ owner, repo, pathRel: fileRel, mode, root, config })})\n\n` +
146
140
  'Revise a **causa raiz** e o **teste de regressão** — são as duas seções que decidem se ' +
147
141
  'a correção ataca o defeito ou o sintoma. Quando estiver pronto, valide com:\n' +
148
142
  `\`\`\`\ngh issue edit ${n} --add-label "spec-wave:ready"\n\`\`\`` +
@@ -20,6 +20,7 @@ import { recordUsage } from '../lib/usage-report.mjs';
20
20
  import { slugify } from '../lib/slugify.mjs';
21
21
  import { resolveFromRoot } from '../lib/project-root.mjs';
22
22
  import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
23
+ import { docBlobUrl } from '../lib/repo-links.mjs';
23
24
  import { buildTechContext } from '../lib/tech-context.mjs';
24
25
  import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
25
26
 
@@ -213,7 +214,7 @@ export async function generatePlan({ issueNumber }) {
213
214
  await commentOnIssue(
214
215
  token, owner, repo, parseInt(issueNumber, 10),
215
216
  `📋 **plan.md gerado automaticamente!**\n\n` +
216
- `📄 Arquivo: [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
217
+ `📄 Arquivo: [\`${fileRel}\`](${docBlobUrl({ owner, repo, pathRel: fileRel, mode, root, config })})\n\n` +
217
218
  `Revise o plano e, quando estiver pronto, valide a Feature: mova o card para **✅ Ready** ou use:\n` +
218
219
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:ready"\n\`\`\`` +
219
220
  formatLintWarning(lintFindings)
@@ -7,6 +7,7 @@ import { recordUsage } from '../lib/usage-report.mjs';
7
7
  import { slugify } from '../lib/slugify.mjs';
8
8
  import { resolveFromRoot } from '../lib/project-root.mjs';
9
9
  import { resolveFlowContext, commitGenerated } from '../lib/flow-run.mjs';
10
+ import { docBlobUrl } from '../lib/repo-links.mjs';
10
11
  import { loadPrompt, systemPromptWithTools } from '../lib/prompt-loader.mjs';
11
12
  import { detectIssueType } from '../lib/issue-type.mjs';
12
13
  import {
@@ -110,7 +111,7 @@ export async function generateSpec({ issueNumber }) {
110
111
  await commentOnIssue(
111
112
  token, owner, repo, parseInt(issueNumber, 10),
112
113
  `📋 **spec.md gerado automaticamente!**\n\n` +
113
- `📄 Arquivo: [\`${fileRel}\`](https://github.com/${owner}/${repo}/blob/main/${fileRel})\n\n` +
114
+ `📄 Arquivo: [\`${fileRel}\`](${docBlobUrl({ owner, repo, pathRel: fileRel, mode, root })})\n\n` +
114
115
  `Revise a especificação e, quando estiver pronto, gere o plano técnico: mova o card para **📋 Plan** ou use:\n` +
115
116
  `\`\`\`\ngh issue edit ${issueNumber} --add-label "spec-wave:plan"\n\`\`\`` +
116
117
  formatLintWarning(lintFindings)
@@ -0,0 +1,169 @@
1
+ // Alterna entre rodar o fluxo no GitHub Actions e rodar nesta máquina.
2
+ //
3
+ // O comando escreve os DOIS lados do interruptor — o `.spec-wave.json`, que a
4
+ // CLI e a skill leem, e a variável de repositório, que o `if:` de cada job
5
+ // avalia. Escrever só um deles é o defeito que o comando existe para evitar:
6
+ // config em "local" com a variável ausente significa workflow disparando e
7
+ // minuto sendo cobrado enquanto o usuário acha que desligou.
8
+ //
9
+ // A variável exige permissão de administração. Sem ela o comando NÃO finge que
10
+ // deu certo: grava o config, diz o que falta e aponta a alternativa manual.
11
+
12
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ import * as p from '@clack/prompts';
16
+ import chalk from 'chalk';
17
+
18
+ import { resolveToken } from '../api/auth.mjs';
19
+ import { getRepoVariable, setRepoVariable, deleteRepoVariable } from '../api/github-rest.mjs';
20
+ import { CONFIG_FILE, WORKFLOW_FILES } from '../config.mjs';
21
+ import { updateConfig } from '../lib/config-file.mjs';
22
+ import {
23
+ EXECUTION_MODES, EXECUTION_VARIABLE, EXECUTION_GUARD,
24
+ configuredMode, variableValueFor, describeModeState,
25
+ } from '../lib/execution-mode.mjs';
26
+ import { loadConfig } from '../lib/project-root.mjs';
27
+
28
+ /**
29
+ * Workflows instalados que NÃO carregam a guarda (função quase pura — só lê o fs).
30
+ *
31
+ * @param {string|null} root
32
+ * @returns {string[]}
33
+ */
34
+ export function unguardedWorkflows(root) {
35
+ const dir = path.join(root || process.cwd(), '.github', 'workflows');
36
+ if (!existsSync(dir)) return [];
37
+ const presentes = new Set(readdirSync(dir));
38
+ return WORKFLOW_FILES
39
+ .filter(file => presentes.has(file))
40
+ .filter(file => !readFileSync(path.join(dir, file), 'utf-8').includes(EXECUTION_GUARD));
41
+ }
42
+
43
+ // A variável só é legível por quem administra o repo. 403 não é falha: é "não
44
+ // verificável", e quem chama distingue isso de "não existe" (null).
45
+ async function readVariable(token, owner, repo) {
46
+ try {
47
+ return await getRepoVariable(token, owner, repo, EXECUTION_VARIABLE);
48
+ } catch (err) {
49
+ if (err.status === 403 || err.status === 404) return undefined;
50
+ throw err;
51
+ }
52
+ }
53
+
54
+ export async function mode({ target, dryRun = false } = {}) {
55
+ p.intro(chalk.bold('spec-wave mode'));
56
+
57
+ const { config, root, configPath } = loadConfig();
58
+ if (!config) {
59
+ p.log.error(`${CONFIG_FILE} não encontrado — rode \`npx @spec-wave/cli@latest init\` antes.`);
60
+ process.exit(1);
61
+ }
62
+
63
+ const alvo = target ? String(target).toLowerCase() : null;
64
+ if (alvo && !EXECUTION_MODES.includes(alvo)) {
65
+ p.log.error(`Modo inválido: ${alvo}. Use um de: ${EXECUTION_MODES.join(', ')}.`);
66
+ process.exit(1);
67
+ }
68
+
69
+ const atual = configuredMode(config);
70
+ const owner = config.owner;
71
+ const repo = config.repo;
72
+
73
+ let token = null;
74
+ let variavel;
75
+ if (owner && repo) {
76
+ try {
77
+ token = await resolveToken();
78
+ variavel = await readVariable(token, owner, repo);
79
+ } catch (err) {
80
+ p.log.warn(`Não foi possível consultar a variável do repositório: ${err.message}`);
81
+ variavel = undefined;
82
+ }
83
+ }
84
+
85
+ // Sem argumento: só relatório.
86
+ if (!alvo) {
87
+ const estado = describeModeState({
88
+ configured: atual, variable: variavel, unguardedWorkflows: unguardedWorkflows(root),
89
+ });
90
+ p.log.info(estado.summary);
91
+ for (const nota of estado.notes) p.log.message(`• ${nota}`);
92
+ for (const fix of estado.fixes) p.log.warn(fix);
93
+ p.outro(
94
+ atual === 'local'
95
+ ? `Próximo passo de uma issue: ${chalk.cyan('spec-wave run <issue>')}`
96
+ : `Para desligar o CI: ${chalk.cyan('spec-wave mode local')}`
97
+ );
98
+ return { mode: atual, variable: variavel, changed: false };
99
+ }
100
+
101
+ const esperado = variableValueFor(alvo);
102
+ const mudaConfig = atual !== alvo;
103
+ const mudaVariavel = variavel !== undefined && variavel !== esperado;
104
+
105
+ if (!mudaConfig && !mudaVariavel) {
106
+ p.log.success(`Já está em ${chalk.bold(alvo)} — config e variável do repositório coincidem.`);
107
+ p.outro('Nada a fazer.');
108
+ return { mode: alvo, variable: variavel, changed: false };
109
+ }
110
+
111
+ if (dryRun) {
112
+ if (mudaConfig) p.log.info(`${CONFIG_FILE}: execution.mode ${atual} → ${alvo}`);
113
+ if (mudaVariavel) {
114
+ p.log.info(esperado === null
115
+ ? `Variável ${EXECUTION_VARIABLE}: remover (valor atual: ${variavel})`
116
+ : `Variável ${EXECUTION_VARIABLE}: definir como "${esperado}"`);
117
+ }
118
+ p.outro('Dry-run: nada foi alterado.');
119
+ return { mode: atual, variable: variavel, changed: false };
120
+ }
121
+
122
+ if (mudaConfig) {
123
+ const { changed } = updateConfig(cfg => {
124
+ cfg.execution = { ...(cfg.execution || {}), mode: alvo };
125
+ }, { cwd: root || process.cwd() });
126
+ if (changed) p.log.success(`${CONFIG_FILE} atualizado: execution.mode = ${alvo}`);
127
+ }
128
+
129
+ let variavelOk = true;
130
+ if (token && owner && repo) {
131
+ try {
132
+ if (esperado === null) {
133
+ const removida = await deleteRepoVariable(token, owner, repo, EXECUTION_VARIABLE);
134
+ p.log.success(removida
135
+ ? `Variável ${EXECUTION_VARIABLE} removida — os workflows voltam a disparar.`
136
+ : `Variável ${EXECUTION_VARIABLE} já não existia.`);
137
+ } else {
138
+ await setRepoVariable(token, owner, repo, EXECUTION_VARIABLE, esperado);
139
+ p.log.success(`Variável ${EXECUTION_VARIABLE}=${esperado} — os jobs passam a ser pulados (0 minutos).`);
140
+ }
141
+ } catch (err) {
142
+ variavelOk = false;
143
+ p.log.error(
144
+ `Não foi possível escrever a variável ${EXECUTION_VARIABLE} (${err.status || ''} ${err.message}).\n` +
145
+ 'Ela exige administração no repositório — peça a um admin, ou defina em ' +
146
+ 'Settings → Secrets and variables → Actions → Variables.'
147
+ );
148
+ }
149
+ } else {
150
+ variavelOk = false;
151
+ p.log.warn(`Sem owner/repo ou token: só o ${CONFIG_FILE} foi atualizado.`);
152
+ }
153
+
154
+ const semGuarda = unguardedWorkflows(root);
155
+ if (alvo === 'local' && semGuarda.length > 0) {
156
+ p.log.warn(
157
+ `Estes workflows instalados ainda não têm a guarda \`${EXECUTION_GUARD}\` e vão rodar mesmo assim: ` +
158
+ `${semGuarda.join(', ')}. Rode \`npx @spec-wave/cli@latest update\`.`
159
+ );
160
+ }
161
+
162
+ p.log.message(chalk.dim(`Commite o ${configPath ? path.basename(configPath) : CONFIG_FILE} — quem clona o repo lê a versão versionada.`));
163
+ p.outro(
164
+ alvo === 'local'
165
+ ? `Modo local. Conduza o fluxo com ${chalk.cyan('spec-wave run <issue>')}.`
166
+ : 'Modo actions. As labels de gatilho voltam a disparar os workflows.'
167
+ );
168
+ return { mode: alvo, variable: esperado, changed: true, variableApplied: variavelOk };
169
+ }