@aksp/opencrew 1.8.0 → 1.10.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +133 -16
  3. package/package.json +1 -1
  4. package/src/commands/update.js +9 -3
  5. package/src/lib/blocos.js +7 -3
  6. package/src/lib/resumo.js +3 -0
  7. package/templates/AGENTS.md +11 -2
  8. package/templates/_opencrew/.opencrew-version +1 -1
  9. package/templates/_opencrew/core/best-practices/_catalog.yaml +5 -0
  10. package/templates/_opencrew/core/best-practices/documento-oficial.md +144 -0
  11. package/templates/_opencrew/core/modelos/documento-oficial.md +42 -0
  12. package/templates/_opencrew/core/prompts/design.prompt.md +1 -0
  13. package/templates/_opencrew/core/prompts/discovery.prompt.md +1 -1
  14. package/templates/_opencrew/core/prompts/documento.prompt.md +134 -0
  15. package/templates/_opencrew/core/prompts/entrega.prompt.md +93 -18
  16. package/templates/_opencrew/core/prompts/export.prompt.md +5 -81
  17. package/templates/_opencrew/core/runner.pipeline.md +20 -20
  18. package/templates/_opencrew/core/scripts/documento/argumentos.mjs +50 -0
  19. package/templates/_opencrew/core/scripts/documento/corpo.mjs +51 -0
  20. package/templates/_opencrew/core/scripts/documento/estilos.mjs +42 -0
  21. package/templates/_opencrew/core/scripts/documento/gravar.mjs +44 -0
  22. package/templates/_opencrew/core/scripts/documento/linha.mjs +58 -0
  23. package/templates/_opencrew/core/scripts/documento/marcacoes.mjs +55 -0
  24. package/templates/_opencrew/core/scripts/documento/markdown.mjs +92 -0
  25. package/templates/_opencrew/core/scripts/documento/pacote.mjs +82 -0
  26. package/templates/_opencrew/core/scripts/documento/perfil.mjs +70 -0
  27. package/templates/_opencrew/core/scripts/documento/png.mjs +20 -0
  28. package/templates/_opencrew/core/scripts/documento/projeto.mjs +60 -0
  29. package/templates/_opencrew/core/scripts/documento/tabelas.mjs +57 -0
  30. package/templates/_opencrew/core/scripts/documento/timbre.mjs +64 -0
  31. package/templates/_opencrew/core/scripts/documento/xml.mjs +95 -0
  32. package/templates/_opencrew/core/scripts/documento/zip.mjs +86 -0
  33. package/templates/_opencrew/core/scripts/documento.mjs +150 -0
  34. package/templates/_opencrew/core/scripts/entrega/argumentos.mjs +13 -9
  35. package/templates/_opencrew/core/scripts/entrega/canais.mjs +12 -6
  36. package/templates/_opencrew/core/scripts/entrega/comparar.mjs +104 -0
  37. package/templates/_opencrew/core/scripts/entrega/copia.mjs +138 -0
  38. package/templates/_opencrew/core/scripts/entrega/destino.mjs +98 -0
  39. package/templates/_opencrew/core/scripts/entrega/documentos.mjs +104 -0
  40. package/templates/_opencrew/core/scripts/entrega/fora.mjs +4 -3
  41. package/templates/_opencrew/core/scripts/entrega/gravar.mjs +3 -3
  42. package/templates/_opencrew/core/scripts/entrega/guardar.mjs +60 -0
  43. package/templates/_opencrew/core/scripts/entrega/leiame.mjs +54 -17
  44. package/templates/_opencrew/core/scripts/entrega/leitor.mjs +16 -3
  45. package/templates/_opencrew/core/scripts/entrega/lembrar.mjs +65 -0
  46. package/templates/_opencrew/core/scripts/entrega/passos.mjs +21 -6
  47. package/templates/_opencrew/core/scripts/entrega/pendencias.mjs +16 -6
  48. package/templates/_opencrew/core/scripts/entrega/ressalvas.mjs +78 -0
  49. package/templates/_opencrew/core/scripts/entrega/resumo.mjs +29 -0
  50. package/templates/_opencrew/core/scripts/entrega/retrato.mjs +42 -0
  51. package/templates/_opencrew/core/scripts/entrega/separar.mjs +9 -3
  52. package/templates/_opencrew/core/scripts/entregar.mjs +76 -44
  53. package/templates/_opencrew/core/scripts/verificar/argumentos.mjs +4 -4
  54. package/templates/_opencrew/core/scripts/verificar/entradas.mjs +26 -0
  55. package/templates/_opencrew/core/scripts/verificar/gravacao.mjs +41 -0
  56. package/templates/_opencrew/core/scripts/verificar/relatorio.mjs +10 -3
  57. package/templates/_opencrew/core/scripts/verificar.mjs +17 -27
  58. package/templates/gitignore +1 -0
