@aksp/opencrew 1.7.1 → 1.9.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 (41) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +87 -4
  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 +4 -0
  8. package/templates/_opencrew/.opencrew-version +1 -1
  9. package/templates/_opencrew/core/prompts/discovery.prompt.md +1 -1
  10. package/templates/_opencrew/core/prompts/entrega.prompt.md +205 -0
  11. package/templates/_opencrew/core/prompts/export.prompt.md +5 -81
  12. package/templates/_opencrew/core/runner.pipeline.md +32 -23
  13. package/templates/_opencrew/core/scripts/entrega/argumentos.mjs +50 -0
  14. package/templates/_opencrew/core/scripts/entrega/canais.mjs +53 -0
  15. package/templates/_opencrew/core/scripts/entrega/comparar.mjs +104 -0
  16. package/templates/_opencrew/core/scripts/entrega/copia.mjs +138 -0
  17. package/templates/_opencrew/core/scripts/entrega/destino.mjs +98 -0
  18. package/templates/_opencrew/core/scripts/entrega/fora.mjs +50 -0
  19. package/templates/_opencrew/core/scripts/entrega/gravar.mjs +83 -0
  20. package/templates/_opencrew/core/scripts/entrega/guardar.mjs +60 -0
  21. package/templates/_opencrew/core/scripts/entrega/leiame.mjs +125 -0
  22. package/templates/_opencrew/core/scripts/entrega/leitor.mjs +133 -0
  23. package/templates/_opencrew/core/scripts/entrega/lembrar.mjs +65 -0
  24. package/templates/_opencrew/core/scripts/entrega/longas.mjs +109 -0
  25. package/templates/_opencrew/core/scripts/entrega/nomes.mjs +78 -0
  26. package/templates/_opencrew/core/scripts/entrega/passos.mjs +104 -0
  27. package/templates/_opencrew/core/scripts/entrega/pendencias.mjs +97 -0
  28. package/templates/_opencrew/core/scripts/entrega/ressalvas.mjs +78 -0
  29. package/templates/_opencrew/core/scripts/entrega/resumo.mjs +29 -0
  30. package/templates/_opencrew/core/scripts/entrega/retrato.mjs +42 -0
  31. package/templates/_opencrew/core/scripts/entrega/separar.mjs +106 -0
  32. package/templates/_opencrew/core/scripts/entrega/texto.mjs +95 -0
  33. package/templates/_opencrew/core/scripts/entregar.mjs +166 -0
  34. package/templates/_opencrew/core/scripts/verificar/argumentos.mjs +4 -4
  35. package/templates/_opencrew/core/scripts/verificar/entradas.mjs +26 -0
  36. package/templates/_opencrew/core/scripts/verificar/gravacao.mjs +41 -0
  37. package/templates/_opencrew/core/scripts/verificar/pecas.mjs +4 -4
  38. package/templates/_opencrew/core/scripts/verificar/relatorio.mjs +10 -3
  39. package/templates/_opencrew/core/scripts/verificar/secoes.mjs +22 -2
  40. package/templates/_opencrew/core/scripts/verificar.mjs +21 -29
  41. package/templates/gitignore +1 -0
@@ -1,59 +1,16 @@
1
- # Export — Multi-Format Output
1
+ # Export — Tables to CSV
2
2
 
3
- You are the opencrew Export agent. Your role is to transform pipeline outputs from markdown into the requested delivery format. You do NOT create content or make editorial decisions — you transform existing, approved content.
3
+ You are the opencrew Export agent. Your role is to transform the tables of a pipeline output from markdown into CSV. You do NOT create content or make editorial decisions — you transform existing, approved content.
4
4
 
5
5
  ## Context Loading
6
6
 
7
7
  Before starting, read:
8
8
  - The input file specified by the step's `inputFile` field — this is the source content to export
9
- - The step's `format:` field — this determines the target output format
9
+ - The step's `format:` field — `csv` is the only export format
10
10
 
