@aksp/opencrew 1.6.2 → 1.7.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 (71) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +70 -13
  3. package/package.json +2 -2
  4. package/src/cli.js +15 -38
  5. package/src/commands/init.js +41 -44
  6. package/src/commands/update.js +78 -75
  7. package/src/lib/blocos.js +148 -0
  8. package/src/lib/deteccao.js +69 -0
  9. package/src/lib/fsx.js +1 -55
  10. package/src/lib/ides.js +4 -0
  11. package/src/lib/legado.js +142 -0
  12. package/src/lib/manifest.js +67 -26
  13. package/src/lib/mcp.js +131 -0
  14. package/src/lib/migrations.js +77 -74
  15. package/src/lib/node-version.js +43 -0
  16. package/src/lib/prompts.js +25 -2
  17. package/src/lib/resumo.js +125 -0
  18. package/templates/.mcp.json +1 -1
  19. package/templates/AGENTS.md +20 -6
  20. package/templates/_opencrew/.opencrew-version +1 -1
  21. package/templates/_opencrew/core/best-practices/social-networks-publishing.md +14 -14
  22. package/templates/_opencrew/core/escritorio/animacao.js +64 -0
  23. package/templates/_opencrew/core/escritorio/app.js +137 -0
  24. package/templates/_opencrew/core/escritorio/cena.js +132 -0
  25. package/templates/_opencrew/core/escritorio/demo.js +79 -0
  26. package/templates/_opencrew/core/escritorio/escala.js +27 -0
  27. package/templates/_opencrew/core/escritorio/index.html +166 -0
  28. package/templates/_opencrew/core/escritorio/modelo-agentes.js +93 -0
  29. package/templates/_opencrew/core/escritorio/modelo-estado.js +71 -0
  30. package/templates/_opencrew/core/escritorio/modelo-mesas.js +81 -0
  31. package/templates/_opencrew/core/escritorio/modelo-pagina.js +95 -0
  32. package/templates/_opencrew/core/escritorio/modelo-textos.js +65 -0
  33. package/templates/_opencrew/core/escritorio/modelo-visao.js +91 -0
  34. package/templates/_opencrew/core/escritorio/modelo.js +29 -0
  35. package/templates/_opencrew/core/escritorio/painel.js +120 -0
  36. package/templates/_opencrew/core/escritorio/quadro.js +106 -0
  37. package/templates/_opencrew/core/escritorio/rota.js +62 -0
  38. package/templates/_opencrew/core/escritorio/rotulos.js +78 -0
  39. package/templates/_opencrew/core/escritorio/sprites-mesa.js +122 -0
  40. package/templates/_opencrew/core/escritorio/sprites-sala.js +92 -0
  41. package/templates/_opencrew/core/escritorio/sprites.js +187 -0
  42. package/templates/_opencrew/core/prompts/build.prompt.md +3 -3
  43. package/templates/_opencrew/core/prompts/export.prompt.md +1 -1
  44. package/templates/_opencrew/core/prompts/repair.prompt.md +7 -12
  45. package/templates/_opencrew/core/prompts/sherlock-shared.md +5 -5
  46. package/templates/_opencrew/core/runner.pipeline.md +76 -139
  47. package/templates/_opencrew/core/scripts/comum.mjs +49 -4
  48. package/templates/_opencrew/core/scripts/conferir-fontes/busca.mjs +42 -3
  49. package/templates/_opencrew/core/scripts/conferir-fontes/relatorio.mjs +18 -6
  50. package/templates/_opencrew/core/scripts/conferir-fontes.mjs +97 -39
  51. package/templates/_opencrew/core/scripts/escritorio/leitura.mjs +31 -0
  52. package/templates/_opencrew/core/scripts/escritorio/porta.mjs +98 -0
  53. package/templates/_opencrew/core/scripts/escritorio/projeto.mjs +29 -0
  54. package/templates/_opencrew/core/scripts/escritorio/servidor.mjs +78 -0
  55. package/templates/_opencrew/core/scripts/escritorio.mjs +117 -0
  56. package/templates/_opencrew/core/scripts/estado/argumentos.mjs +61 -0
  57. package/templates/_opencrew/core/scripts/estado/arquivo.mjs +53 -0
  58. package/templates/_opencrew/core/scripts/estado/decisao.mjs +56 -0
  59. package/templates/_opencrew/core/scripts/estado/elenco.mjs +58 -0
  60. package/templates/_opencrew/core/scripts/estado/nucleo.mjs +113 -0
  61. package/templates/_opencrew/core/scripts/estado/preferencia.mjs +24 -0
  62. package/templates/_opencrew/core/scripts/estado.mjs +96 -0
  63. package/templates/_opencrew/core/scripts/verificar.mjs +7 -4
  64. package/templates/_opencrew/core/skills.engine.md +7 -3
  65. package/templates/gitignore +1 -0
  66. package/templates/skills/blotato/SKILL.md +39 -10
  67. package/templates/skills/image-ai-generator/SKILL.md +18 -5
  68. package/templates/skills/image-ai-generator/scripts/generate.py +52 -10
  69. package/templates/skills/instagram-publisher/SKILL.md +4 -0
  70. package/templates/skills/opencrew-skill-creator/references/skill-format.md +1 -0
  71. package/templates/skills/resend/SKILL.md +52 -13
@@ -6,27 +6,32 @@
6
6
  // Rode a partir da pasta do projeto (a que tem `_opencrew/`); a crew fica dentro dela.