@@ -0,0 +1,104 @@
1
+ // Comparação entre o que a entrega copiaria agora e a cópia que já está no destino: é ela que
2
+ // decide se nada muda, se só há arquivos a acrescentar ou se a entrega vai para outra pasta.
3
+ // Com o retrato do que foi copiado (`copia.json`, ver `retrato.mjs`), a comparação é com ele: o que o
4
+ // usuário fez na cópia depois não conta. Sem retrato, é com os arquivos da pasta.
5
+ // Este módulo só lê. Spec: fase-u3a2-entrega-no-projeto.md, regra 14 (repositório do OpenCrew).
6
+ import { createHash } from 'node:crypto';
7
+ import { promises as fs } from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ // Em texto, CRLF e LF são o mesmo conteúdo (o editor ou a sincronização trocam); o resto, byte a byte.
11
+ const DE_TEXTO = new Set(['.txt', '.md', '.html', '.htm', '.csv', '.json']);
12
+ export const emLf = (texto) => texto.replace(/\r\n/g, '\n');
13
+
14
+ /** Os bytes que um arquivo da entrega tem: os gerados (o Word), o texto gerado, ou os do arquivo de origem. */
15
+ export const bytesDe = async (a) => a.bytes ?? (a.texto != null ? Buffer.from(a.texto, 'utf8') : fs.readFile(a.de));
16
+
17
+ const ehTexto = (nome) => DE_TEXTO.has(path.extname(nome).toLowerCase());
18
+ const mesmoConteudo = (nome, a, b) => a.equals(b) || (ehTexto(nome) && emLf(a.toString('utf8')) === emLf(b.toString('utf8')));
19
+
20
+ /** Os arquivos sob uma pasta, com o prefixo dado e `/`; pasta que não existe: nenhum. */
21
+ async function listar(pasta, prefixo) {
22
+ const entradas = await fs.readdir(pasta, { withFileTypes: true }).catch(() => []);
23
+ const achados = [];
24
+ for (const e of entradas) {
25
+ if (e.isDirectory()) achados.push(...(await listar(path.join(pasta, e.name), `${prefixo}${e.name}/`)));
26
+ else achados.push(`${prefixo}${e.name}`);
27
+ }
28
+ return achados;
29
+ }
30
+
31
+ const REENTREGA = '-reentrega-';
32
+
33
+ /** 1 para `<run>`, N para `<run>-reentrega-N` (N ≥ 2), 0 para qualquer outro nome. */
34
+ function numeroDe(nome, run) {
35
+ if (nome === run) return 1;
36
+ const n = nome.startsWith(`${run}${REENTREGA}`) ? nome.slice(run.length + REENTREGA.length) : '';
37
+ return /^[1-9]\d*$/.test(n) && Number(n) >= 2 ? Number(n) : 0;
38
+ }
39
+
40
+ /**
41
+ * As pastas desta execução no destino, da mais antiga para a mais nova: `<run>` é a 1 e
42
+ * `<run>-reentrega-N` é a N. Só pastas: arquivo com um desses nomes não conta.
43
+ * @returns {Promise<{ n: number, nome: string }[]>}
44
+ */
45
+ export async function pastasDaExecucao(destino, run) {
46
+ const entradas = await fs.readdir(destino, { withFileTypes: true }).catch(() => []);
47
+ const pastas = entradas.filter((e) => e.isDirectory()).map((e) => ({ n: numeroDe(e.name, run), nome: e.name }));
48
+ return pastas.filter((p) => p.n).sort((a, b) => a.n - b.n);
49
+ }
50
+
51
+ const hashDe = (nome, bytes) => createHash('sha1').update(ehTexto(nome) ? emLf(bytes.toString('utf8')) : bytes).digest('hex');
52
+
53
+ /** O retrato de uma lista de arquivos da entrega: `{ 'pasta/nome': hash }` (em texto, sem contar CRLF/LF). */
54
+ export async function retratoDe(arquivos) {
55
+ const retrato = {};
56
+ for (const a of arquivos) retrato[`${a.pasta}/${a.nome}`] = hashDe(a.nome, await bytesDe(a));
57
+ return retrato;
58
+ }
59
+
60
+ /**
61
+ * A mesma pergunta de `oQueFalta`, respondida pelo retrato do que foi copiado para a pasta. O que
62
+ * o usuário editou, apagou ou acrescentou na cópia não é diferença. Arquivo novo cujo lugar já
63
+ * está ocupado só conta como copiado quando o conteúdo é o mesmo; senão, é diferença.
64
+ */
65
+ async function contraORetrato(pasta, esperados, canais, ignorar, retrato) {
66
+ const saiu = (rel) => canais.has(rel.split('/')[0]) && !ignorar.has(rel) && !esperados.has(rel);
67
+ if (Object.keys(retrato).some(saiu)) return null;
68
+ const faltam = [];
69
+ for (const [rel, a] of esperados) {
70
+ const bytes = await bytesDe(a);
71
+ if (Object.hasOwn(retrato, rel)) {
72
+ if (retrato[rel] !== hashDe(rel, bytes)) return null;
73
+ continue;
74
+ }
75
+ const noDisco = await fs.readFile(path.join(pasta, rel)).catch(() => null);
76
+ if (noDisco == null) faltam.push(a);
77
+ else if (!mesmoConteudo(rel, noDisco, bytes)) return null;
78
+ }
79
+ return faltam;
80
+ }
81
+
82
+ /**
83
+ * Compara os arquivos que seriam copiados agora com uma pasta de cópia. Só as pastas de canal que
84
+ * seriam copiadas agora são olhadas: a raiz da cópia e as outras pastas ficam de fora.
85
+ * @param {string} pasta a cópia, absoluta · @param {object[]} arquivos `{ pasta, nome, texto | de }`
86
+ * @param {Set<string>} ignorar `pasta/nome` do que a entrega tem mas não copia agora
87
+ * @param {object|null} [retrato] o retrato do que foi copiado para esta pasta; null = comparar com os arquivos
88
+ * @returns {Promise<null | object[]>} null = diferente (arquivo que mudou, saiu ou está a mais);
89
+ * senão, os arquivos que faltam na cópia (nenhum = igual)
90
+ */
91
+ export async function oQueFalta(pasta, arquivos, ignorar, retrato = null) {
92
+ const esperados = new Map(arquivos.map((a) => [`${a.pasta}/${a.nome}`, a]));
93
+ if (retrato) return contraORetrato(pasta, esperados, new Set(arquivos.map((a) => a.pasta)), ignorar, retrato);
94
+ const faltam = new Map(esperados);
95
+ for (const canal of new Set(arquivos.map((a) => a.pasta))) {
96
+ for (const rel of await listar(path.join(pasta, canal), `${canal}/`)) {
97
+ if (ignorar.has(rel)) continue;
98
+ const a = esperados.get(rel);
99
+ if (!a || !mesmoConteudo(rel, await fs.readFile(path.join(pasta, rel)), await bytesDe(a))) return null;
100
+ faltam.delete(rel);
101
+ }
102
+ }
103
+ return [...faltam.values()];
104
+ }
@@ -0,0 +1,138 @@
1
+ // A cópia da entrega para a pasta do projeto que o usuário escolheu: uma pasta por execução
2
+ // (`<destino>/<run_id>/`). Nada é sobrescrito, menos o `LEIA-ME.md` da cópia, que é do script.
3
+ // Pasta nova é montada em `<pasta>.tmp/`, ao lado, e renomeada no fim: nada fica pela metade.
4
+ // O script só apaga o temporário que ele mesmo criou. O que o usuário edita na cópia fica: a
5
+ // entrega seguinte é comparada com o retrato do que foi copiado (`retrato.mjs`), não com a pasta.
6
+ // Spec: fase-u3a2-entrega-no-projeto.md, regras 14 e 15 (repositório do OpenCrew).
7
+ import { constants, existsSync, promises as fs } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { emLf, oQueFalta, pastasDaExecucao } from './comparar.mjs';
10
+
11
+ export const AVISO = (pasta) => `Há uma entrega mais nova desta execução em \`${pasta}\`.`;
12
+ const AVISO_ANTERIOR = /^Há uma entrega mais nova desta execução em `[^`\n]*`\.\r?\n\r?\n/;
13
+ const LEIAME = 'LEIA-ME.md';
14
+ const apagar = (pasta) => fs.rm(pasta, { recursive: true, force: true, maxRetries: 3 });
15
+ const ehPasta = async (p) => fs.stat(p).then((s) => s.isDirectory(), () => false);
16
+
17
+ /** Grava um arquivo que ainda não existe ali: se existir, falha (nunca por cima). */
18
+ async function gravarNovo(alvo, a) {
19
+ await fs.mkdir(path.dirname(alvo), { recursive: true });
20
+ if ((a.bytes ?? a.texto) != null) await fs.writeFile(alvo, a.bytes ?? a.texto, { encoding: 'utf8', flag: 'wx' });
21
+ else await fs.copyFile(a.de, alvo, constants.COPYFILE_EXCL);
22
+ }
23
+
24
+ /** Monta a pasta nova no temporário e a põe no lugar; em falha, o temporário some. */
25
+ async function montar(pasta, arquivos, leiame, em) {
26
+ const tmp = `${pasta}.tmp`;
27
+ if (await ehPasta(tmp)) await apagar(tmp); // sobra de uma chamada interrompida
28
+ await fs.mkdir(tmp);
29
+ try {
30
+ for (const a of arquivos) {
31
+ em.alvo = path.join(pasta, a.pasta, a.nome);
32
+ await gravarNovo(path.join(tmp, a.pasta, a.nome), a);
33
+ }
34
+ em.alvo = path.join(pasta, LEIAME);
35
+ await fs.writeFile(path.join(tmp, LEIAME), leiame, 'utf8');
36
+ em.alvo = pasta;
37
+ await fs.rename(tmp, pasta);
38
+ } catch (erro) {
39
+ await apagar(tmp).catch(() => {});
40
+ throw erro;
41
+ }
42
+ }
43
+
44
+ /** A pasta mais alta do caminho que ainda não existe (é a que o script cria); null quando todas existem. */
45
+ function primeiraQueFalta(pasta) {
46
+ let falta = null;
47
+ for (let p = pasta; !existsSync(p); p = path.dirname(p)) falta = p;
48
+ return falta;
49
+ }
50
+
51
+ /** Desfaz as pastas vazias que esta chamada criou, de `pasta` até `criada`. */
52
+ async function desfazer(criada, pasta) {
53
+ for (let p = pasta; ; p = path.dirname(p)) {
54
+ await fs.rmdir(p);
55
+ if (p === criada) return;
56
+ }
57
+ }
58
+
59
+ /** Cria `pasta` com a entrega inteira. @returns {Promise<boolean>} o destino precisou ser criado? */
60
+ async function criar(pasta, arquivos, leiame, em) {
61
+ em.alvo = pasta;
62
+ if (existsSync(pasta)) throw new Error('já existe um arquivo com o nome da pasta');
63
+ const destino = path.dirname(pasta);
64
+ const criada = primeiraQueFalta(destino);
65
+ try {
66
+ await fs.mkdir(destino, { recursive: true });
67
+ await montar(pasta, arquivos, leiame, em);
68
+ } catch (erro) {
69
+ if (criada) await desfazer(criada, destino).catch(() => {});
70
+ throw erro;
71
+ }
72
+ return Boolean(criada);
73
+ }
74
+
75
+ /** O LEIA-ME da cópia é regravado quando muda (CRLF no lugar de LF não é mudança), sempre por último. */
76
+ async function regravarLeiame(pasta, leiame, em) {
77
+ em.alvo = path.join(pasta, LEIAME);
78
+ const atual = await fs.readFile(em.alvo, 'utf8').catch(() => null);
79
+ if (atual == null || emLf(atual) !== leiame) await fs.writeFile(em.alvo, leiame, 'utf8');
80
+ }
81
+
82
+ /** As pastas anteriores ganham, na primeira linha do LEIA-ME, o aviso da pasta nova (um só). */
83
+ async function avisarAnteriores(destino, anteriores, pastaNova, em) {
84
+ for (const { nome } of anteriores) {
85
+ em.alvo = path.join(destino, nome, LEIAME);
86
+ const atual = await fs.readFile(em.alvo, 'utf8').catch(() => null);
87
+ if (atual != null) await fs.writeFile(em.alvo, `${AVISO(pastaNova)}\n\n${atual.replace(AVISO_ANTERIOR, '')}`, 'utf8');
88
+ }
89
+ }
90
+
91
+ /** O LEIA-ME da pasta número `n` (1 = `<run>`; N = `<run>-reentrega-N`): um texto fixo, ou quem o monta. */
92
+ const leiameDe = (leiame, n) => (typeof leiame === 'function' ? leiame(n) : leiame);
93
+
94
+ /**
95
+ * Completa a pasta mais nova com o que falta, ou cria a pasta de reentrega quando algo mudou. A
96
+ * comparação é com o retrato do que foi copiado para ela; sem retrato, com os arquivos dela.
97
+ */
98
+ async function atualizar({ destino, run, arquivos, ignorar, leiame, retratos }, existentes, em) {
99
+ const ultima = existentes.at(-1);
100
+ const pasta = path.join(destino.abs, ultima.nome);
101
+ const faltam = await oQueFalta(pasta, arquivos, ignorar, retratos?.[`${destino.rel}/${ultima.nome}`] ?? null);
102
+ if (!faltam) {
103
+ const nome = `${run}-reentrega-${ultima.n + 1}`;
104
+ await criar(path.join(destino.abs, nome), arquivos, leiameDe(leiame, ultima.n + 1), em);
105
+ await avisarAnteriores(destino.abs, existentes, `${destino.rel}/${nome}`, em);
106
+ return { tipo: 'reentrega', pasta: `${destino.rel}/${nome}` };
107
+ }
108
+ for (const a of faltam) {
109
+ em.alvo = path.join(pasta, a.pasta, a.nome);
110
+ await gravarNovo(em.alvo, a);
111
+ }
112
+ await regravarLeiame(pasta, leiameDe(leiame, ultima.n), em);
113
+ return { tipo: faltam.length ? 'completada' : 'igual', pasta: `${destino.rel}/${ultima.nome}`, novos: faltam.length };
114
+ }
115
+
116
+ /**
117
+ * Copia para o destino o que está pronto.
118
+ * @param {object} o
119
+ * @param {{ rel: string, abs: string }} o.destino a pasta escolhida, já validada · @param {string} o.run
120
+ * @param {object[]} o.arquivos o que é copiado agora: `{ pasta, nome, texto | de }`
121
+ * @param {Set<string>} o.ignorar `pasta/nome` do que a entrega tem e não é copiado agora
122
+ * @param {string|function(number): string} o.leiame o LEIA-ME da cópia, ou quem o monta para a pasta número N
123
+ * @param {object} [o.retratos] por pasta de cópia (relativa ao projeto), o retrato do que foi copiado
124
+ * @returns {Promise<object>} `{ tipo, pasta, novos, criouDestino }` — `tipo`: nova, igual,
125
+ * completada ou reentrega; `pasta`: relativa ao projeto · ou `{ tipo: 'falha', arquivo }`, com o
126
+ * caminho absoluto que não pôde ser gravado
127
+ */
128
+ export async function copiar(o) {
129
+ const em = { alvo: o.destino.abs };
130
+ try {
131
+ const existentes = await pastasDaExecucao(o.destino.abs, o.run);
132
+ if (existentes.length) return await atualizar(o, existentes, em);
133
+ const criouDestino = await criar(path.join(o.destino.abs, o.run), o.arquivos, leiameDe(o.leiame, 1), em);
134
+ return { tipo: 'nova', pasta: `${o.destino.rel}/${o.run}`, criouDestino };
135
+ } catch {
136
+ return { tipo: 'falha', arquivo: em.alvo };
137
+ }
138
+ }
@@ -0,0 +1,98 @@
1
+ // O destino da cópia: a pasta do projeto que o usuário escolheu para guardar a entrega. Vem de
2
+ // `--lembrar-destino`, de `--destino` ou de `entrega.destino` no `crew.yaml`, nessa ordem, e uma
3
+ // função só valida os três. Este módulo só lê.
4
+ // Spec: fase-u3a2-entrega-no-projeto.md, §3 e regra 13 (repositório do OpenCrew).
5
+ import { existsSync, promises as fs, statSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { lugarReal } from '../comum.mjs';
8
+ import { limpar } from './argumentos.mjs';
9
+
10
+ export const MSG = {
11
+ recusado: (valor) => `Não copiei: o destino precisa ser uma pasta dentro do projeto, fora de \`_opencrew/\`, \`crews/\`, \`skills/\`, \`.git/\` e \`node_modules/\`. Recebi: ${limpar(valor)}.`,
12
+ semDestino: 'Cópia: nenhuma pasta escolhida para esta crew.',
13
+ };
14
+
15
+ const PROIBIDAS = new Set(['_opencrew', 'crews', 'skills', '.git', 'node_modules']);
16
+ const NAO = /^(?:nao|não|no)$/i;
17
+ // Absoluto: começa por `/`, `\` ou letra de unidade. Lista ou mapa do YAML não é caminho.
18
+ const NAO_E_CAMINHO = /^(?:[\\/]|[A-Za-z]:|[[{]|-(?:\s|$))/;
19
+
20
+ /** Relativo à raiz, com `/`; null quando sai do projeto, é a raiz ou cai numa pasta reservada. */
21
+ function dentro(raiz, alvo) {
22
+ const rel = path.relative(raiz, alvo);
23
+ if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
24
+ const partes = rel.split(path.sep);
25
+ return PROIBIDAS.has(partes[0].toLowerCase()) ? null : partes.join('/');
26
+ }
27
+
28
+ /** O trecho do caminho que já existe é uma pasta? Um arquivo no caminho impede a cópia. */
29
+ function semArquivoNoCaminho(abs) {
30
+ let existente = abs;
31
+ while (!existsSync(existente)) existente = path.dirname(existente);
32
+ return statSync(existente).isDirectory();
33
+ }
34
+
35
+ /**
36
+ * Valida um destino (regra 13), venha de onde vier.
37
+ * @returns {{ tipo: 'nao' } | { tipo: 'recusado', valor: string } | { tipo: 'pasta', rel: string, abs: string }}
38
+ * `nao`: o usuário não quer cópia · `rel`: relativo ao projeto, com `/`
39
+ */
40
+ export function validarDestino(raiz, valor) {
41
+ const texto = String(valor ?? '').trim();
42
+ if (NAO.test(texto)) return { tipo: 'nao' };
43
+ const recusado = { tipo: 'recusado', valor: texto };
44
+ if (!texto || NAO_E_CAMINHO.test(texto)) return recusado;
45
+ // \ vale como separador em qualquer sistema: o usuário digita Pasta\Sub também fora do Windows.
46
+ const abs = path.resolve(raiz, texto.replace(/\\/g, '/'));
47
+ const rel = dentro(raiz, abs);
48
+ // Pelo lugar real também: um atalho dentro do projeto pode levar para fora, ou para `crews/`.
49
+ if (!rel || !dentro(lugarReal(raiz), lugarReal(abs)) || !semArquivoNoCaminho(abs)) return recusado;
50
+ return { tipo: 'pasta', rel, abs };
51
+ }
52
+
53
+ const semFim = (linha) => linha.replace(/\r?\n$/, '');
54
+ const recuoDe = (linha) => linha.match(/^[ \t]*/)[0];
55
+
56
+ /**
57
+ * O bloco `entrega:` de um `crew.yaml` já separado em linhas: onde começa e acaba, o recuo dos
58
+ * filhos e a linha de `destino:` (um nível abaixo; -1 quando não há). Sem o bloco: null.
59
+ */
60
+ export function blocoDaEntrega(linhas) {
61
+ const limpas = linhas.map(semFim);
62
+ const inicio = limpas.findIndex((l) => /^entrega:\s*(?:#.*)?$/.test(l));
63
+ if (inicio < 0) return null;
64
+ let fim = inicio + 1;
65
+ while (fim < limpas.length && (!limpas[fim].trim() || /^[ \t]/.test(limpas[fim]))) fim += 1;
66
+ const filhos = limpas.slice(inicio + 1, fim).filter((l) => l.trim());
67
+ const recuo = filhos.length ? recuoDe(filhos[0]) : ' ';
68
+ const destino = limpas.findIndex((l, i) => i > inicio && i < fim && recuoDe(l) === recuo && /^destino:/.test(l.trim()));
69
+ return { inicio, fim, recuo, destino };
70
+ }
71
+
72
+ /** O valor de uma linha `chave: valor`: sem as aspas e sem o comentário do fim da linha. */
73
+ function valorDaLinha(linha) {
74
+ const resto = linha.slice(linha.indexOf(':') + 1).trim();
75
+ const comAspas = resto.match(/^(["'])(.*?)\1\s*(?:#.*)?$/);
76
+ return comAspas ? comAspas[2] : resto.replace(/(?:^|\s+)#.*$/, '').trim();
77
+ }
78
+
79
+ /** `entrega.destino` como está escrito no texto de um `crew.yaml`; null quando não há. Uma lista volta como o primeiro item dela (`- a`), que nunca é um destino válido. */
80
+ export function destinoDoTexto(texto) {
81
+ const linhas = texto.replace(/^\uFEFF/, '').split(/\r?\n/);
82
+ const bloco = blocoDaEntrega(linhas);
83
+ if (!bloco || bloco.destino < 0) return null;
84
+ const valor = valorDaLinha(linhas[bloco.destino]);
85
+ if (valor) return valor;
86
+ const seguinte = (linhas[bloco.destino + 1] ?? '').trim();
87
+ return seguinte.startsWith('-') ? seguinte : null;
88
+ }
89
+
90
+ /**
91
+ * O destino desta chamada: `--lembrar-destino` vence `--destino`, que vence o `crew.yaml`.
92
+ * @returns {Promise<object>} o que `validarDestino` devolve, ou `{ tipo: 'nenhum' }` (ninguém escolheu)
93
+ */
94
+ export async function escolherDestino(raiz, crew, args) {
95
+ const doYaml = async () => destinoDoTexto(await fs.readFile(path.join(crew, 'crew.yaml'), 'utf8'));
96
+ const valor = args.lembrarDestino ?? args.destino ?? (await doYaml());
97
+ return valor == null ? { tipo: 'nenhum' } : validarDestino(raiz, valor);
98
+ }
@@ -0,0 +1,104 @@
1
+ // Documentos na entrega: o item cujo formato tem a plataforma `documento` vai para a pasta
2
+ // `documentos/`. Texto (`.md` ou `.txt`) vira Word, inteiro, com o perfil de documento oficial do
3
+ // projeto, se existir: os mesmos bytes que o `documento.mjs` grava para o mesmo texto e o mesmo
4
+ // perfil. Outro tipo de arquivo é copiado como está, com aviso. Erro ao converter (inclusive
5
+ // perfil inválido) é pendência de `documentos`, que nunca vira ressalva: os outros canais seguem.
6
+ // Este módulo só lê: quem grava é `gravar.mjs`.
7
+ // Spec: fase-u3b-documento-word.md, decisão 9, regra 12 e §6 (repositório do OpenCrew).
8
+ import { existsSync, promises as fs, statSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { PERFIL, gerarDocx, lerPerfilDoProjeto } from '../documento.mjs';
11
+ import { limpar } from './argumentos.mjs';
12
+
13
+ /** A pasta da entrega e o tipo do arquivo que é (ou vai virar) um Word. */
14
+ export const DOCUMENTOS = 'documentos';
15
+ export const WORD = 'documento';
16
+ const TEXTO = /\.(md|txt)$/i;
17
+
18
+ export const MSG = {
19
+ copiado: (arquivo) => `${arquivo}: só converto .md ou .txt em Word. Copiei o arquivo como está.`,
20
+ erro: (arquivo, motivo) => `Não consegui gerar o Word de ${arquivo}: ${limpar(motivo).replace(/\.+$/, '')}.`,
21
+ aviso: (arquivo, aviso) => `${arquivo}: ${aviso}`,
22
+ semPerfil: 'Sem papel timbrado: este projeto não tem perfil de documento oficial. Para criar o seu: node _opencrew/core/scripts/documento.mjs --criar-perfil',
23
+ semTexto: 'o arquivo não tem texto para converter',
24
+ naoUtf8: 'o arquivo não está em UTF-8',
25
+ };
26
+ /** A linha de "O que não foi conferido" da entrega que tem um Word. */
27
+ export const NAO_CONFERIDO = 'Como o documento abre no Word.';
28
+
29
+ /** O tipo do arquivo em `documentos/`: texto que vira Word, ou cópia como está. */
30
+ export const tipoDoDocumento = (arquivo) => (TEXTO.test(arquivo) ? WORD : 'copia');
31
+ export const naoConferidoDoWord = (arquivos) => (arquivos.some((a) => a.pasta === DOCUMENTOS && a.tipo === WORD) ? [NAO_CONFERIDO] : []);
32
+
33
+ /** O perfil do projeto, lido uma vez: `{}` sem perfil, `{ perfil, logotipo }` ou `{ erro }`. */
34
+ async function perfilDoProjeto(raiz) {
35
+ const arquivo = path.join(raiz, PERFIL);
36
+ if (!existsSync(arquivo) || !statSync(arquivo).isFile()) return {};
37
+ try {
38
+ return await lerPerfilDoProjeto(raiz, PERFIL);
39
+ } catch (falha) {
40
+ return { erro: falha?.message ?? falha };
41
+ }
42
+ }
43
+
44
+ /** O texto do arquivo, em UTF-8; null quando os bytes não são UTF-8. */
45
+ function decodificar(bytes) {
46
+ try {
47
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** O Word de um texto: `{ bytes, avisos }`, ou `{ erro }` com o motivo. */
54
+ async function converter(copia, lido, gerar) {
55
+ if (lido.erro) return { erro: lido.erro };
56
+ try {
57
+ const texto = decodificar(await fs.readFile(copia.de));
58
+ if (texto === null) return { erro: MSG.naoUtf8 };
59
+ const documento = gerar({ texto, perfil: lido.perfil, logotipo: lido.logotipo });
60
+ return documento.vazio ? { erro: MSG.semTexto } : documento;
61
+ } catch (falha) {
62
+ return { erro: falha?.message ?? falha };
63
+ }
64
+ }
65
+
66
+ /** A cópia do texto vira o arquivo Word: cada origem é um grupo, para dois nomes iguais não se perderem. */
67
+ const comoWord = (copia, bytes) => ({ ...copia, nome: copia.nome.replace(TEXTO, '.docx'), de: null, bytes, pastaDeOrigem: copia.de });
68
+ const aviso = (texto) => ({ pasta: DOCUMENTOS, texto });
69
+ const pendencia = (linha) => ({ linha, chave: null, preencher: null });
70
+
71
+ /** Um arquivo de `documentos/` → o que entra em `copias`, `avisos` ou `pendencias`. */
72
+ async function tratar(copia, lido, gerar, saida) {
73
+ if (copia.tipo !== WORD) {
74
+ saida.avisos.push(aviso(MSG.copiado(copia.origem)));
75
+ return saida.copias.push(copia);
76
+ }
77
+ const r = await converter(copia, lido, gerar);
78
+ if (r.erro) return saida.pendencias.push(pendencia(MSG.erro(copia.origem, r.erro)));
79
+ saida.avisos.push(...r.avisos.map((a) => aviso(MSG.aviso(copia.origem, a))));
80
+ return saida.copias.push(comoWord(copia, r.bytes));
81
+ }
82
+
83
+ /**
84
+ * Converte os textos que `separar` pôs em `documentos/`. Sem nenhum, devolve o que recebeu e não
85
+ * lê o perfil.
86
+ * @param {string} raiz a pasta do projeto
87
+ * @param {{ unidades: object[], copias: object[], avisos: object[] }} produtos o que `separar` devolve
88
+ * @param {function} [gerar] quem monta o Word (`gerarDocx`); os testes trocam para injetar um erro
89
+ * @returns {Promise<{ produtos: object, pendencias: object[] }>} `produtos`: os mesmos, com o Word
90
+ * (`{ …, nome: '<nome>.docx', bytes }`) no lugar de cada texto convertido e os avisos da conversão ·
91
+ * `pendencias`: uma por texto que não foi convertido, `{ linha, chave: null, preencher: null }`
92
+ */
93
+ export async function converterDocumentos(raiz, produtos, gerar = gerarDocx) {
94
+ const saida = { copias: [], avisos: [...produtos.avisos], pendencias: [] };
95
+ if (!produtos.copias.some((c) => c.pasta === DOCUMENTOS)) return { produtos, pendencias: [] };
96
+ const lido = produtos.copias.some((c) => c.pasta === DOCUMENTOS && c.tipo === WORD) ? await perfilDoProjeto(raiz) : {};
97
+ for (const copia of produtos.copias) {
98
+ if (copia.pasta === DOCUMENTOS) await tratar(copia, lido, gerar, saida);
99
+ else saida.copias.push(copia);
100
+ }
101
+ // Sem perfil, o Word sai sem cabeçalho: a entrega diz, para ninguém procurar um timbre que não existe.
102
+ if (!lido.perfil && !lido.erro && saida.copias.some((c) => c.pasta === DOCUMENTOS && c.bytes)) saida.avisos.push(aviso(MSG.semPerfil));
103
+ return { produtos: { ...produtos, copias: saida.copias, avisos: saida.avisos }, pendencias: saida.pendencias };
104
+ }
@@ -1,7 +1,8 @@
1
1
  // Blocos de rótulo do arquivo de origem que não chegam à entrega: os de serviço (notas, checklist,
2
2
  // FORMAT) e, nos formatos lidos pelo leitor de peças, os que não são a peça do formato (os SLIDES
3
3
  // de um carrossel, por exemplo). A entrega não muda: o LEIA-ME só passa a dizer o que ficou fora.
4
- // Spec: fase-u3a1-pasta-de-entrega.md, §4 e §6, ajuste da execução real (repositório do OpenCrew).
4
+ // Specs: fase-u3a1-pasta-de-entrega.md, §4 e §6, ajuste da execução real, e
5
+ // fase-u3a2-entrega-no-projeto.md, §6: o aviso do LEIA-ME cita o arquivo de origem (repositório do OpenCrew).
5
6
  import { semFrontmatter } from '../verificar/leitura.mjs';
6
7
  import { ROTULOS } from '../verificar/pecas.mjs';
7
8
  import { lerTrechos, marcar } from '../verificar/secoes.mjs';
@@ -9,7 +10,7 @@ import { PRINCIPAL } from './leitor.mjs';
9
10
  import { ehServico, secoesDeRotulo, semComentarios } from './texto.mjs';
10
11
 
11
12
  export const MSG = {
12
- fora: (blocos) => `Ficou fora do texto para colar: ${blocos.join(', ')}. Veja no arquivo de origem.`,
13
+ fora: (blocos, arquivo) => `Ficou fora do texto para colar: ${blocos.join(', ')}. Veja no arquivo de origem, \`${arquivo}\`.`,
13
14
  foraNaTela: (arquivo, blocos) => `${arquivo}: ficou fora do texto para colar: ${blocos.join(', ')}. Veja no arquivo de origem.`,
14
15
  };
15
16
 
@@ -45,5 +46,5 @@ export function blocosFora(texto, formato = null) {
45
46
 
46
47
  /** O aviso de um arquivo: `{ pasta, texto, tela }`, ou nenhum quando nada ficou fora. */
47
48
  export function avisoDeFora(item, blocos) {
48
- return blocos.length ? [{ pasta: item.canal, texto: MSG.fora(blocos), tela: MSG.foraNaTela(item.rel, blocos) }] : [];
49
+ return blocos.length ? [{ pasta: item.canal, texto: MSG.fora(blocos, item.rel), tela: MSG.foraNaTela(item.rel, blocos) }] : [];
49
50
  }
@@ -25,10 +25,10 @@ async function limparSobras({ entrega, tmp, antiga }) {
25
25
  if ((await tipoDe(tmp)) === 'pasta') await apagar(tmp);
26
26
  }
27
27
 
28
- /** Grava um arquivo da entrega na pasta temporária: o texto gerado, ou os mesmos bytes da origem. */
28
+ /** Grava um arquivo da entrega na pasta temporária: os bytes ou o texto gerados, ou os mesmos bytes da origem. */
29
29
  async function gravarArquivo(destino, a) {
30
30
  await fs.mkdir(path.dirname(destino), { recursive: true });
31
- if (a.texto != null) await fs.writeFile(destino, a.texto, 'utf8');
31
+ if ((a.bytes ?? a.texto) != null) await fs.writeFile(destino, a.bytes ?? a.texto, 'utf8');
32
32
  else await fs.copyFile(a.de, destino);
33
33
  }
34
34
 
@@ -51,7 +51,7 @@ async function trocar({ entrega, tmp, antiga }, passo) {
51
51
  /**
52
52
  * Refaz `entrega/` do zero e grava, ao lado, o relatório da verificação.
53
53
  * @param {string} execucao pasta da execução (`crews/<crew>/output/<run>/`), absoluta
54
- * @param {object[]} arquivos `{ pasta, nome, texto | de }`
54
+ * @param {object[]} arquivos `{ pasta, nome, texto | bytes | de }`
55
55
  * @param {{ leiame: string, relatorio: string }} textos
56
56
  * @returns {Promise<string|null>} null quando gravou tudo; senão, o caminho que não foi gravado
57
57
  */
@@ -0,0 +1,60 @@
1
+ // Guardar a entrega no projeto: decide o que está pronto para ser copiado, chama a cópia e diz,
2
+ // em uma ou duas linhas do resumo, o que aconteceu. Canal com pendência não aceita fica de fora,
3
+ // e o HTML editável dele também. Depois da cópia, guarda o retrato do que foi copiado (`copia.json`).
4
+ // Spec: fase-u3a2-entrega-no-projeto.md, regras 13 a 15 e §6 (repositório do OpenCrew).
5
+ import { relativoAoProjeto } from '../comum.mjs';
6
+ import { EDITAVEIS } from './canais.mjs';
7
+ import { retratoDe } from './comparar.mjs';
8
+ import { copiar } from './copia.mjs';
9
+ import { MSG as DESTINO } from './destino.mjs';
10
+ import { montarLeiame } from './leiame.mjs';
11
+ import { gravarRetratos, lerRetratos } from './retrato.mjs';
12
+
13
+ export const MSG = {
14
+ falhaDeEscrita: (arquivo) => `Não consegui gravar ${arquivo}. Feche o arquivo, ou espere a sincronização da pasta, e rode de novo.`,
15
+ criei: (destino) => `Criei a pasta ${destino}.`,
16
+ nova: (pasta) => `Cópia: ${pasta}`,
17
+ igual: (pasta) => `Cópia: ${pasta} — já está atualizada.`,
18
+ completada: (pasta, n) => `Cópia: ${pasta} — completei com ${n} ${n === 1 ? 'arquivo novo' : 'arquivos novos'}.`,
19
+ reentrega: (pasta) => `Cópia: ${pasta} — guardei aqui porque a entrega mudou. Os arquivos da anterior ficaram como estavam; só o LEIA-ME dela ganhou um aviso.`,
20
+ nada: 'Cópia: nada foi copiado, porque nenhum canal está pronto.',
21
+ };
22
+
23
+ /** A pasta cuja situação decide se o arquivo é copiado: a do canal (o editável segue o canal dele). */
24
+ const canalDe = (a) => (a.pasta === EDITAVEIS ? a.doCanal : a.pasta);
25
+
26
+ /** Pasta criada nesta chamada: o retrato dela começa do zero. */
27
+ const ehNova = (r) => r.tipo === 'nova' || r.tipo === 'reentrega';
28
+
29
+ /** As linhas do resumo para o que a cópia devolveu. */
30
+ function linhasDe(r, destino) {
31
+ if (r.tipo === 'nova') return [...(r.criouDestino ? [MSG.criei(destino.rel)] : []), MSG.nova(r.pasta)];
32
+ return [MSG[r.tipo](r.pasta, r.novos)];
33
+ }
34
+
35
+ /**
36
+ * Copia para o destino o que está pronto e descreve o resultado.
37
+ * @param {string} raiz pasta do projeto · @param {object} destino o que `escolherDestino` devolveu
38
+ * @param {object} dados os dados da entrega (os do LEIA-ME)
39
+ * @param {string} execucao a pasta da execução, absoluta: é nela que fica o retrato da cópia
40
+ * @returns {Promise<{ linhas: string[], pasta: string|null, falhou: boolean }>} `linhas`: as do
41
+ * resumo · `pasta`: a pasta da cópia, relativa ao projeto, quando há uma · `falhou`: destino
42
+ * recusado ou gravação que falhou (o final é ENTREGA:INCOMPLETA)
43
+ */
44
+ export async function guardar(raiz, destino, dados, execucao) {
45
+ if (destino.tipo === 'nao') return { linhas: [], pasta: null, falhou: false };
46
+ if (destino.tipo === 'nenhum') return { linhas: [DESTINO.semDestino], pasta: null, falhou: false };
47
+ if (destino.tipo === 'recusado') return { linhas: [DESTINO.recusado(destino.valor)], pasta: null, falhou: true };
48
+ const pronto = (a) => !dados.pendencias.has(canalDe(a));
49
+ const arquivos = dados.arquivos.filter(pronto);
50
+ if (!arquivos.length) return { linhas: [MSG.nada], pasta: null, falhou: false };
51
+ const ignorar = new Set(dados.arquivos.filter((a) => !pronto(a)).map((a) => `${a.pasta}/${a.nome}`));
52
+ const leiame = (n) => montarLeiame({ ...dados, arquivos, ehCopia: true, reentrega: n });
53
+ const retratos = await lerRetratos(execucao);
54
+ const r = await copiar({ destino, run: dados.run, arquivos, ignorar, leiame, retratos });
55
+ if (r.tipo === 'falha') return { linhas: [MSG.falhaDeEscrita(relativoAoProjeto(raiz, r.arquivo))], pasta: null, falhou: true };
56
+ // O retrato da pasta: o que já estava nele e o que esta entrega tem (copiado agora ou igual ao que foi).
57
+ const semRetrato = await gravarRetratos(execucao, { ...retratos, [r.pasta]: { ...(ehNova(r) ? {} : retratos[r.pasta]), ...(await retratoDe(arquivos)) } });
58
+ const falha = semRetrato ? [MSG.falhaDeEscrita(relativoAoProjeto(raiz, semRetrato))] : [];
59
+ return { linhas: [...linhasDe(r, destino), ...falha], pasta: r.pasta, falhou: falha.length > 0 };
60
+ }