11
11
  ---
12
12
 
13
- ## Supported Formats
14
-
15
- ### PDF (`format: pdf`)
16
-
17
- Transform markdown content into a PDF file using Playwright (already available in the project).
18
-
19
- **Process:**
20
- 1. Read the full input markdown file
21
- 2. Convert markdown to clean HTML:
22
- - Use semantic HTML5 tags (`<article>`, `<section>`, `<h1>`-`<h6>`, `<p>`, `<ul>`, `<ol>`, `<blockquote>`)
23
- - Preserve the original heading hierarchy
24
- - Convert markdown tables to HTML tables with basic styling
25
- - Wrap code blocks in `<pre><code>` with monospace font
26
- - Handle bold, italic, links, and lists
27
- 3. Wrap in a minimal HTML document with print-friendly CSS:
28
- ```html
29
- <!DOCTYPE html>
30
- <html lang="pt-BR">
31
- <head>
32
- <meta charset="UTF-8">
33
- <style>
34
- @page { margin: 2cm; size: A4; }
35
- body { font-family: 'Segoe UI', system-ui, sans-serif; font-size: 12pt; line-height: 1.6; color: #1a1a1a; }
36
- h1 { font-size: 22pt; margin-top: 0; }
37
- h2 { font-size: 16pt; border-bottom: 1px solid #ddd; padding-bottom: 4pt; }
38
- h3 { font-size: 13pt; }
39
- table { border-collapse: collapse; width: 100%; margin: 12pt 0; }
40
- th, td { border: 1px solid #ddd; padding: 6pt 8pt; text-align: left; }
41
- th { background: #f5f5f5; }
42
- code { font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: 10pt; background: #f0f0f0; padding: 1pt 4pt; border-radius: 3pt; }
43
- pre code { display: block; padding: 8pt 12pt; overflow-x: auto; }
44
- blockquote { border-left: 3pt solid #ccc; margin-left: 0; padding-left: 12pt; color: #555; }
45
- </style>
46
- </head>
47
- <body>{content}</body>
48
- </html>
49
- ```
50
- 4. Write the HTML to a temporary file: `crews/{crew-name}/output/{run_id}/export/temp.html`
51
- 5. Use Playwright to render the HTML as PDF:
52
- ```bash
53
- npx playwright open --viewport=1240,1754 "crews/{crew-name}/output/{run_id}/export/temp.html"
54
- ```
55
- Then use the print-to-PDF functionality.
56
- 6. Save the PDF to the step's `outputFile` path
13
+ ## Supported Format
57
14
 
58
15
  ### CSV / Excel (`format: csv`)
59
16
 
@@ -84,50 +41,17 @@ Keyword,Intent,Volume,Competition
84
41
  "product adoption",Informational,Low,Low
85
42
  ```
86
43
 
87
- ### Formatted Social Post (`format: formatted-post`)
88
-
89
- Transform markdown content into a platform-ready post with proper formatting.
90
-
91
- **Process:**
92
- 1. Read the full input markdown file
93
- 2. Extract the post content: caption/hook, body, CTA, hashtags
94
- 3. Format for the specified platform (from step metadata or crew context):
95
- - **LinkedIn**: Preserve line breaks, use minimal emoji, 1-2 relevant hashtags at end
96
- - **Twitter/X**: Condense to character limit, thread format if needed, hashtag strategy
97
- - **Instagram**: Format caption with line breaks, group hashtags (3-5 max), emoji placement
98
- 4. Output as clean text with platform-specific formatting notes:
99
- ```markdown
100
- # Formatted Post — {platform}
101
-
102
- **Caption:**
103
- {formatted caption text}
104
-
105
- **Hashtags:**
106
- {hashtag list}
107
-
108
- **Formatting notes:**
109
- - Line breaks: {count} intentional breaks
110
- - Character count: {N}
111
- - Best posting time: {recommendation based on crew context}
112
- ```
113
-
114
- ---
115
-
116
44
  ## Smart Recommendations
117
45
 
118
- - **Multiple outputs from same content**: If the crew produces one piece of content that needs to go to multiple platforms, batch the exports. Export the same source to all required formats in sequence.
119
- - **PDF quality**: The print CSS is minimal but functional. For brand-specific PDFs (logos, custom fonts, color schemes), the user should use a design template (via `template-designer` skill).
46
+ - **Text for each channel**: the delivery folder of the run already has the text of each channel ready to paste, and its `LEIA-ME.md` — this prompt does not format posts.
120
47
  - **CSV structure**: The CSV export extracts ALL tables from the source. If the source has one main data table, it produces one clean CSV. If it has many, they're separated by `# Table:` headers.