7
7
  // Última linha da saída: FONTES:OK ou FONTES:PENDENTE (o runner lê esta linha).
8
8
  // Código de saída: 0 = conferiu (OK ou PENDENTE) · 1 = erro de uso (opção faltando, pasta sem
9
- // `_opencrew/`, crew fora do projeto ou inexistente), sem linha FONTES: e sem escrever nada.
10
- // Specs: specs/fase-u2-crew-que-conhece-o-projeto.md e specs/fase-r1-reparos-1-6-1.md
11
- // (repositório do OpenCrew). Módulos em conferir-fontes/: coleta, busca e relatório.
9
+ // `_opencrew/`, crew fora do projeto ou inexistente) ou arquivo da crew que não dá para ler
10
+ // ("Não consegui conferir: …"); com código 1 não há linha FONTES:.
11
+ // Caminho de rede e endereço de site citados não são testados (alerta "não conferi"); o script
12
+ // nunca acessa a rede, e o --corrigir nunca grava fora da pasta real da crew.
13
+ // Specs: specs/fase-u2-crew-que-conhece-o-projeto.md, specs/fase-r1-reparos-1-6-1.md e
14
+ // specs/fase-r2-update-e-envio-seguros.md, regras 23 a 26 (repositório do OpenCrew).
15
+ // Módulos em conferir-fontes/: coleta, busca e relatório.
12
16
  import { readFile, writeFile, copyFile } from 'node:fs/promises';
13
17
  import { existsSync } from 'node:fs';
14
18
  import path from 'node:path';
15
- import { erroDeUso, dentroDoProjeto, ehPrincipal } from './comum.mjs';
19
+ import { erroDeUso, dentroDoProjeto, relativoAoProjeto, realDentroDe, ehPrincipal } from './comum.mjs';
16
20
  import { coletar, trocarCitacao } from './conferir-fontes/coleta.mjs';
17
21
  import {
18
- LIMITE_DA_BUSCA, barra, ehAbsoluto, temBarraFinal, resolver, indexar, candidatosPorNome, nomesDaPastaEsperada,
22
+ LIMITE_DA_BUSCA, ehAbsoluto, ehRedeOuSite, pareceSite, temBarraFinal, resolver, indexar, candidatosPorNome, nomesDaPastaEsperada,
19
23
  } from './conferir-fontes/busca.mjs';
20
24
  import { formatar, MSG } from './conferir-fontes/relatorio.mjs';
21
25
 
22
26
  export { formatar };
23
27
 
24
28
  /**
25
- * Caminho absoluto que existe dentro do projeto: o mesmo caminho, relativo à raiz. A própria raiz
26
- * não tem caminho relativo a sugerir.
29
+ * Caminho absoluto que existe dentro do projeto: o mesmo caminho, relativo à raiz — pelo lugar
30
+ * real, quando foi escrito por um link que leva ao projeto. A própria raiz não tem caminho
31
+ * relativo a sugerir.
27
32
  */
28
33
  function sugestaoRelativa(raiz, ref, achado) {
29
- const relativo = dentroDoProjeto(raiz, achado) ? barra(path.relative(raiz, achado)) : '';
34
+ const relativo = dentroDoProjeto(raiz, achado) ? relativoAoProjeto(raiz, achado) : '';
30
35
  return relativo ? relativo + (temBarraFinal(ref) ? '/' : '') : null;
31
36
  }
32
37
 
@@ -43,27 +48,43 @@ async function procurar(item, { raiz, crew, indice, destino }) {
43
48
  if (!item.candidatos.length) item.pasta = await nomesDaPastaEsperada(raiz, crew, item.ref);
44
49
  }
45
50
 
51
+ /**
52
+ * Estado de uma citação. Caminho de rede ou endereço de site não é testado: vira o alerta
53
+ * "não conferido" (regra 25). O resto é procurado no disco: o que falta é pendência, e o caminho
54
+ * absoluto que existe, o alerta de não portátil. `ctx.indice` é montado só na primeira falta.
55
+ */
56
+ async function classificar(item, destino, ctx) {
57
+ const { raiz, crew } = ctx;
58
+ if (ehRedeOuSite(item.ref)) {
59
+ item.estado = 'nao-conferido';
60
+ return;
61
+ }
62
+ const achado = resolver(raiz, crew, item.ref, item.citadoEm);
63
+ if (!achado) {
64
+ ctx.indice ??= await indexar(raiz, ctx.limite);
65
+ if (pareceSite(ctx, item) && !candidatosPorNome(ctx.indice, item.ref).length) item.estado = 'nao-conferido';
66
+ else await procurar(item, { raiz, crew, indice: ctx.indice, destino });
67
+ } else if (ehAbsoluto(item.ref)) {
68
+ item.estado = 'nao-portatil';
69
+ item.sugestao = sugestaoRelativa(raiz, item.ref, achado);
70
+ }
71
+ }
72
+
46
73
  /**
47
74
  * Confere os caminhos que a crew cita. `limite` é o máximo de itens do projeto vistos na busca
48
- * por nome; quando a busca para nele, `buscaParcial` é true.
75
+ * por nome; quando a busca para nele, `buscaParcial` é true. Estados: ok · faltando (pendência) ·
76
+ * nao-portatil e nao-conferido (alertas; não mudam o status).
49
77
  */
