@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.
- package/CHANGELOG.md +102 -0
- package/README.md +70 -13
- package/package.json +2 -2
- package/src/cli.js +15 -38
- package/src/commands/init.js +41 -44
- package/src/commands/update.js +78 -75
- package/src/lib/blocos.js +148 -0
- package/src/lib/deteccao.js +69 -0
- package/src/lib/fsx.js +1 -55
- package/src/lib/ides.js +4 -0
- package/src/lib/legado.js +142 -0
- package/src/lib/manifest.js +67 -26
- package/src/lib/mcp.js +131 -0
- package/src/lib/migrations.js +77 -74
- package/src/lib/node-version.js +43 -0
- package/src/lib/prompts.js +25 -2
- package/src/lib/resumo.js +125 -0
- package/templates/.mcp.json +1 -1
- package/templates/AGENTS.md +20 -6
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/best-practices/social-networks-publishing.md +14 -14
- package/templates/_opencrew/core/escritorio/animacao.js +64 -0
- package/templates/_opencrew/core/escritorio/app.js +137 -0
- package/templates/_opencrew/core/escritorio/cena.js +132 -0
- package/templates/_opencrew/core/escritorio/demo.js +79 -0
- package/templates/_opencrew/core/escritorio/escala.js +27 -0
- package/templates/_opencrew/core/escritorio/index.html +166 -0
- package/templates/_opencrew/core/escritorio/modelo-agentes.js +93 -0
- package/templates/_opencrew/core/escritorio/modelo-estado.js +71 -0
- package/templates/_opencrew/core/escritorio/modelo-mesas.js +81 -0
- package/templates/_opencrew/core/escritorio/modelo-pagina.js +95 -0
- package/templates/_opencrew/core/escritorio/modelo-textos.js +65 -0
- package/templates/_opencrew/core/escritorio/modelo-visao.js +91 -0
- package/templates/_opencrew/core/escritorio/modelo.js +29 -0
- package/templates/_opencrew/core/escritorio/painel.js +120 -0
- package/templates/_opencrew/core/escritorio/quadro.js +106 -0
- package/templates/_opencrew/core/escritorio/rota.js +62 -0
- package/templates/_opencrew/core/escritorio/rotulos.js +78 -0
- package/templates/_opencrew/core/escritorio/sprites-mesa.js +122 -0
- package/templates/_opencrew/core/escritorio/sprites-sala.js +92 -0
- package/templates/_opencrew/core/escritorio/sprites.js +187 -0
- package/templates/_opencrew/core/prompts/build.prompt.md +3 -3
- package/templates/_opencrew/core/prompts/export.prompt.md +1 -1
- package/templates/_opencrew/core/prompts/repair.prompt.md +7 -12
- package/templates/_opencrew/core/prompts/sherlock-shared.md +5 -5
- package/templates/_opencrew/core/runner.pipeline.md +76 -139
- package/templates/_opencrew/core/scripts/comum.mjs +49 -4
- package/templates/_opencrew/core/scripts/conferir-fontes/busca.mjs +42 -3
- package/templates/_opencrew/core/scripts/conferir-fontes/relatorio.mjs +18 -6
- package/templates/_opencrew/core/scripts/conferir-fontes.mjs +97 -39
- package/templates/_opencrew/core/scripts/escritorio/leitura.mjs +31 -0
- package/templates/_opencrew/core/scripts/escritorio/porta.mjs +98 -0
- package/templates/_opencrew/core/scripts/escritorio/projeto.mjs +29 -0
- package/templates/_opencrew/core/scripts/escritorio/servidor.mjs +78 -0
- package/templates/_opencrew/core/scripts/escritorio.mjs +117 -0
- package/templates/_opencrew/core/scripts/estado/argumentos.mjs +61 -0
- package/templates/_opencrew/core/scripts/estado/arquivo.mjs +53 -0
- package/templates/_opencrew/core/scripts/estado/decisao.mjs +56 -0
- package/templates/_opencrew/core/scripts/estado/elenco.mjs +58 -0
- package/templates/_opencrew/core/scripts/estado/nucleo.mjs +113 -0
- package/templates/_opencrew/core/scripts/estado/preferencia.mjs +24 -0
- package/templates/_opencrew/core/scripts/estado.mjs +96 -0
- package/templates/_opencrew/core/scripts/verificar.mjs +7 -4
- package/templates/_opencrew/core/skills.engine.md +7 -3
- package/templates/gitignore +1 -0
- package/templates/skills/blotato/SKILL.md +39 -10
- package/templates/skills/image-ai-generator/SKILL.md +18 -5
- package/templates/skills/image-ai-generator/scripts/generate.py +52 -10
- package/templates/skills/instagram-publisher/SKILL.md +4 -0
- package/templates/skills/opencrew-skill-creator/references/skill-format.md +1 -0
- 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)
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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,
|
|
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
|
|
26
|
-
*
|
|
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) ?
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}
|