121
48
 
122
49
  ## Limitations
123
50
 
124
- - PDF export uses Playwright's built-in print-to-PDF. Complex layouts (multi-column, absolute positioning) may not render correctly.
125
51
  - CSV export is from markdown tables only — it does not parse JSON, YAML, or unstructured data.
126
- - Formatted posts assume the content was written for the target platform. Cross-platform adaptation (e.g., blog → Twitter thread) should be done by a content agent before export.
127
52
 
128
53
  ## Error Handling
129
54
 
130
55
  - If the input file is missing → **ERROR**: stop, inform the user
131
56
  - If the input file has no extractable content for the target format (e.g., CSV requested but no tables found) → warn the user, save a note in the output file
132
- - If Playwright is unavailable for PDF export → fall back to saving the HTML file as the output, inform the user
133
57
  - **Never fabricate content — only transform what exists in the input file**
@@ -283,19 +283,17 @@ Before executing any step that references an agent:
283
283
  - Apply Voice Guidance (vocabulary always/never use, tone rules)
284
284
  5. **Inject format context**: Check if the current step's frontmatter contains a `format:` field.
285
285
  If present:
286
- a. **Export formats** — if format is one of `pdf`, `csv`, or `formatted-post`:
287
- - Read `_opencrew/core/prompts/export.prompt.md`
288
- - Parse the YAML frontmatter to extract the `name` field
289
- - Extract the Markdown body (everything after the YAML frontmatter closing `---`)
290
- - Append to the agent's context, before skill instructions:
291
- ```
292
- --- EXPORT FORMAT: {format} ---
293
-
294
- {export.prompt.md markdown body}
295
- ```
296
- - The agent must follow the export process for the specified format — read the input file,
297
- transform the content, and write the output file in the target format.
298
- - Skip the best-practices lookup below for export formats.
286
+ a. **Export format** — if format is `csv`:
287
+ - Read `_opencrew/core/prompts/export.prompt.md` and append its Markdown body to the agent's
288
+ context, before skill instructions, under the line `--- EXPORT FORMAT: csv ---`
289
+ - The agent must follow the export process — read the input file, transform the content,
290
+ and write the output file in the target format. Skip the best-practices lookup below.
291
+ a2. **`pdf` or `formatted-post`** (a step of an old crew) — neither is generated any more. Say
292
+ `O formato "{id}" não é mais gerado. O passo segue sem ele e grava o texto em markdown. Para ter um PDF, use Imprimir → Salvar como PDF.`
293
+ (`{id}` = the format) and run it as a common step, with no format injection. For `pdf`, the
294
+ agent writes markdown and the `outputFile` is used with the extension `.md`: that is the
295
+ path that goes to `caminho.mjs` (`saida`, `conferir`) and that the next steps read (their
296
+ `inputFile`, too) — no `.pdf` is created.
299
297
  b. **Content formats** — otherwise, read `_opencrew/best-practices.local/{format}.md` (the user's
300
298
  own version, never touched by `update`) if it exists, else `_opencrew/core/best-practices/{format}.md`