50
78
  export async function conferir({ raiz, crew, limite = LIMITE_DA_BUSCA }) {
51
- let indice = null;
79
+ const ctx = { raiz, crew, limite, indice: null };
52
80
  const refs = [];
53
81
  for (const [ref, { arquivos, destino }] of await coletar(raiz, crew)) {
54
82
  const item = { ref, citadoEm: [...arquivos], estado: 'ok', sugestao: null, candidatos: [], pasta: [] };
55
- const achado = resolver(raiz, crew, ref, item.citadoEm);
56
- if (!achado) {
57
- indice ??= await indexar(raiz, limite);
58
- await procurar(item, { raiz, crew, indice, destino });
59
- } else if (ehAbsoluto(ref)) {
60
- item.estado = 'nao-portatil';
61
- item.sugestao = sugestaoRelativa(raiz, ref, achado);
62
- }
83
+ await classificar(item, destino, ctx);
63
84
  refs.push(item);
64
85
  }
65
86
  const status = refs.some((i) => i.estado === 'faltando') ? 'PENDENTE' : 'OK';
66
- return { crew, raiz, refs, status, buscaParcial: Boolean(indice?.parcial), limite };
87
+ return { crew, raiz, refs, status, buscaParcial: Boolean(ctx.indice?.parcial), limite };
67
88
  }
68
89
 
