@aksp/opencrew 1.6.3 → 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 +51 -0
- package/README.md +50 -5
- package/package.json +1 -1
- package/templates/AGENTS.md +20 -6
- package/templates/_opencrew/.opencrew-version +1 -1
- 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/repair.prompt.md +7 -12
- package/templates/_opencrew/core/runner.pipeline.md +45 -128
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Leitura e gravação de `crews/<crew>/state.json`: inteira ou nenhuma.
|
|
2
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regras 3 e 6 (repositório do OpenCrew).
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/** O Windows recusa a troca de nome com um destes códigos enquanto outro processo lê o arquivo. */
|
|
6
|
+
const RECUSAS = ['EPERM', 'EBUSY', 'EACCES'];
|
|
7
|
+
/** Depois da primeira tentativa: até 3 repetições, uma a cada 100 ms (cerca de 300 ms ao todo). */
|
|
8
|
+
const REPETICOES = 3;
|
|
9
|
+
const INTERVALO_MS = 100;
|
|
10
|
+
|
|
11
|
+
const pausa = (ms) => new Promise((seguir) => { setTimeout(seguir, ms); });
|
|
12
|
+
|
|
13
|
+
/** O estado gravado, ou `null` quando o arquivo falta ou está ilegível (pela metade, sem `agents` em lista). */
|
|
14
|
+
export async function lerEstado(arquivo) {
|
|
15
|
+
try {
|
|
16
|
+
const estado = JSON.parse((await fs.readFile(arquivo, 'utf8')).replace(/^\uFEFF/, ''));
|
|
17
|
+
return Array.isArray(estado?.agents) ? estado : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function trocarNome(de, para, { renomear = fs.rename, esperar = pausa }) {
|
|
24
|
+
for (let repeticao = 0; ; repeticao++) {
|
|
25
|
+
try {
|
|
26
|
+
return await renomear(de, para);
|
|
27
|
+
} catch (erro) {
|
|
28
|
+
if (!RECUSAS.includes(erro?.code) || repeticao === REPETICOES) throw erro;
|
|
29
|
+
await esperar(INTERVALO_MS);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Grava o estado num temporário da mesma pasta (`state.json.<pid>.tmp`) e troca o nome: quem lê
|
|
36
|
+
* vê o arquivo antigo ou o novo, nunca um pela metade.
|
|
37
|
+
* @param {string} arquivo caminho do `state.json`
|
|
38
|
+
* @param {object} estado
|
|
39
|
+
* @param {{ renomear?: Function, esperar?: Function }} [deps] SÓ PARA TESTE: a troca de nome
|
|
40
|
+
* (`(de, para) => Promise`) e a espera entre as tentativas (`(ms) => Promise`)
|
|
41
|
+
* @returns {Promise<boolean>} `false` quando desistiu: o arquivo fica como estava e o temporário é apagado
|
|
42
|
+
*/
|
|
43
|
+
export async function gravarEstado(arquivo, estado, deps = {}) {
|
|
44
|
+
const temporario = `${arquivo}.${process.pid}.tmp`;
|
|
45
|
+
try {
|
|
46
|
+
await fs.writeFile(temporario, `${JSON.stringify(estado, null, 2)}\n`);
|
|
47
|
+
await trocarNome(temporario, arquivo, deps);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
await fs.rm(temporario, { force: true }).catch(() => {});
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// O que fazer com um evento: aplicar, montar o estado de novo antes, ou ignorar. Puro, como o
|
|
2
|
+
// núcleo: recebe o estado lido (ou `null`) e o evento, não toca em disco.
|
|
3
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regra 5 e §6 (repositório do OpenCrew).
|
|
4
|
+
import { proximoEstado } from './nucleo.mjs';
|
|
5
|
+
|
|
6
|
+
/** Os motivos de `ESTADO:IGNORADO` (o texto depois do travessão). */
|
|
7
|
+
export const MOTIVO = {
|
|
8
|
+
desligado: 'escritório desligado (ligue com /opencrew dashboard)',
|
|
9
|
+
semElenco: 'crew-party.csv ausente ou sem agentes',
|
|
10
|
+
agenteDesconhecido: (id, ids) => `agente "${id}" não está no elenco da crew. Ids válidos: ${ids.join(', ')}`,
|
|
11
|
+
pularSemAgente: (ids) => `o evento pular precisa de --agente. Ids válidos: ${ids.join(', ')}`,
|
|
12
|
+
semEstado: 'sem estado desta execução',
|
|
13
|
+
naoGravou: 'não foi possível gravar o estado',
|
|
14
|
+
inesperado: (erro) => `erro inesperado: ${erro?.message ?? erro}`,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const USAM_AGENTE = ['passo', 'checkpoint', 'pular'];
|
|
18
|
+
/** Eventos que montam o estado quando ele falta; os outros são ignorados sem ele. */
|
|
19
|
+
const MONTAM = ['iniciar', 'passo', 'checkpoint'];
|
|
20
|
+
const ENCERRADA = ['completed', 'failed'];
|
|
21
|
+
|
|
22
|
+
function motivoParaIgnorar(anterior, { tipo, agente, elenco }) {
|
|
23
|
+
const ids = elenco.map((a) => a.id);
|
|
24
|
+
if (!ids.length) return MOTIVO.semElenco;
|
|
25
|
+
const pedeAgente = USAM_AGENTE.includes(tipo);
|
|
26
|
+
if (pedeAgente && agente && !ids.includes(agente)) return MOTIVO.agenteDesconhecido(agente, ids);
|
|
27
|
+
if (tipo === 'pular' && !agente) return MOTIVO.pularSemAgente(ids);
|
|
28
|
+
return anterior || MONTAM.includes(tipo) ? null : MOTIVO.semEstado;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `passo` e `checkpoint` montam o estado de novo quando ele falta ou está ilegível, quando o
|
|
33
|
+
* `passo --n 1` chega sobre uma execução encerrada, e quando o agente do evento está no elenco
|
|
34
|
+
* mas não no estado gravado (elenco editado sem um `iniciar` depois).
|
|
35
|
+
*/
|
|
36
|
+
function pedeRecriar(anterior, { tipo, n, agente }) {
|
|
37
|
+
if (tipo !== 'passo' && tipo !== 'checkpoint') return false;
|
|
38
|
+
if (!anterior) return true;
|
|
39
|
+
if (tipo === 'passo' && n === 1 && ENCERRADA.includes(anterior.status)) return true;
|
|
40
|
+
return Boolean(agente) && !anterior.agents.some((a) => a?.id === agente);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {object|null} anterior o estado lido do `state.json`; `null` se falta ou está ilegível
|
|
45
|
+
* @param {object} evento o evento do núcleo, sempre com `crew`, `elenco` e `agora`
|
|
46
|
+
* @returns {{ ignorado: string } | { estado: object, recriado: boolean }} o motivo para não
|
|
47
|
+
* mexer em nada, ou o estado a gravar (`recriado`: foi montado de novo, como no `iniciar`,
|
|
48
|
+
* herdando só o `step.total`)
|
|
49
|
+
*/
|
|
50
|
+
export function decidir(anterior, evento) {
|
|
51
|
+
const motivo = motivoParaIgnorar(anterior, evento);
|
|
52
|
+
if (motivo) return { ignorado: motivo };
|
|
53
|
+
const recriado = pedeRecriar(anterior, evento);
|
|
54
|
+
const base = recriado ? proximoEstado(null, { ...evento, tipo: 'iniciar', passos: anterior?.step?.total }) : anterior;
|
|
55
|
+
return { estado: proximoEstado(base, evento), recriado };
|
|
56
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// O elenco de uma crew, lido de `crew-party.csv` (cabeçalho id,displayName,title,icon,path,execution;
|
|
2
|
+
// crew antiga pode não ter a coluna `id`).
|
|
3
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regra 2 (repositório do OpenCrew).
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Texto CSV → linhas, cada uma com os seus campos (sem as aspas e sem espaço nas pontas). Entre
|
|
9
|
+
* aspas o campo pode ter vírgula, quebra de linha e `""` (uma aspa). Uma passada só, caractere a
|
|
10
|
+
* caractere: aspa que não fecha leva o resto do texto para o campo, nunca trava.
|
|
11
|
+
*/
|
|
12
|
+
export function lerCsv(texto) {
|
|
13
|
+
const linhas = [];
|
|
14
|
+
let linha = [];
|
|
15
|
+
let campo = '';
|
|
16
|
+
let aspas = false;
|
|
17
|
+
const fechaCampo = () => { linha.push(campo.trim()); campo = ''; };
|
|
18
|
+
const fechaLinha = () => { fechaCampo(); linhas.push(linha); linha = []; };
|
|
19
|
+
for (let i = 0; i < texto.length; i++) {
|
|
20
|
+
const c = texto[i];
|
|
21
|
+
if (c === '"' && aspas && texto[i + 1] === '"') campo += texto[i++];
|
|
22
|
+
else if (c === '"') aspas = !aspas;
|
|
23
|
+
else if (aspas || !',\r\n'.includes(c)) campo += c;
|
|
24
|
+
else if (c === ',') fechaCampo();
|
|
25
|
+
else if (c === '\n' || texto[i + 1] !== '\n') fechaLinha(); // `\r\n` fecha a linha uma vez só
|
|
26
|
+
}
|
|
27
|
+
if (campo || linha.length) fechaLinha();
|
|
28
|
+
return linhas;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** O id que o nome do arquivo do agente dá: `./agents/x.agent.md` → `x`; outro caminho, nenhum. */
|
|
32
|
+
const idDoCaminho = (caminho = '') => caminho.match(/([^/\\]+)\.agent\.md$/i)?.[1] ?? '';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* O elenco, na ordem do arquivo: `[{ id, name, icon }]`. As colunas `id`, `displayName`, `icon` e
|
|
36
|
+
* `path` são achadas pelo nome no cabeçalho. Linha sem `id` (crew antiga, de antes de o cabeçalho
|
|
37
|
+
* ser fixo) usa o nome do arquivo em `path`. Sem nenhum dos dois, ou com `id` repetido, a linha
|
|
38
|
+
* fica de fora; sem `displayName`, o nome é o `id`.
|
|
39
|
+
*/
|
|
40
|
+
export function elencoDoCsv(texto) {
|
|
41
|
+
const [cabecalho = [], ...linhas] = lerCsv(texto.replace(/^\uFEFF/, ''));
|
|
42
|
+
const colunas = ['id', 'displayname', 'icon', 'path'].map((c) => cabecalho.findIndex((h) => h.toLowerCase() === c));
|
|
43
|
+
const [id, nome, icone, caminho] = colunas;
|
|
44
|
+
const vistos = new Set();
|
|
45
|
+
return linhas
|
|
46
|
+
.map((l) => ({ l, i: l[id] || idDoCaminho(l[caminho]) }))
|
|
47
|
+
.map(({ l, i }) => ({ id: i, name: l[nome] || i, icon: l[icone] ?? '' }))
|
|
48
|
+
.filter((a) => a.id && !vistos.has(a.id) && vistos.add(a.id));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** O elenco da crew; lista vazia quando o `crew-party.csv` falta, não abre ou não tem agentes. */
|
|
52
|
+
export async function lerElenco(pastaDaCrew) {
|
|
53
|
+
try {
|
|
54
|
+
return elencoDoCsv(await fs.readFile(path.join(pastaDaCrew, 'crew-party.csv'), 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Núcleo puro do estado da execução: (estado, evento) → estado novo. Não lê relógio nem disco:
|
|
2
|
+
// a hora chega em `evento.agora` e o elenco em `evento.elenco`. O estado recebido nunca é alterado.
|
|
3
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regras 1 e 2 e §4 (repositório do OpenCrew).
|
|
4
|
+
|
|
5
|
+
/** Agente que ainda não entregou. `delivering` só existe em arquivo da 1.6.x. */
|
|
6
|
+
const ATIVOS = ['working', 'checkpoint', 'delivering'];
|
|
7
|
+
/** Quem o `falhar` derruba. */
|
|
8
|
+
const EM_CURSO = ['working', 'checkpoint'];
|
|
9
|
+
|
|
10
|
+
const texto = (v) => (typeof v === 'string' ? v : '');
|
|
11
|
+
const aPartirDe = (minimo, v) => (Number.isInteger(v) && v >= minimo ? v : null);
|
|
12
|
+
|
|
13
|
+
function agenteNormal(a) {
|
|
14
|
+
return { id: texto(a?.id), name: texto(a?.name), icon: texto(a?.icon), status: texto(a?.status) || 'idle', label: texto(a?.label) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** O estado lido, no formato de hoje: agentes sem `desk` e com `label`, `step` completo. */
|
|
18
|
+
function normalizar(e) {
|
|
19
|
+
const { step, handoff } = e;
|
|
20
|
+
return {
|
|
21
|
+
...e,
|
|
22
|
+
crew: texto(e.crew),
|
|
23
|
+
status: texto(e.status),
|
|
24
|
+
step: { current: aPartirDe(0, step?.current) ?? 0, total: aPartirDe(1, step?.total), label: texto(step?.label) },
|
|
25
|
+
agents: (Array.isArray(e.agents) ? e.agents : []).map(agenteNormal),
|
|
26
|
+
handoff: handoff && typeof handoff === 'object' ? handoff : null,
|
|
27
|
+
startedAt: e.startedAt ?? null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Só os campos da §4, na ordem dela; o que é de um status só não sobrevive a ele. */
|
|
32
|
+
function fechar(e, agora) {
|
|
33
|
+
return {
|
|
34
|
+
crew: e.crew,
|
|
35
|
+
status: e.status,
|
|
36
|
+
step: e.step,
|
|
37
|
+
agents: e.agents,
|
|
38
|
+
handoff: e.handoff,
|
|
39
|
+
...(e.status === 'failed' ? { motivo: texto(e.motivo) } : {}),
|
|
40
|
+
startedAt: e.startedAt,
|
|
41
|
+
updatedAt: agora,
|
|
42
|
+
...(e.status === 'completed' ? { completedAt: e.completedAt } : {}),
|
|
43
|
+
...(e.status === 'failed' ? { failedAt: e.failedAt } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const trocar = (agents, quem, status) => agents.map((a) => (quem(a) ? { ...a, status } : a));
|
|
48
|
+
|
|
49
|
+
/** `--n` grava `step.current`; `--rotulo` grava `step.label` (sem ele: vazio). */
|
|
50
|
+
const comPasso = (step, { n, rotulo }) => ({ ...step, current: aPartirDe(1, n) ?? step.current, label: texto(rotulo) });
|
|
51
|
+
|
|
52
|
+
function iniciar(_anterior, { crew, elenco, passos, agora }) {
|
|
53
|
+
return {
|
|
54
|
+
crew: texto(crew),
|
|
55
|
+
status: 'running',
|
|
56
|
+
step: { current: 0, total: aPartirDe(1, passos), label: '' },
|
|
57
|
+
agents: elenco.map((a) => agenteNormal({ ...a, status: 'idle', label: '' })),
|
|
58
|
+
handoff: null,
|
|
59
|
+
startedAt: agora,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function passo(estado, evento) {
|
|
64
|
+
const base = { ...estado, status: 'running', step: comPasso(estado.step, evento) };
|
|
65
|
+
const alvo = evento.agente ? estado.agents.find((a) => a.id === evento.agente) : null;
|
|
66
|
+
if (!alvo) return base;
|
|
67
|
+
const outros = estado.agents.filter((a) => a !== alvo && ATIVOS.includes(a.status));
|
|
68
|
+
const agents = estado.agents.map((a) => {
|
|
69
|
+
if (a === alvo) return { ...a, status: 'working', label: texto(evento.rotulo) };
|
|
70
|
+
return outros.includes(a) ? { ...a, status: 'done' } : a;
|
|
71
|
+
});
|
|
72
|
+
const bastao = { from: outros[0]?.id, to: alvo.id, message: texto(evento.mensagem), completedAt: evento.agora };
|
|
73
|
+
return { ...base, agents, handoff: outros.length ? bastao : estado.handoff };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function checkpoint(estado, evento) {
|
|
77
|
+
const agents = evento.agente ? trocar(estado.agents, (a) => a.id === evento.agente, 'checkpoint') : estado.agents;
|
|
78
|
+
return { ...estado, status: 'checkpoint', step: comPasso(estado.step, evento), agents };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pular(estado, { agente }) {
|
|
82
|
+
return { ...estado, agents: agente ? trocar(estado.agents, (a) => a.id === agente, 'skipped') : estado.agents };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function concluir(estado, { agora }) {
|
|
86
|
+
return { ...estado, status: 'completed', completedAt: agora, agents: trocar(estado.agents, (a) => a.status !== 'skipped', 'done') };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function falhar(estado, { motivo, agora }) {
|
|
90
|
+
const agents = trocar(estado.agents, (a) => EM_CURSO.includes(a.status), 'failed');
|
|
91
|
+
return { ...estado, status: 'failed', failedAt: agora, motivo: texto(motivo), agents };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const TRANSICOES = { iniciar, passo, checkpoint, pular, concluir, falhar };
|
|
95
|
+
|
|
96
|
+
/** Os seis eventos da regra 2, na ordem em que o runner os usa. */
|
|
97
|
+
export const EVENTOS = Object.keys(TRANSICOES);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* O estado depois do evento.
|
|
101
|
+
* @param {object|null} estado o estado atual, como está no `state.json` (da 1.6.x também); o
|
|
102
|
+
* `iniciar` não o lê
|
|
103
|
+
* @param {object} evento `{ tipo, agora, … }` · `tipo`: um dos `EVENTOS` · `agora`: a hora, em ISO
|
|
104
|
+
* · no `iniciar`: `crew`, `elenco` (`[{ id, name, icon }]`) e `passos` · nos outros, conforme o
|
|
105
|
+
* evento: `n`, `agente`, `rotulo`, `mensagem`, `motivo`. Agente que não está no estado vale como
|
|
106
|
+
* evento sem agente.
|
|
107
|
+
* @returns {object} o estado novo, no formato da §4 da spec (`step.total` vazio é `null`)
|
|
108
|
+
*/
|
|
109
|
+
export function proximoEstado(estado, evento) {
|
|
110
|
+
if (!Object.hasOwn(TRANSICOES, evento.tipo)) throw new Error(`Evento desconhecido: ${evento.tipo}`);
|
|
111
|
+
const atual = evento.tipo === 'iniciar' ? null : normalizar(estado);
|
|
112
|
+
return fechar(TRANSICOES[evento.tipo](atual, evento), evento.agora);
|
|
113
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// O escritório está ligado? A preferência mora em `_opencrew/_memory/preferences.md`.
|
|
2
|
+
// Spec: fase-e1-escritorio-ao-vivo.md, regra 9 (repositório do OpenCrew).
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
// A linha da preferência: `- **Dashboard:** <valor>` (a que o onboarding grava) ou
|
|
7
|
+
// `Dashboard: <valor>`, sem diferenciar maiúsculas. Comentário e texto corrido não contam.
|
|
8
|
+
const LINHA = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?dashboard(?:\*\*)?:(?:\*\*)?[ \t]*(\S*)/im;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pura: o texto de `preferences.md` liga o escritório? Vale a primeira linha da preferência. A marca
|
|
12
|
+
* de ordem de bytes que o PowerShell do Windows e alguns editores gravam no início do arquivo não
|
|
13
|
+
* faz parte da linha.
|
|
14
|
+
*/
|
|
15
|
+
export const ligadoNoTexto = (texto) => texto.replace(/^\uFEFF/, '').match(LINHA)?.[1].toLowerCase() === 'enabled';
|
|
16
|
+
|
|
17
|
+
/** Desligado por padrão: sem o arquivo, sem a linha ou com outro valor, a resposta é `false`. */
|
|
18
|
+
export async function escritorioLigado(raiz) {
|
|
19
|
+
try {
|
|
20
|
+
return ligadoNoTexto(await fs.readFile(path.join(raiz, '_opencrew', '_memory', 'preferences.md'), 'utf8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Estado da execução de uma crew, para o escritório ao vivo: grava `crews/<crew>/state.json`,
|
|
3
|
+
// e só ele. Quem grava é este script, um comando por evento — a IA não monta o JSON.
|
|
4
|
+
// Uso (na pasta do projeto): node _opencrew/core/scripts/estado.mjs <crew> <evento> [opções]
|
|
5
|
+
// iniciar [--passos N]
|
|
6
|
+
// passo [--n K] [--agente <id>] [--rotulo "<texto>"] [--mensagem "<texto>"]
|
|
7
|
+
// checkpoint [--n K] [--agente <id>] [--rotulo "<texto>"]
|
|
8
|
+
// pular --agente <id>
|
|
9
|
+
// concluir
|
|
10
|
+
// falhar [--motivo "<texto>"]
|
|
11
|
+
// <crew> é o nome da pasta em `crews/`. Só grava com `Dashboard: enabled` em
|
|
12
|
+
// `_opencrew/_memory/preferences.md`.
|
|
13
|
+
// Última linha da saída (o runner lê esta linha): ESTADO:OK, ESTADO:OK — estado recriado ou
|
|
14
|
+
// ESTADO:IGNORADO — <motivo>. Código de saída: 0 sempre que a linha ESTADO: sai · 1 = erro de uso
|
|
15
|
+
// (crew ou evento faltando, evento fora da lista, pasta sem `_opencrew/`, crew inexistente ou
|
|
16
|
+
// fora de `crews/`); com código 1 não há linha ESTADO: e nada é escrito.
|
|
17
|
+
// Spec: fase-e1-escritorio-ao-vivo.md (repositório do OpenCrew).
|
|
18
|
+
import { statSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { MSG, dentroDoProjeto, ehPrincipal } from './comum.mjs';
|
|
21
|
+
import { USO, lerArgs, limparTexto } from './estado/argumentos.mjs';
|
|
22
|
+
import { EVENTOS } from './estado/nucleo.mjs';
|
|
23
|
+
import { MOTIVO, decidir } from './estado/decisao.mjs';
|
|
24
|
+
import { lerElenco } from './estado/elenco.mjs';
|
|
25
|
+
import { lerEstado, gravarEstado } from './estado/arquivo.mjs';
|
|
26
|
+
import { escritorioLigado } from './estado/preferencia.mjs';
|
|
27
|
+
|
|
28
|
+
const OK = 'ESTADO:OK';
|
|
29
|
+
const RECRIADO = 'ESTADO:OK — estado recriado';
|
|
30
|
+
/** A resposta é sempre uma linha só, mesmo que o motivo traga um id ou um erro com quebra. */
|
|
31
|
+
const ignorado = (motivo) => `ESTADO:IGNORADO — ${motivo}`.replace(/\s*[\r\n]+\s*/g, ' ');
|
|
32
|
+
|
|
33
|
+
const LISTA = EVENTOS.join(', ');
|
|
34
|
+
|
|
35
|
+
function ehPasta(caminho) {
|
|
36
|
+
try {
|
|
37
|
+
return statSync(caminho).isDirectory();
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Onde fica a crew, ou o erro de uso. A crew é uma pasta direta de `crews/` (`crews/<nome>`
|
|
45
|
+
* também vale): só assim o único arquivo escrito é `crews/<crew>/state.json`.
|
|
46
|
+
* @returns {{ erro: string } | { pasta: string, crew: string }}
|
|
47
|
+
*/
|
|
48
|
+
function localizar(raiz, { crew, evento }) {
|
|
49
|
+
if (!crew) return { erro: 'Falta o nome da crew.' };
|
|
50
|
+
if (!evento) return { erro: `Falta o evento. Eventos: ${LISTA}.` };
|
|
51
|
+
if (!EVENTOS.includes(evento)) return { erro: `Evento desconhecido: ${limparTexto(evento)}. Eventos: ${LISTA}.` };
|
|
52
|
+
if (!ehPasta(path.join(raiz, '_opencrew'))) return { erro: MSG.semRaiz };
|
|
53
|
+
const base = path.resolve(raiz, 'crews');
|
|
54
|
+
const nome = crew.replace(/^crews[\\/]+/, '');
|
|
55
|
+
if (!dentroDoProjeto(base, nome)) return { erro: MSG.foraDoProjeto(limparTexto(crew)) };
|
|
56
|
+
const pasta = path.resolve(base, nome);
|
|
57
|
+
if (path.dirname(pasta) !== base || !ehPasta(pasta)) return { erro: MSG.crewNaoEncontrada(limparTexto(crew)) };
|
|
58
|
+
return { pasta, crew: path.basename(pasta) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Confere a preferência, lê o elenco e o estado, decide e grava. Devolve a linha `ESTADO:`. */
|
|
62
|
+
async function responder({ raiz, pasta, crew }, args, deps) {
|
|
63
|
+
if (!(await escritorioLigado(raiz))) return ignorado(MOTIVO.desligado);
|
|
64
|
+
const agora = (deps.agora ?? (() => new Date().toISOString()))();
|
|
65
|
+
const evento = { tipo: args.evento, ...args.opcoes, crew, elenco: await lerElenco(pasta), agora };
|
|
66
|
+
const arquivo = path.join(pasta, 'state.json');
|
|
67
|
+
const decisao = decidir(await lerEstado(arquivo), evento);
|
|
68
|
+
if (decisao.ignorado) return ignorado(decisao.ignorado);
|
|
69
|
+
if (!(await gravarEstado(arquivo, decisao.estado, deps))) return ignorado(MOTIVO.naoGravou);
|
|
70
|
+
return decisao.recriado ? RECRIADO : OK;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string[]} argv
|
|
75
|
+
* @param {object} [deps] `cwd` (a pasta do projeto) e `escrever`; SÓ PARA TESTE: `agora` (a hora,
|
|
76
|
+
* em ISO), `renomear` e `esperar` (a troca de nome e a espera da gravação)
|
|
77
|
+
* @returns {Promise<number>} 0 = a linha `ESTADO:` saiu · 1 = erro de uso (a linha de uso e o
|
|
78
|
+
* motivo, sem linha `ESTADO:`, nada escrito)
|
|
79
|
+
*/
|
|
80
|
+
export async function main(argv, deps = {}) {
|
|
81
|
+
const { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = deps;
|
|
82
|
+
const args = lerArgs(argv);
|
|
83
|
+
const local = localizar(cwd, args);
|
|
84
|
+
if (local.erro) {
|
|
85
|
+
escrever(USO);
|
|
86
|
+
escrever(local.erro);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
// O escritório nunca para a execução: o que der errado daqui em diante vira ESTADO:IGNORADO.
|
|
90
|
+
escrever(await responder({ raiz: cwd, ...local }, args, deps).catch((erro) => ignorado(MOTIVO.inesperado(erro))));
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (ehPrincipal(import.meta.url)) {
|
|
95
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
|
96
|
+
}
|