301
299
  (e.g., `_opencrew/core/best-practices/instagram-feed.md`)
@@ -500,6 +498,7 @@ root the one-line command of each moment and read the last line (`CAMINHO:OK {pa
500
498
  is empty → this check never fires (legacy behavior).
501
499
 
502
500
  0b. **Escritório** — if it is on, run `passo`, or `checkpoint` when the step is a checkpoint (see "Escritório" above).
501
+ 0c. **Entrega** — before the first step that publishes or sends, once the final approval was given, run the delivery (see "Entrega" below).
503
502
 
504
503
  1. **Pre-Step Input Validation** — MANDATORY. If the step's frontmatter declares an `inputFile`, the input comes from the `entrada` action, never from a path you build: validate that the input exists before executing the step. Run the `entrada` command (Output Path Transformation) with the `inputFile` as declared:
505
504
  - `CAMINHO:OK {path}` → that path is the step's input (the newest version that has the file): read the input from it and execute the step.
@@ -668,10 +667,10 @@ When a step has `on_reject: {step-id}` (a review step):
668
667
  the `format:` of the step that generated that file; a step with no `format:`, with an export
669
668
  format (`pdf`, `csv`, `formatted-post`) or with one outside `[a-z0-9-]+` goes without `=formato`:
670
669
  ```bash
671
- node _opencrew/core/scripts/verificar.mjs --crew "crews/{name}" --arquivo "{path1}={format1},{path2},…"
670
+ node _opencrew/core/scripts/verificar.mjs --crew "crews/{name}" --arquivo "{path1}={format1},{path2},…" --relatorio "crews/{name}/output/{run_id}/verificacao-ciclo-{N}.md"
672
671
  ```
673
- Save the full output to `crews/{name}/output/{run_id}/verificacao-ciclo-{N}.md` and inject it
674
- into the reviewer's context as `--- VERIFICAÇÃO AUTOMÁTICA ---`. The reviewer must copy the
672
+ The script writes its report to that file (`{N}` = the cycle; do not save it yourself). Inject
673
+ the output into the reviewer's context as `--- VERIFICAÇÃO AUTOMÁTICA ---`. The reviewer must copy the
675
674
  measured values from it (see best-practices `review.md`). If the checker did not run (no Node,
676
675
  an error, or no `VERIFICACAO:` status line), tell the user, continue with the normal review and
677
676
  repeat it at the final approval: "⚠️ A verificação automática não rodou: {motivo}".
@@ -694,11 +693,12 @@ When a step has `on_reject: {step-id}` (a review step):
694
693
  {any other status} A revisão não aprovou o texto depois de {N} ciclos. Motivo: {parecer resumido}
695
694
 
696
695
  1. Corrigir eu mesmo (eu edito o texto e você verifica de novo)
697
- 2. Aceitar assim mesmo
696
+ 2. Aceitar assim mesmo (fica registrado na entrega)
698
697
  3. Abortar
699
698
  ```
700
699
  5. **Final approval checkpoint** (the checkpoint after the review): show the summary of the last
701
- report — `Verificação automática: {N} bloqueios, {M} alertas, {Z} não medidos` — plus the list
700
+ report — `Verificação automática: {N} bloqueios, {M} alertas, {Z} não medidos`, with
701
+ `{P} a preencher` right after the blocks when the report counts any — plus the list
702
702
  of alerts and the {Z} items not measured or not verified (the `Não medido` and `Não verificado`
703
703
  lines under each file, not the "não é texto" line of **Notas**), one per line as
704
704
  `{arquivo} — {motivo}`, then the lines under `**Notas:**` in that report, as they are written,
@@ -706,7 +706,8 @@ When a step has `on_reject: {step-id}` (a review step):
706
706
  every file left unchecked by the safe-name rule. List the same way every file the path script
707
707
  did not check (see Output Path Transformation). If the approved
708
708
  text still contains `[PREENCHER: …]`, ask the user for each missing piece of real information
709
- and write it into the text before approving.
709
+ and write it into the text before approving. If the user does not have it, do not insist and
710
+ never invent: keep the `[PREENCHER]`, say `Sem problema: deixo [PREENCHER: {o que falta}] no texto. Na entrega você escolhe entre preencher depois e entregar assim mesmo, com ressalva.` and go on.
710
711
 
711
712
  ### Step Execution Order (Summary)
712
713
 
@@ -715,6 +716,7 @@ For reference, the complete execution order for each pipeline step is:
715
716
  ```
716
717
  0. Agent deselection check (skip step if its agent was deselected)
717
718
  0b. Escritório command (passo or checkpoint) — only if it is on
719
+ 0c. Entrega (delivery script) — only before the first step that publishes or sends
718
720
  1. Pre-Step Input Validation (script gate: `entrada`)
719
721
  2. Read step file
720
722
  3. Check execution mode and execute (subagent / inline / checkpoint)
@@ -724,10 +726,18 @@ For reference, the complete execution order for each pipeline step is:
724
726
 
725
727
  Steps 1 and 4 are binary script gates. If either fails, the pipeline does NOT advance — the user is consulted.
726
728
 
729
+ ### Entrega
730
+
731
+ One script turns the approved files into `crews/{name}/output/{run_id}/entrega/` (a folder per channel, text ready to paste, a `LEIA-ME.md`) and copies what is ready to the folder of the project the user chose. Read `_opencrew/core/prompts/entrega.prompt.md` and follow it: how to build `{lista}`, when to add `--vai-publicar`, what to do with `ENTREGA:OK`, `ENTREGA:COM_RESSALVA` and `ENTREGA:INCOMPLETA`, the question about the folder of the project that keeps a copy (asked once per crew) and what to do with a script that did not run.
732
+
733
+ - **Command** — from the project root, by the safe-name rule (nome seguro), everything between double quotes: `node _opencrew/core/scripts/entregar.mjs --crew "crews/{name}" --run "{run_id}" --arquivo "{lista}"`
734
+ - **When** — once, after the final approval, immediately before the first step that publishes or sends (`side_effects: irreversible`, in the step or in the agent's skill); with no such step, after the last step. Always before the end-of-run command of the Escritório. If the irreversible step comes before the final approval (a crew built by an old version), the delivery runs at the end.
735
+ - **Crew with no final approval checkpoint** — same moments, and show `Esta crew não tem aprovação final: confira os arquivos antes de usar.` **Never** for a run that was rejected, aborted before the final approval or left with no approved file.
736
+ - The output of the script is the final summary of the run: show it as it came; if the run stops later, at an irreversible step, show it before stopping. After "Edit this content" changes an approved file, run it again.
737
+
727
738
  ### After Pipeline Completion
728
739
 
729
- 1. Save final output to `crews/{name}/output/{run_id}/{filename}.md`
730
- (The run folder was created during initialization — no separate date subfolder needed)
740
+ 1. **Entrega** — if the delivery has not run in this run, run it now (see "Entrega" above).
731
741
  1b. **Escritório** — if it is on, run `concluir` (see "Escritório" above).
732
742
 
733
743
  2. **Update crew memory** — write to BOTH files:
@@ -828,8 +838,7 @@ Steps 1 and 4 are binary script gates. If either fails, the pipeline does NOT ad
828
838
  ```
829
839
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
830
840
  ✅ Pipeline complete!
831
- 📁 Run folder: crews/{name}/output/{run_id}/
832
- 📄 Output saved to: {output path}
841
+ 📁 Delivery: crews/{name}/output/{run_id}/entrega/ — start with LEIA-ME.md
833
842
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
834
843
 
835
844
  What would you like to do?
@@ -0,0 +1,50 @@
1
+ // Linha de comando do `entregar.mjs`: as opções, a lista `caminho=formato` e a linha de uso.
2
+ // Specs: fase-u3a1-pasta-de-entrega.md, §3 e §6, e fase-u3a2-entrega-no-projeto.md, §3
3
+ // (repositório do OpenCrew).
4
+ import { lerItemDaLista } from '../verificar/argumentos.mjs';
5
+
6
+ export const USO = 'Uso: node _opencrew/core/scripts/entregar.mjs --crew "crews/<crew>" --run "<id>" --arquivo "<caminho=formato>[,<caminho=formato>…]" [--destino "<pasta>"] [--lembrar-destino "<pasta>"|nao] [--aceitar-pendencias] [--vai-publicar <canal>] [--ajuda]';
7
+
8
+ const OPCAO = /^--(crew|run|arquivo|vai-publicar|destino|lembrar-destino)(?:=(.*))?$/s;
9
+ const CHAVE = { 'lembrar-destino': 'lembrarDestino' };
10
+
11
+ /** Texto que veio da linha de comando e volta numa mensagem: uma linha só, até 200 caracteres. */
12
+ export const limpar = (valor) => String(valor).replace(/\s+/g, ' ').trim().slice(0, 200);
13
+
14
+ function guardar(args, nome, valor) {
15
+ if (nome === 'arquivo') args.arquivos.push(valor);
16
+ else if (nome === 'vai-publicar') args.vaiPublicar.push(valor.trim().toLowerCase());
17
+ else args[CHAVE[nome] ?? nome] = valor;
18
+ }
19
+
20
+ /**
21
+ * `argv` → `{ crew, run, arquivos, vaiPublicar, destino, lembrarDestino, aceitar, ajuda,
22
+ * desconhecida }`. Opção vale como `--nome valor` e `--nome=valor`. `--arquivo` e `--vai-publicar`
23
+ * repetidos somam, e o que vem solto logo depois da lista é mais um item dela. `destino` e
24
+ * `lembrarDestino` ficam sem valor (undefined) quando a opção não veio. `desconhecida`: a primeira
25
+ * opção que o script não conhece, ou null.
26
+ */
27
+ export function lerArgs(argv) {
28
+ const args = { arquivos: [], vaiPublicar: [], aceitar: false, ajuda: false, desconhecida: null };
29
+ let naLista = false;
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const [, nome, colado] = argv[i].match(OPCAO) ?? [];
32
+ const solto = !nome && !argv[i].startsWith('--');
33
+ if (nome) guardar(args, nome, colado ?? (i + 1 < argv.length && !argv[i + 1].startsWith('--') ? argv[++i] : ''));
34
+ else if (argv[i] === '--ajuda') args.ajuda = true;
35
+ else if (argv[i] === '--aceitar-pendencias') args.aceitar = true;
36
+ else if (solto && naLista) args.arquivos.push(argv[i]);
37
+ else args.desconhecida ??= argv[i];
38
+ naLista = nome ? nome === 'arquivo' : solto && naLista;
39
+ }
40
+ return args;
41
+ }
42
+
43
+ /** A lista de `--arquivo` → `[{ arquivo, formato }]` (`formato` null quando não veio `=formato`). */
44
+ export function lerLista(arquivos) {
45
+ const textos = arquivos.join(',').split(',').map((s) => s.trim()).filter(Boolean);
46
+ return textos.map(lerItemDaLista).map((i) => (typeof i === 'string' ? { arquivo: i, formato: null } : i));
47
+ }
48
+
49
+ /** O `--run` é um segmento só: sem `/`, `\` nem `..`. */
50
+ export const runValido = (run) => !/[\\/]/.test(run) && run !== '..' && run !== '.';
@@ -0,0 +1,53 @@
1
+ // Canais da entrega: a pasta de cada formato (o `platform:` do best-practice), os nomes que o
2
+ // LEIA-ME mostra e o que nunca entra numa entrega.
3
+ // Spec: fase-u3a1-pasta-de-entrega.md, regras 1 e 3 (repositório do OpenCrew).
4
+ import { existsSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { lerFrontmatter, lerTexto } from '../verificar/leitura.mjs';
7
+
8
+ /** Pasta do canal → nome no LEIA-ME, na ordem em que as seções aparecem. */
9
+ export const CANAIS = { instagram: 'Instagram', linkedin: 'LinkedIn', blog: 'Blog', email: 'E-mail', whatsapp: 'WhatsApp', twitter: 'X/Twitter', youtube: 'YouTube' };
10
+ export const OUTROS = 'outros';
11
+ export const EDITAVEIS = 'editaveis';
12
+ export const ehCanal = (pasta) => Object.hasOwn(CANAIS, pasta);
13
+ /** Como a pasta é chamada numa mensagem: o nome do canal, ou `outros/`. */
14
+ export const nomeDaPasta = (pasta) => CANAIS[pasta] ?? `${pasta}/`;
15
+
16
+ async function plataformaEm(raiz, pasta, formato) {
17
+ const arquivo = path.join(raiz, '_opencrew', ...pasta, `${formato}.md`);
18
+ if (!existsSync(arquivo)) return null;
19
+ const valor = lerFrontmatter(await lerTexto(arquivo))?.platform;
20
+ return typeof valor === 'string' && valor.trim() ? valor.trim().toLowerCase() : null;
21
+ }
22
+
23
+ /**
24
+ * Canal de um formato: o `platform:` de `_opencrew/best-practices.local/<formato>.md`, quando o
25
+ * arquivo o declara; senão, o do core. Sem formato, sem best-practice, sem `platform:` ou com
26
+ * plataforma que não é uma das sete pastas: null (o arquivo vai para `outros/`).
27
+ */
28
+ export async function canalDoFormato(raiz, formato) {
29
+ if (!formato || !/^[a-z0-9-]+$/.test(formato)) return null;
30
+ const local = await plataformaEm(raiz, ['best-practices.local'], formato);
31
+ const plataforma = local ?? (await plataformaEm(raiz, ['core', 'best-practices'], formato));
32
+ return plataforma && ehCanal(plataforma) ? plataforma : null;
33
+ }
34
+
35
+ const PASTAS_DE_SERVICO = new Set(['entrega', 'entrega.tmp', 'export']);
36
+
37
+ /**
38
+ * Arquivo de serviço nunca entra, mesmo listado: `verificacao-*.md`, `ressalvas.json` (as
39
+ * pendências aceitas, fase-u3a2-entrega-no-projeto.md, regra 18), `publicado.json`, o que está
40
+ * em `entrega/`, `entrega.tmp/` ou `export/` de uma execução e o `caption.txt` direto na pasta
41
+ * da execução (é ali que o publicador o grava; dentro de uma pasta `vN` ele é saída de passo).
42
+ * E o `copia.json` direto na pasta da execução: o retrato do que foi copiado (mesma spec, regra 14).
43
+ * @param {string} rel caminho relativo ao projeto, com `/`
44
+ */
45
+ export function ehDeServico(rel) {
46
+ const partes = rel.toLowerCase().split('/');
47
+ const nome = partes.at(-1);
48
+ if (/^verificacao-.*\.md$/.test(nome) || nome === 'publicado.json' || nome === 'ressalvas.json') return true;
49
+ const naSaida = partes[0] === 'crews' && partes[2] === 'output';
50
+ if (!naSaida) return false;
51
+ if (partes.slice(4, -1).some((p) => PASTAS_DE_SERVICO.has(p))) return true;
52
+ return (nome === 'caption.txt' || nome === 'copia.json') && partes.length === 5;
53
+ }
@@ -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: o texto gerado, ou os do arquivo de origem. */
15
+ export const bytesDe = async (a) => (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.texto != null) await fs.writeFile(alvo, 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
+ }