69
90
  async function copiaDeSeguranca(arquivo) {
@@ -71,38 +92,69 @@ async function copiaDeSeguranca(arquivo) {
71
92
  await copyFile(arquivo, bak);
72
93
  }
73
94
 
95
+ /** Regrava a citação num arquivo; a cópia .bak é feita uma vez por arquivo. */
96
+ async function regravar(arquivo, item, tocados) {
97
+ const texto = await readFile(arquivo, 'utf8');
98
+ if (!tocados.has(arquivo)) {
99
+ await copiaDeSeguranca(arquivo);
100
+ tocados.add(arquivo);
101
+ }
102
+ await writeFile(arquivo, trocarCitacao(texto, item, path.basename(arquivo) === 'crew.yaml'));
103
+ }
104
+
105
+ /**
106
+ * Guarda de escrita do --corrigir (regra 24): só se grava em arquivo cujo lugar real fica dentro
107
+ * da pasta real da crew, e só se ela fica dentro do projeto. A pasta do arquivo passa pela mesma
108
+ * prova: é nela que a cópia .bak é gravada. Link físico não é reconhecido.
109
+ * @returns {((arquivo: string) => boolean)|null} null: a crew inteira é um link para fora do projeto
110
+ */
111
+ function guardaDeEscrita(raiz, crew) {
112
+ const pasta = path.resolve(raiz, crew);
113
+ if (!realDentroDe(raiz, pasta)) return null;
114
+ return (arquivo) => [arquivo, path.dirname(arquivo)].every((lugar) => realDentroDe(pasta, lugar));
115
+ }
116
+
74
117
  /**
75
118
  * Troca, nos arquivos da crew, cada caminho com sugestão única — só a citação que a coleta leu,
76
- * nunca um pedaço de outro texto. @returns quantos caminhos
119
+ * nunca um pedaço de outro texto. O que a guarda de escrita barra não muda: `avisar` recebe uma
120
+ * linha por arquivo pulado, ou uma só quando a crew inteira é um link para fora do projeto.
121
+ * @returns quantos caminhos foram gravados, em ao menos um arquivo
77
122
  */
78
- export async function corrigir({ resultado }) {
123
+ export async function corrigir({ resultado, avisar = () => {} }) {
124
+ const { raiz, crew } = resultado;
79
125
  const comSugestao = resultado.refs.filter((i) => i.sugestao && i.estado !== 'ok');
126
+ const podeGravar = comSugestao.length ? guardaDeEscrita(raiz, crew) : null;
127
+ if (!podeGravar) {
128
+ if (comSugestao.length) avisar(MSG.crewLigadaParaFora(crew));
129
+ return 0;
130
+ }
80
131
  const tocados = new Set();
132
+ const pulados = new Set();
133
+ let gravados = 0;
81
134
  for (const item of comSugestao) {
82
- for (const arquivo of item.citadoEm) {
83
- const texto = await readFile(arquivo, 'utf8');
84
- if (!tocados.has(arquivo)) {
85
- await copiaDeSeguranca(arquivo);
86
- tocados.add(arquivo);
87
- }
88
- await writeFile(arquivo, trocarCitacao(texto, item, path.basename(arquivo) === 'crew.yaml'));
89
- }
135
+ const dentro = item.citadoEm.filter((arquivo) => podeGravar(arquivo));
136
+ for (const arquivo of item.citadoEm) if (!dentro.includes(arquivo)) pulados.add(arquivo);
137
+ for (const arquivo of dentro) await regravar(arquivo, item, tocados);
138
+ if (dentro.length) gravados += 1;
90
139
  }
91
- return comSugestao.length;
140
+ for (const arquivo of pulados) avisar(MSG.linkParaFora(relativoAoProjeto(raiz, arquivo)));
141
+ return gravados;
92
142
  }
93
143
 
94
- /** --corrigir: troca o que tem sugestão única e diz quantas pendências ficam sem correção. */
144
+ /** --corrigir: troca o que tem sugestão única, diz o que pulou e quantas pendências ficam sem correção. */
95
145
  async function corrigirEAvisar(r, escrever) {
96
- const n = await corrigir({ resultado: r });
146
+ const pulados = [];
147
+ const n = await corrigir({ resultado: r, avisar: (linha) => pulados.push(linha) });
97
148
  let atual = r;
98
149
  if (n) {
99
150
  escrever(MSG.corrigidos(n));
100
151
  atual = await conferir({ raiz: r.raiz, crew: r.crew });
101
152
  escrever(formatar(atual));
102
153
  }
154
+ for (const linha of pulados) escrever(linha);
103
155
  const semSugestao = atual.refs.filter((i) => i.estado === 'faltando' && !i.sugestao).length;
104
156
  if (semSugestao) escrever(MSG.semCorrecaoAutomatica(semSugestao));
105
- else if (!n) escrever(MSG.nadaACorrigir);
157
+ else if (!n && !pulados.length) escrever(MSG.nadaACorrigir);
106
158
  return atual;
107
159
  }
108
160
 
@@ -114,7 +166,7 @@ function lerCrew(argv) {
114
166
  return valor && !valor.startsWith('--') ? valor : null;
115
167
  }
116
168
 
117
- /** @returns {Promise<number>} 0 = conferiu (OK ou PENDENTE) · 1 = erro de uso */
169
+ /** @returns {Promise<number>} 0 = conferiu (OK ou PENDENTE) · 1 = erro de uso, ou arquivo da crew que não dá para ler */
118
170
  export async function main(argv, { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = {}) {
119
171
  const crew = lerCrew(argv);
120
172
  const erro = erroDeUso({ raiz: cwd, faltando: crew ? [] : ['--crew'], crew });
@@ -123,11 +175,17 @@ export async function main(argv, { cwd = process.cwd(), escrever = (s) => proces
123
175
  if (!crew) escrever(USO);
124
176
  return 1;
125
177
  }
126
- let r = await conferir({ raiz: cwd, crew });
127
- escrever(formatar(r));
128
- if (argv.includes('--corrigir')) r = await corrigirEAvisar(r, escrever);
129
- escrever(`FONTES:${r.status}`);
130
- return 0;
178
+ try {
179
+ let r = await conferir({ raiz: cwd, crew });
180
+ escrever(formatar(r));
181
+ if (argv.includes('--corrigir')) r = await corrigirEAvisar(r, escrever);
182
+ escrever(`FONTES:${r.status}`);
183
+ return 0;
184
+ } catch (erroDeLeitura) {
185
+ // Link quebrado, pasta com nome de arquivo, arquivo sem permissão: uma linha, sem linha FONTES:.
186
+ escrever(MSG.naoConferi(erroDeLeitura.message));
187
+ return 1;
188
+ }
131
189
  }
132
190
 
133
191
  if (ehPrincipal(import.meta.url)) {
@@ -0,0 +1,31 @@
1
+ // O que o `/estado` do escritório devolve: o estado de cada crew, lido do disco a cada pedido.
2
+ // Só lê; quem grava `crews/<crew>/state.json` é o `estado.mjs`.
3
+ import { promises as fs } from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ const MARCA_DE_ORDEM = 0xfeff; // alguns editores e terminais gravam essa marca no início do arquivo
7
+
8
+ /** O estado de uma crew, ou null: arquivo ausente, pela metade, inválido ou sem `agents` em lista. */
9
+ async function lerCrew(raiz, crew) {
10
+ try {
11
+ const texto = await fs.readFile(path.join(raiz, 'crews', crew, 'state.json'), 'utf8');
12
+ const estado = JSON.parse(texto.charCodeAt(0) === MARCA_DE_ORDEM ? texto.slice(1) : texto);
13
+ return Array.isArray(estado?.agents) ? { crew, estado } : null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ /** `updatedAt` em milissegundos; ausente ou ilegível vale 0 (a crew vai para o fim). */
20
+ const atualizadaEm = ({ estado }) => Date.parse(estado.updatedAt) || 0;
21
+
22
+ /**
23
+ * As crews de `crews/` que têm estado legível, a de `updatedAt` mais recente primeiro. Os nomes
24
+ * vêm da pasta, nunca do pedido. Sem a pasta `crews/`, ou sem nenhum estado: lista vazia.
25
+ * @returns {Promise<Array<{ crew: string, estado: object }>>}
26
+ */
27
+ export async function lerCrews(raiz) {
28
+ const nomes = await fs.readdir(path.join(raiz, 'crews')).catch(() => []);
29
+ const lidas = await Promise.all(nomes.sort().map((nome) => lerCrew(raiz, nome)));
30
+ return lidas.filter(Boolean).sort((a, b) => atualizadaEm(b) - atualizadaEm(a));
31
+ }
@@ -0,0 +1,98 @@
1
+ // A porta do escritório: abrir, perguntar quem está numa porta ocupada e procurar a que serve.
2
+ // Spec: fase-e1-escritorio-ao-vivo.md, regra 15 (repositório do OpenCrew).
3
+ import http from 'node:http';
4
+ import net from 'node:net';
5
+
6
+ const LOCAL = '127.0.0.1';
7
+ const ULTIMA_PORTA = 65535;
8
+ // Quem responde na porta pode ser qualquer serviço: a resposta não é guardada sem limite.
9
+ const TETO_DA_SONDA = 1_000_000;
10
+
11
+ /**
12
+ * Alguém aceita conexão em 127.0.0.1:porta? No Windows, o `listen` em 127.0.0.1 abre por cima de
13
+ * quem escuta a mesma porta em 0.0.0.0 ou `::` e toma o tráfego local dele: só a conexão mostra
14
+ * que a porta tem dono. Porta livre recusa na hora; sem resposta dentro da espera, conta como livre.
15
+ */
16
+ function alguemEscuta(porta, esperaMs = 1000) {
17
+ return new Promise((resolve) => {
18
+ const conexao = net.connect({ host: LOCAL, port: porta });
19
+ const fim = (escuta) => { conexao.destroy(); resolve(escuta); };
20
+ conexao.setTimeout(esperaMs, () => fim(false));
21
+ conexao.once('connect', () => fim(true)).once('error', () => fim(false));
22
+ });
23
+ }
24
+
25
+ /**
26
+ * Abre o servidor na porta, preso em 127.0.0.1.
27
+ * @returns {Promise<boolean>} true se subiu; false se a porta não pôde ser aberta (ocupada, por
28
+ * quem quer que seja e em qualquer endereço, ou reservada pelo sistema). O mesmo servidor pode
29
+ * tentar outra porta em seguida.
30
+ */
31
+ export async function abrir(servidor, porta) {
32
+ if (porta !== 0 && await alguemEscuta(porta)) return false;
33
+ return new Promise((resolve) => {
34
+ const subiu = () => { servidor.off('error', falhou); resolve(true); };
35
+ const falhou = () => { servidor.off('listening', subiu); resolve(false); };
36
+ servidor.once('listening', subiu).once('error', falhou).listen(porta, LOCAL);
37
+ });
38
+ }
39
+
40
+ /** O `projeto` de uma resposta do `/estado`, ou null quando a resposta não é a de um escritório. */
41
+ function projetoDaResposta(corpo) {
42
+ try {
43
+ const { projeto } = JSON.parse(corpo) ?? {};
44
+ return typeof projeto === 'string' ? projeto : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Junta o corpo da resposta e o entrega inteiro; cortada no meio ou acima do teto, entrega null. */
51
+ function juntar(res, entregar) {
52
+ let corpo = '';
53
+ res.setEncoding('utf8');
54
+ res.on('data', (parte) => {
55
+ corpo += parte;
56
+ if (corpo.length > TETO_DA_SONDA) entregar(null);
57
+ });
58
+ res.on('end', () => entregar(corpo));
59
+ res.on('error', () => entregar(null));
60
+ }
61
+
62
+ /**
63
+ * Pergunta quem está na porta: `GET /estado`, com espera de até 1 s.
64
+ * @returns {Promise<string|null>} o `projeto` do escritório que respondeu; null para qualquer
65
+ * outra resposta (outro serviço, erro, resposta grande demais) ou nenhuma dentro da espera
66
+ */
67
+ export function sondar(porta, esperaMs = 1000) {
68
+ return new Promise((resolve) => {
69
+ const pedido = http.get({ host: LOCAL, port: porta, path: '/estado', agent: false }, (res) => juntar(res, fim));
70
+ const relogio = setTimeout(() => fim(null), esperaMs);
71
+ pedido.on('error', () => fim(null));
72
+ function fim(corpo) {
73
+ clearTimeout(relogio);
74
+ pedido.destroy();
75
+ resolve(corpo === null ? null : projetoDaResposta(corpo));
76
+ }
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Procura a porta do escritório, de `inicial` em diante, em até `tentativas` portas. Porta que
82
+ * não abre é sondada: se quem responde é o escritório deste projeto, não sobe outro.
83
+ * @param {object} o
84
+ * @param {number} o.inicial primeira porta
85
+ * @param {string} o.projeto id deste projeto
86
+ * @param {(porta: number) => Promise<boolean>} o.abrir tenta abrir o servidor na porta
87
+ * @param {(porta: number) => Promise<string|null>} o.sondar o `projeto` de quem está na porta
88
+ * @returns {Promise<{ tipo: 'aberto'|'ja-aberto'|'sem-porta', porta?: number, ultima: number }>}
89
+ * `ultima` é a última porta do intervalo (a que entra na mensagem "sem porta")
90
+ */
91
+ export async function procurar({ inicial, projeto, abrir: tentar, sondar: perguntar, tentativas = 10 }) {
92
+ const ultima = Math.min(inicial + tentativas - 1, ULTIMA_PORTA);
93
+ for (let porta = inicial; porta <= ultima; porta++) {
94
+ if (await tentar(porta)) return { tipo: 'aberto', porta, ultima };
95
+ if ((await perguntar(porta)) === projeto) return { tipo: 'ja-aberto', porta, ultima };
96
+ }
97
+ return { tipo: 'sem-porta', ultima };
98
+ }
@@ -0,0 +1,29 @@
1
+ // Identidade do projeto para o escritório: diz se o servidor que responde numa porta é o deste
2
+ // projeto, sem expor o caminho da pasta.
3
+ import { createHash } from 'node:crypto';
4
+ import { realpathSync } from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ /**
8
+ * 12 caracteres hexadecimais do SHA-256 do caminho real da raiz. No Windows o caminho vai para
9
+ * minúsculas antes: lá `D:\Projeto` e `d:\projeto` são a mesma pasta. Função pura.
10
+ */
11
+ export function idDoProjeto(caminhoReal, plataforma = process.platform) {
12
+ const texto = plataforma === 'win32' ? caminhoReal.toLowerCase() : caminhoReal;
13
+ return createHash('sha256').update(texto).digest('hex').slice(0, 12);
14
+ }
15
+
16
+ /**
17
+ * Caminho real: o mesmo projeto aberto por um link de pasta dá o mesmo resultado. Onde o sistema
18
+ * não resolve (alguns discos virtuais), vale o caminho absoluto.
19
+ */
20
+ function caminhoReal(raiz) {
21
+ try {
22
+ return realpathSync.native(raiz);
23
+ } catch {
24
+ return path.resolve(raiz);
25
+ }
26
+ }
27
+
28
+ /** O id do projeto que mora em `raiz`. */
29
+ export const projetoDe = (raiz) => idDoProjeto(caminhoReal(raiz));
@@ -0,0 +1,78 @@
1
+ // Servidor do escritório: só leitura, só neste computador, com rotas fechadas.
2
+ // Spec: fase-e1-escritorio-ao-vivo.md, regras 13 a 15 (repositório do OpenCrew).
3
+ import http from 'node:http';
4
+ import { promises as fs } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { lerCrews } from './leitura.mjs';
7
+ import { projetoDe } from './projeto.mjs';
8
+
9
+ // Vão em TODA resposta. Nenhuma resposta leva `Access-Control-*`: outra origem não lê o estado.
10
+ const CABECALHOS = {
11
+ 'Cache-Control': 'no-store',
12
+ 'X-Content-Type-Options': 'nosniff',
13
+ 'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'",
14
+ };
15
+ const TEXTO = 'text/plain; charset=utf-8';
16
+ const JSON_UTF8 = 'application/json; charset=utf-8';
17
+ const TIPOS = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
18
+ const SEM_TIPO = 'application/octet-stream';
19
+
20
+ const MSG_HOST = (porta) => `Abra por http://127.0.0.1:${porta}`;
21
+
22
+ function responder(res, status, { corpo, tipo = TEXTO, extras = {} }) {
23
+ res.writeHead(status, { ...CABECALHOS, 'Content-Type': tipo, 'Content-Length': Buffer.byteLength(corpo), ...extras });
24
+ res.end(corpo);
25
+ }
26
+
27
+ /**
28
+ * O pedido veio para este endereço? Uma página de fora que aponte um nome dela para 127.0.0.1
29
+ * chega com o nome dela no `Host`; só valem `127.0.0.1` e `localhost`, com a porta real.
30
+ */
31
+ function hostAceito(req) {
32
+ const porta = req.socket.localPort;
33
+ const host = String(req.headers.host ?? '').toLowerCase();
34
+ return host === `127.0.0.1:${porta}` || host === `localhost:${porta}`;
35
+ }
36
+
37
+ /** Arquivo da página pelo nome da lista; null quando o caminho não é rota ou o arquivo falta. */
38
+ async function lerArquivo(ctx, caminho) {
39
+ const nome = ctx.rotas.get(caminho);
40
+ if (!nome) return null;
41
+ const corpo = await fs.readFile(path.join(ctx.pasta, nome)).catch(() => null);
42
+ return corpo && { corpo, tipo: TIPOS[path.extname(nome)] ?? SEM_TIPO };
43
+ }
44
+
45
+ async function atender(req, res, ctx) {
46
+ if (!hostAceito(req)) return responder(res, 421, { corpo: MSG_HOST(req.socket.localPort) });
47
+ if (req.method !== 'GET') return responder(res, 405, { corpo: 'Método não permitido', extras: { Allow: 'GET' } });
48
+ const caminho = req.url.split('?')[0];
49
+ if (caminho === '/estado') {
50
+ const corpo = JSON.stringify({ projeto: ctx.projeto, crews: await lerCrews(ctx.raiz) });
51
+ return responder(res, 200, { corpo, tipo: JSON_UTF8 });
52
+ }
53
+ const arquivo = await lerArquivo(ctx, caminho);
54
+ return arquivo ? responder(res, 200, arquivo) : responder(res, 404, { corpo: 'Não encontrado' });
55
+ }
56
+
57
+ function falhar(res) {
58
+ if (res.headersSent) res.destroy();
59
+ else responder(res, 500, { corpo: 'Erro interno' });
60
+ }
61
+
62
+ /**
63
+ * Cria o servidor (sem abrir a porta: quem abre é `abrir`, em `porta.mjs`).
64
+ * @param {object} o
65
+ * @param {string} o.raiz pasta do projeto (a que tem `_opencrew/` e `crews/`)
66
+ * @param {string} o.pasta pasta da página (`_opencrew/core/escritorio/`)
67
+ * @param {string[]} o.arquivos nomes servidos dessa pasta. É uma lista fixa: o caminho do pedido
68
+ * é comparado com ela e nunca é juntado a uma pasta, e a pasta nunca é lida para montar rota
69
+ * @returns {import('node:http').Server}
70
+ */
71
+ export function criarServidor({ raiz, pasta, arquivos }) {
72
+ const rotas = new Map([['/', 'index.html'], ...arquivos.map((nome) => [`/${nome}`, nome])]);
73
+ const ctx = { raiz, pasta, rotas, projeto: projetoDe(raiz) };
74
+ // Sem `Host` o pedido também chega aqui e leva o 421 com os cabeçalhos, e não o 400 do Node.
75
+ return http.createServer({ requireHostHeader: false }, (req, res) => {
76
+ atender(req, res, ctx).catch(() => falhar(res));
77
+ });
78
+ }
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // Escritório do OpenCrew — servidor local da página que mostra a equipe trabalhando.
3
+ // Uso: node _opencrew/core/scripts/escritorio.mjs [--porta <1024 a 65535>]
4
+ // Rode na pasta do projeto (a que contém `_opencrew/`). Sem --porta, começa na 4747.
5
+ // Só leitura e só neste computador: escuta em 127.0.0.1, aceita só GET e não escreve em disco.
6
+ // Serve a página (`_opencrew/core/escritorio/`) e `GET /estado`, que lê `crews/*/state.json` a
7
+ // cada pedido.
8
+ // Porta ocupada: se quem responde nela é o escritório deste mesmo projeto, avisa e sai com 0, sem
9
+ // subir outro; senão tenta a seguinte, até 10 portas ao todo.
10
+ // Código de saída: 0 = escritório aberto (o processo segue rodando até Ctrl+C) ou já aberto ·
11
+ // 1 = erro de uso (porta inválida, pasta sem `_opencrew/`) ou nenhuma das 10 portas livre.
12
+ // Spec: fase-e1-escritorio-ao-vivo.md, regras 13 a 15 (repositório do OpenCrew).
13
+ import { existsSync, statSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { ehPrincipal } from './comum.mjs';
17
+ import { criarServidor } from './escritorio/servidor.mjs';
18
+ import { abrir, sondar, procurar } from './escritorio/porta.mjs';
19
+ import { projetoDe } from './escritorio/projeto.mjs';
20
+
21
+ /**
22
+ * Os arquivos da página, um a um: é a lista inteira do que o servidor entrega além de `/estado`.
23
+ * Arquivo novo em `_opencrew/core/escritorio/` só é servido depois de entrar aqui.
24
+ */
25
+ export const ARQUIVOS = [
26
+ 'index.html', 'app.js', 'painel.js', 'rotulos.js', 'cena.js',
27
+ 'sprites.js', 'sprites-sala.js', 'sprites-mesa.js',
28
+ 'modelo.js', 'modelo-mesas.js', 'modelo-textos.js', 'modelo-estado.js', 'modelo-agentes.js',
29
+ 'modelo-visao.js', 'modelo-pagina.js', 'demo.js',
30
+ 'escala.js', 'rota.js', 'animacao.js', 'quadro.js',
31
+ ];
32
+
33
+ const PORTA_PADRAO = 4747;
34
+ const PORTA_MINIMA = 1024;
35
+ const PORTA_MAXIMA = 65535;
36
+
37
+ // Instalado, este script fica em `_opencrew/core/scripts/` e a página em `_opencrew/core/escritorio/`.
38
+ const PASTA_DA_PAGINA = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'escritorio');
39
+
40
+ const endereco = (porta) => `http://127.0.0.1:${porta}`;
41
+
42
+ const USO = `Uso: node _opencrew/core/scripts/escritorio.mjs [--porta <${PORTA_MINIMA} a ${PORTA_MAXIMA}>]`;
43
+ const MSG = {
44
+ aberto: (porta) => `Escritório aberto em ${endereco(porta)} — Ctrl+C para fechar`,
45
+ jaAberto: (porta) => `O escritório já está aberto em ${endereco(porta)}`,
46
+ semPorta: (a, b) => `Não foi possível abrir o escritório: as portas ${a} a ${b} estão ocupadas. Use --porta.`,
47
+ foraDaRaiz: 'Rode este comando na pasta do projeto (a que contém _opencrew/).',
48
+ portaInvalida: `Porta inválida: use --porta com um número de ${PORTA_MINIMA} a ${PORTA_MAXIMA}.`,
49
+ };
50
+
51
+ const ehPasta = (p) => existsSync(p) && statSync(p).isDirectory();
52
+
53
+ /**
54
+ * A porta pedida em `--porta N` ou `--porta=N` (a última vale); sem a opção, a padrão.
55
+ * @returns {number|null} null quando o valor não é um inteiro de 1024 a 65535
56
+ */
57
+ function lerPorta(argv) {
58
+ let porta = PORTA_PADRAO;
59
+ for (let i = 0; i < argv.length; i++) {
60
+ const opcao = argv[i].match(/^--porta(?:=(.*))?$/s);
61
+ if (!opcao) continue;
62
+ const valor = opcao[1] ?? argv[++i] ?? '';
63
+ const numero = /^\d{1,5}$/.test(valor) ? Number(valor) : 0;
64
+ if (numero < PORTA_MINIMA || numero > PORTA_MAXIMA) return null;
65
+ porta = numero;
66
+ }
67
+ return porta;
68
+ }
69
+
70
+ /**
71
+ * Erro de uso, na ordem: porta inválida → pasta atual sem `_opencrew/`.
72
+ * @returns {string[]|null} as linhas em PT-BR, ou null quando está tudo certo
73
+ */
74
+ function erroDeUso(porta, cwd) {
75
+ if (porta === null) return [MSG.portaInvalida, USO];
76
+ if (!ehPasta(path.join(cwd, '_opencrew'))) return [MSG.foraDaRaiz];
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Sobe o escritório do projeto que mora em `cwd`, ou reconhece o que já está aberto.
82
+ * @param {string[]} argv argumentos da linha de comando
83
+ * @param {object} [o]
84
+ * @param {string} [o.cwd] pasta do projeto
85
+ * @param {(linha: string) => void} [o.escrever] recebe cada linha da saída
86
+ * @param {string} [o.pasta] pasta da página
87
+ * @param {string[]} [o.arquivos] nomes servidos da pasta da página
88
+ * @param {(servidor: object, porta: number) => Promise<boolean>} [o.abrir] SÓ PARA TESTE: tenta
89
+ * abrir a porta (serve para simular portas ocupadas sem ocupá-las)
90
+ * @param {(porta: number) => Promise<string|null>} [o.sondar] SÓ PARA TESTE: o `projeto` de quem
91
+ * responde na porta
92
+ * @returns {Promise<{ code: number, porta?: number, servidor?: import('node:http').Server }>}
93
+ * `code` 0 com `porta` (escritório aberto ou já aberto) ou 1; `servidor` só quando este
94
+ * comando abriu um — é ele que mantém o processo vivo
95
+ */
96
+ export async function main(argv, o = {}) {
97
+ const { cwd = process.cwd(), escrever = (linha) => process.stdout.write(`${linha}\n`), pasta = PASTA_DA_PAGINA, arquivos = ARQUIVOS } = o;
98
+ const inicial = lerPorta(argv);
99
+ const erro = erroDeUso(inicial, cwd);
100
+ if (erro) {
101
+ erro.forEach((linha) => escrever(linha));
102
+ return { code: 1 };
103
+ }
104
+ const servidor = criarServidor({ raiz: cwd, pasta, arquivos });
105
+ const tentar = (porta) => (o.abrir ?? abrir)(servidor, porta);
106
+ const r = await procurar({ inicial, projeto: projetoDe(cwd), abrir: tentar, sondar: o.sondar ?? sondar });
107
+ if (r.tipo === 'sem-porta') {
108
+ escrever(MSG.semPorta(inicial, r.ultima));
109
+ return { code: 1 };
110
+ }
111
+ escrever(r.tipo === 'aberto' ? MSG.aberto(r.porta) : MSG.jaAberto(r.porta));
112
+ return r.tipo === 'aberto' ? { code: 0, porta: r.porta, servidor } : { code: 0, porta: r.porta };
113
+ }
114
+
115
+ if (ehPrincipal(import.meta.url)) {
116
+ main(process.argv.slice(2)).then(({ code }) => { process.exitCode = code; });
117
+ }
@@ -0,0 +1,61 @@
1
+ // Linha de comando do `estado.mjs`: a crew, o evento, as opções e a linha de uso.
2
+ // Spec: fase-e1-escritorio-ao-vivo.md, §3 e regra 7 (repositório do OpenCrew).
3
+
4
+ export const USO = 'Uso: node _opencrew/core/scripts/estado.mjs <crew> <evento> [opções]';
5
+
6
+ /** Teto, em caracteres visíveis, de `--rotulo`, `--mensagem` e `--motivo`. */
7
+ export const TETO = 120;
8
+
9
+ const OPCAO = /^--(passos|n|agente|rotulo|mensagem|motivo)(?:=(.*))?$/s;
10
+ const QUEBRA = /[\r\n\v\f\u0085\u2028\u2029]+/g;
11
+ const segmentador = new Intl.Segmenter('pt', { granularity: 'grapheme' });
12
+
13
+ /**
14
+ * Texto recebido é dado: fica numa linha (quebra de linha vira espaço) e com até 120 caracteres.
15
+ * O corte conta caracteres visíveis, por isso nunca parte um emoji ao meio. O laço para no 121º
16
+ * segmento: no Node 20 cada segmento guarda uma cópia da entrada, e guardar todos custaria caro.
17
+ */
18
+ export function limparTexto(valor) {
19
+ const linha = String(valor).replace(QUEBRA, ' ').trim();
20
+ let fim = 0;
21
+ let visiveis = 0;
22
+ for (const { index, segment } of segmentador.segment(linha)) {
23
+ if (visiveis++ === TETO) break;
24
+ fim = index + segment.length;
25
+ }
26
+ return linha.slice(0, fim);
27
+ }
28
+
29
+ /** Inteiro a partir de 1; qualquer outra coisa é `null` (a opção não vale). */
30
+ const inteiro = (valor) => (/^\d{1,9}$/.test(valor ?? '') && Number(valor) >= 1 ? Number(valor) : null);
31
+
32
+ /** As opções como o núcleo as recebe: ausente ou inválida é `null` (número, agente) ou vazio (texto). */
33
+ function converter(cru) {
34
+ return {
35
+ passos: inteiro(cru.passos),
36
+ n: inteiro(cru.n),
37
+ agente: limparTexto(cru.agente ?? '') || null,
38
+ rotulo: limparTexto(cru.rotulo ?? ''),
39
+ mensagem: limparTexto(cru.mensagem ?? ''),
40
+ motivo: limparTexto(cru.motivo ?? ''),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * `argv` → `{ crew, evento, opcoes: { passos, n, agente, rotulo, mensagem, motivo } }`. Os dois
46
+ * primeiros argumentos soltos são a crew e o evento (indefinidos, se faltam). Opção vale como
47
+ * `--nome valor` e `--nome=valor`; opção sem valor (a última, ou seguida de outra opção) não
48
+ * consome o argumento seguinte.
49
+ */
50
+ export function lerArgs(argv) {
51
+ const soltos = [];
52
+ const cru = {};
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const [, nome, colado] = argv[i].match(OPCAO) ?? [];
55
+ if (!nome) soltos.push(argv[i]);
56
+ else if (colado !== undefined) cru[nome] = colado;
57
+ else if (i + 1 < argv.length && !OPCAO.test(argv[i + 1])) cru[nome] = argv[++i];
58
+ }
59
+ const [crew, evento] = soltos.filter((s) => !s.startsWith('--'));
60
+ return { crew, evento, opcoes: converter(cru) };
61
+ }