chatflux-gestao-agente 0.2.1 → 0.2.3

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/README.md CHANGED
@@ -11,12 +11,30 @@ faz na tela, e tudo no histórico com o seu nome.
11
11
  ## Instalar (equipe — um comando, sem clonar nada)
12
12
 
13
13
  ```sh
14
- claude mcp add --scope user gestao -- npx -y chatflux-gestao-agente mcp
14
+ npx -y chatflux-gestao-agente@latest configurar
15
15
  ```
16
16
 
17
- Pronto. Abra o Claude Code e peça qualquer coisa do portal: na primeira vez ele
18
- mesmo abre o navegador para você autorizar. **Você não digita mais nenhum
19
- comando** — nem `login`, nem `status`.
17
+ No Terminal do Mac ou PowerShell do Windows, esse comando instala a cópia
18
+ permanente do programa, confere a loja oficial de plugins do Claude Code e do
19
+ Codex, atualiza o `chatflux-app`, renova o login quando necessário (abre o
20
+ navegador) e registra/liga o computador. Ferramentas ausentes são puladas; cada
21
+ etapa mostra seu resultado e o que ainda falta fazer. Reinicie o VS Code ao final.
22
+
23
+ Para trocar uma loja antiga, ele pede confirmação. Use `--sim` para autorizar sem
24
+ perguntas, ou `--verificar` para apenas diagnosticar, sem instalar, atualizar,
25
+ abrir login ou ligar o computador. O toolkit é privado: sua conta do GitHub
26
+ precisa ter acesso a `Chatflux-Infra-Tools/chatflux-agent-toolkit`.
27
+
28
+ Depois da instalação, você também pode rodar `gestao configurar`. Se o npm negar
29
+ permissão para instalar globalmente, configure o Node/npm em uma pasta do seu
30
+ usuário e repita, sem sudo nem administrador. Login e ligação do computador
31
+ usam sempre a cópia permanente, nunca o cache temporário do npx.
32
+
33
+ Para registrar o conector MCP diretamente no Claude Code:
34
+
35
+ ```sh
36
+ claude mcp add --scope user gestao -- gestao mcp
37
+ ```
20
38
 
21
39
  ### Instalar (desenvolvimento, dentro deste repo)
22
40
 
@@ -50,7 +68,7 @@ gestao login
50
68
  └─ guarda no Keychain (macOS) ou em ~/.config/gestao/token.json (0600)
51
69
  ```
52
70
 
53
- Vale **até 6 horas** (a validade real aparece no login e no `gestao status`). E na prática você não faz nada disso à mão: o próprio agente
71
+ Vale **1 ano** (decisão de 10/11; a validade real aparece no login e no `gestao status`). E na prática você não faz nada disso à mão: o próprio agente
54
72
  chama a tool `gestao_conectar` quando precisa, o navegador abre, você clica em
55
73
  **Autorizar** e ele refaz a ação. O `gestao login` no terminal existe como
56
74
  alternativa, não como o caminho normal.
@@ -143,7 +161,7 @@ continuam disponíveis; não contam como prova da abertura assistida.
143
161
 
144
162
  ### Reconectar, trocar ambiente ou conta
145
163
 
146
- O login vence (normalmente até 6 h). O residente pausa os pedidos e orienta
164
+ O login vence (1 ano desde o último `gestao login`). O residente pausa os pedidos e orienta
147
165
  `gestao login` ou `gestao_conectar`; ele não renova o token silenciosamente.
148
166
  `gestao status` mostra ambiente, startup, computador e a causa local mesmo sem
149
167
  rede/login. O indicador do portal pode levar até 180 s para perder a conexão.
package/bin/gestao.mjs CHANGED
@@ -14,6 +14,7 @@ const AJUDA = `gestao — portal Gestão pela linha de comando e por agente (MCP
14
14
 
15
15
  gestao login autoriza este computador a agir como você (abre o navegador)
16
16
  gestao status mostra quem está conectado e quanto tempo falta
17
+ gestao configurar [--verificar] [--sim] prepara plugins, login e computador
17
18
  gestao ligar instala início automático e associa este computador ao navegador
18
19
  gestao desligar remove início automático e interrompe o computador, mesmo offline
19
20
  gestao computador executar residente técnico (usado pelo login do SO)
@@ -30,6 +31,11 @@ const [, , verbo] = process.argv;
30
31
 
31
32
  try {
32
33
  switch (verbo) {
34
+ case "configurar": {
35
+ const { configurar } = await import("../lib/configurar.mjs");
36
+ await configurar(process.argv.slice(3));
37
+ break;
38
+ }
33
39
  case "login": {
34
40
  if (nomeServico()) {
35
41
  console.error("Modo serviço (GESTAO_SERVICO) ativo: não há login de pessoa. Use `gestao status`.");
@@ -257,6 +257,7 @@ export function createComputerCli(options = {}) {
257
257
  const state = await startup.installStartup(startupOptions);
258
258
  if (!state.installed || !state.healthy) throw fail('Startup não confirmado.');
259
259
  await startResident(identity);
260
+ if (state.correctedEntryPath) announce('Caminho do início automático corrigido.');
260
261
  } catch {
261
262
  journal.transition({ kind: 'stop', computerId: identity.computerId, generation: registration.generation });
262
263
  let rollbackFailed = false;
@@ -7,14 +7,14 @@
7
7
  */
8
8
  import { spawn } from 'node:child_process';
9
9
 
10
- const PLUGIN = 'chatflux-app@chatflux-marketplace';
11
- const MARKETPLACE = 'chatflux-marketplace';
10
+ export const PLUGIN = 'chatflux-app@chatflux-marketplace';
11
+ export const MARKETPLACE = 'chatflux-marketplace';
12
12
  const MAC_BINARIES = { claude: '/opt/homebrew/bin/claude', codex: '/opt/homebrew/bin/codex' };
13
- const LIST = {
13
+ export const LIST = {
14
14
  claude: ['plugin', 'list', '--json'],
15
15
  codex: ['plugin', 'list', '--marketplace', MARKETPLACE, '--json'],
16
16
  };
17
- const UPDATES = {
17
+ export const UPDATES = {
18
18
  claude: [['plugin', 'marketplace', 'update', MARKETPLACE], ['plugin', 'update', PLUGIN]],
19
19
  codex: [['plugin', 'marketplace', 'upgrade', MARKETPLACE]],
20
20
  };
@@ -28,11 +28,13 @@ const failed = (pluginVersion = null, exitCode) => ({
28
28
 
29
29
  function dependencies(options, updating = false) {
30
30
  const allowed = ['signal', 'spawnImpl', 'platform', 'windowsAdapter', 'timeoutMs'];
31
- if (updating) allowed.push('previousResults');
31
+ if (updating) allowed.push('previousResults', 'tools', 'marketplaceUpdated');
32
32
  if (!options || Object.keys(options).some(k => !allowed.includes(k))) throw new Error('Opções de plugin inválidas.');
33
33
  const { spawnImpl = spawn, platform = process.platform, timeoutMs = 120_000, windowsAdapter, signal } = options;
34
34
  if (typeof spawnImpl !== 'function' || (windowsAdapter !== undefined && typeof windowsAdapter !== 'function') ||
35
35
  !Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 120_000) throw new Error('Opções de plugin inválidas.');
36
+ if (updating && ['tools', 'marketplaceUpdated'].some(key => options[key] !== undefined &&
37
+ (!Array.isArray(options[key]) || options[key].some(tool => !['claude', 'codex'].includes(tool))))) throw new Error('Opções de plugin inválidas.');
36
38
  return { spawnImpl, platform, timeoutMs, windowsAdapter, signal };
37
39
  }
38
40
 
@@ -86,7 +88,7 @@ function run(tool, argv, deps, capture = false) {
86
88
  });
87
89
  }
88
90
 
89
- function installedVersion(tool, stdout) {
91
+ export function installedVersion(tool, stdout) {
90
92
  try {
91
93
  const data = JSON.parse(stdout);
92
94
  const rows = tool === 'claude' ? data : data.installed;
@@ -111,7 +113,7 @@ export async function readPluginVersions(options = {}) {
111
113
  return { claudePluginVersion: claude.pluginVersion, codexPluginVersion: codex.pluginVersion };
112
114
  }
113
115
 
114
- async function update(tool, previous, deps) {
116
+ async function update(tool, previous, deps, marketplaceUpdated = []) {
115
117
  // Só sucesso é terminal na retentativa. Projeção explícita descarta campos livres
116
118
  // mesmo se o journal antigo contiver dados além do protocolo B1.
117
119
  if (previous?.status === 'atualizado') return { status: 'atualizado', pluginVersion: version(previous.pluginVersion) };
@@ -119,6 +121,7 @@ async function update(tool, previous, deps) {
119
121
  if (before.missing) return { status: 'nao_instalado' };
120
122
  if (!before.ok || !before.pluginVersion) return failed(before.pluginVersion, before.exitCode);
121
123
  for (const argv of UPDATES[tool]) {
124
+ if (marketplaceUpdated.includes(tool) && argv[1] === 'marketplace') continue;
122
125
  const result = await run(tool, argv, deps);
123
126
  if (!result.ok) return failed(before.pluginVersion, result.exitCode);
124
127
  }
@@ -129,8 +132,9 @@ async function update(tool, previous, deps) {
129
132
 
130
133
  export async function updatePlugins(options = {}) {
131
134
  const deps = dependencies(options, true);
132
- const claude = await update('claude', options.previousResults?.claude, deps);
133
- const codex = await update('codex', options.previousResults?.codex, deps);
135
+ const tools = options.tools ?? ['claude', 'codex'];
136
+ const claude = tools.includes('claude') ? await update('claude', options.previousResults?.claude, deps, options.marketplaceUpdated) : { status: 'nao_instalado' };
137
+ const codex = tools.includes('codex') ? await update('codex', options.previousResults?.codex, deps, options.marketplaceUpdated) : { status: 'nao_instalado' };
134
138
  // Conservador mesmo em sucesso parcial: o comando pode ter alterado arquivos
135
139
  // antes da falha. Reinício é orientação; esta API não encerra editor/conversa.
136
140
  return { claude, codex, restartRequired: options.previousResults?.restartRequired === true || deps.updateSent === true || claude.status === 'atualizado' || codex.status === 'atualizado' };
@@ -116,7 +116,16 @@ export function createStartupManager({
116
116
  const content = read(l.path);
117
117
  const entry = l.platform === 'darwin' ? content !== null : win().get(l.name) !== null;
118
118
  if (entry && !c) throw conflict();
119
- if (c && l.platform === 'darwin' && content && content.toString() !== plist(l, c)) throw conflict();
119
+ if (c && l.platform === 'darwin' && content && content.toString() !== plist(l, c)) {
120
+ try {
121
+ const parsed = JSON.parse(command('/usr/bin/plutil', ['-convert', 'json', '-o', '-', l.path]));
122
+ const args = parsed.ProgramArguments;
123
+ if (parsed.Label !== l.label || !Array.isArray(args) || args.length !== 4 ||
124
+ typeof args[1] !== 'string' || !args[1].endsWith('/bin/gestao.mjs') ||
125
+ args[2] !== 'computador' || args[3] !== 'executar' || parsed.EnvironmentVariables?.GESTAO_ENV !== l.env) throw conflict();
126
+ return true;
127
+ } catch { throw conflict(); }
128
+ }
120
129
  if (c && l.platform === 'win32') {
121
130
  const value = win().get(l.name);
122
131
  if (value !== null && value !== c.command) throw conflict();
@@ -155,7 +164,7 @@ export function createStartupManager({
155
164
  const c = { version: 1, env: l.env, platform: l.platform, nodePath: validateFile(options.nodePath, l.platform), entryPath: validateFile(options.entryPath, l.platform), identityPath: validateFile(options.identityPath, l.platform), command: null };
156
165
  ensureUserDirectory(l.root, protect); ensureUserDirectory(l.directory, protect);
157
166
  return withFileGate(l.directory, () => {
158
- const previous = metadata(l); owned(l, previous);
167
+ const previous = metadata(l), changedPlist = owned(l, previous);
159
168
  const files = [l.path, l.configPath, ...(l.platform === 'win32' ? [l.launchConfig] : [])];
160
169
  const backup = files.map(file => [file, read(file)]);
161
170
  const oldService = l.platform === 'darwin' ? serviceState(l) : null;
@@ -172,7 +181,7 @@ export function createStartupManager({
172
181
  c.command = `${quoteWindows(path.win32.join(windowsDirectory || 'C:\\Windows', 'System32/wscript.exe'))} //B //Nologo ${quoteWindows(launcher)}`;
173
182
  if (c.command.length > 260) throw failure();
174
183
  }
175
- if (JSON.stringify(previous) === JSON.stringify(c) && startupStatus(options).healthy && (l.platform === 'win32' || wasRunning)) return startupStatus(options);
184
+ if (!changedPlist && JSON.stringify(previous) === JSON.stringify(c) && startupStatus(options).healthy && (l.platform === 'win32' || wasRunning)) return startupStatus(options);
176
185
  touched = true;
177
186
  if (l.platform === 'darwin') {
178
187
  fs.mkdirSync(path.dirname(l.path), { recursive: true });
@@ -187,7 +196,7 @@ export function createStartupManager({
187
196
  if (win().get(l.name) !== c.command) throw failure();
188
197
  }
189
198
  write(l.configPath, JSON.stringify(c));
190
- return startupStatus(options);
199
+ return { ...startupStatus(options), correctedEntryPath: l.platform === 'darwin' && !!previous && (changedPlist || previous.entryPath !== c.entryPath || previous.nodePath !== c.nodePath) };
191
200
  } catch {
192
201
  if (touched) {
193
202
  try {
package/lib/config.mjs CHANGED
@@ -39,7 +39,7 @@ export function ambiente() {
39
39
  //
40
40
  // Keychain do macOS quando houver (é onde o SO já guarda segredo e o usuário
41
41
  // controla o acesso); arquivo 0600 como fallback nos outros sistemas. O token
42
- // vale até 6h (template `convex-agente`) — por isso não fica em texto puro no
42
+ // vale 1 ano (template `convex-agente`) — por isso não fica em texto puro no
43
43
  // macOS, que é a máquina real da equipe. Revogação antes do `exp`: arquivar a
44
44
  // conta (`active:false`); `gestao logout` só apaga a cópia local.
45
45
 
@@ -113,6 +113,12 @@ export function descreverValidade(jwt, agora = Date.now()) {
113
113
  const exp = expiraEm(jwt);
114
114
  const restaMin = Math.max(0, Math.round((exp - agora) / 60000));
115
115
  if (!exp || restaMin === 0) return "validade desconhecida";
116
+ // Acima de um dia, hora sem data engana ("87599h59 (até 18:40)"): mostra dias e data.
117
+ if (restaMin > 24 * 60) {
118
+ const dias = Math.floor(restaMin / (24 * 60));
119
+ const data = new Date(exp).toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
120
+ return `${dias} dia${dias === 1 ? "" : "s"} (até ${data})`;
121
+ }
116
122
  const h = Math.floor(restaMin / 60);
117
123
  const m = restaMin % 60;
118
124
  const dur = h === 0 ? `${m} min` : m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, "0")}`;
@@ -120,6 +126,20 @@ export function descreverValidade(jwt, agora = Date.now()) {
120
126
  return `${dur} (até ${hora})`;
121
127
  }
122
128
 
129
+ /** Aviso para o modelo repassar à pessoa quando faltam 15 dias ou menos para o
130
+ * login vencer. Com validade de 1 ano, sem isso o vencimento pega de surpresa.
131
+ * "" fora da janela, com token inválido ou já vencido (aí a chamada falha e o
132
+ * modelo chama `gestao_conectar` por conta própria). */
133
+ export function avisoVencimento(jwt, agora = Date.now()) {
134
+ const exp = expiraEm(jwt);
135
+ if (!exp || exp <= agora || exp - agora > 15 * 86_400_000) return "";
136
+ return (
137
+ `AVISO: o login do Gestão desta máquina vence em ${descreverValidade(jwt, agora)}. ` +
138
+ "Diga isso à pessoa em uma frase e ofereça renovar agora com a tool `gestao_conectar` " +
139
+ "(abre o navegador; vale mais 1 ano)."
140
+ );
141
+ }
142
+
123
143
  export function tokenValido(dados, margemMs = 60_000) {
124
144
  return Boolean(dados?.token) && expiraEm(dados.token) - margemMs > Date.now();
125
145
  }
@@ -0,0 +1,319 @@
1
+ /** Preparação pessoal, sem comandos recebidos do portal. Efeitos só fora de --verificar. */
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import http from 'node:http';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { spawn } from 'node:child_process';
8
+ import { createInterface } from 'node:readline/promises';
9
+ import { lerToken, expiraEm } from './config.mjs';
10
+ import { scopeDirectory } from './computador-identidade.mjs';
11
+ import { LIST, UPDATES, MARKETPLACE, PLUGIN, installedVersion, updatePlugins } from './computador-plugin.mjs';
12
+
13
+ const REPO = 'Chatflux-Infra-Tools/chatflux-agent-toolkit';
14
+ const URL_GIT = `https://github.com/${REPO}.git`;
15
+ const ENTRY = fileURLToPath(new URL('../bin/gestao.mjs', import.meta.url));
16
+ const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url))).version;
17
+ // Mesmo predicado cached de computador-startup.mjs; aquele módulo não o exporta.
18
+ const cached = value => /(?:^|[\\/])(?:_npx|_cacache)(?:[\\/]|$)/i.test(value);
19
+ const names = { claude: 'Claude Code', codex: 'Codex' };
20
+ const accessHelp = 'Confira seu acesso ao repositório privado Chatflux-Infra-Tools/chatflux-agent-toolkit no GitHub e a conexão; depois rode gestao configurar de novo.';
21
+
22
+ async function ask(question) {
23
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
24
+ const input = createInterface({ input: process.stdin, output: process.stdout });
25
+ try { return /^(s|sim)$/i.test((await input.question(`${question} `)).trim()); }
26
+ finally { input.close(); }
27
+ }
28
+
29
+ export async function configurar(flags = [], options = {}) {
30
+ if (flags.some(flag => !['--sim', '--verificar'].includes(flag))) throw new Error('Use gestao configurar [--sim] [--verificar].');
31
+ const verify = flags.includes('--verificar'), yes = flags.includes('--sim');
32
+ const platform = options.platform ?? process.platform;
33
+ if (!['darwin', 'win32'].includes(platform)) throw new Error('Configurar está disponível para Mac e Windows.');
34
+ const p = platform === 'win32' ? path.win32 : path.posix;
35
+ const fsImpl = options.fsImpl ?? fs, spawnImpl = options.spawnImpl ?? spawn;
36
+ const home = options.home ?? os.homedir(), env = options.env ?? process.env;
37
+ const environment = env.GESTAO_ENV === 'dev' ? 'dev' : 'prod';
38
+ const commandEnv = { ...env, GESTAO_ENV: environment };
39
+ const nodePath = options.nodePath ?? process.execPath, entryPath = fsImpl.realpathSync(options.entryPath ?? ENTRY);
40
+ const version = options.version ?? VERSION, now = options.now ?? Date.now;
41
+ const announce = options.announce ?? console.log, confirm = options.confirm ?? ask;
42
+ const readToken = options.readToken ?? lerToken;
43
+ const results = [];
44
+ let restart = false;
45
+ function result(item, ok, message) {
46
+ const line = `${item}: ${message} ${ok === 'warn' ? '⚠' : ok ? '✓' : '✗'}`;
47
+ results.push({ item, ok, message }); announce(line);
48
+ }
49
+ function read(file) {
50
+ try { return fsImpl.readFileSync(file, 'utf8'); }
51
+ catch (e) { if (e.code === 'ENOENT') return null; throw e; }
52
+ }
53
+ const json = file => { const text = read(file); return text === null ? null : JSON.parse(text); };
54
+ function resolveCommand(tool) {
55
+ const directories = [...new Set([...(env.PATH ?? env.Path ?? '').split(platform === 'win32' ? ';' : ':'),
56
+ p.dirname(nodePath), p.join(home, '.local/bin'), ...(platform === 'darwin' ? ['/opt/homebrew/bin', '/usr/local/bin'] : [])])].filter(Boolean);
57
+ for (const directory of directories) {
58
+ for (const ext of platform === 'win32' ? ['.exe', '.cmd'] : ['']) {
59
+ const binary = p.join(directory, `${tool}${ext}`);
60
+ try {
61
+ fsImpl.accessSync(binary, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
62
+ if (!fsImpl.statSync(binary).isFile()) continue;
63
+ if (ext !== '.cmd') return { binary, prefix: [] };
64
+ // npm gera .cmd que não são executáveis via execFile. Rodar seu JS com
65
+ // o Node evita shell, aspas e expansão de %/& em caminhos do Windows.
66
+ const script = p.join(directory, 'node_modules', { npm: 'npm/bin/npm-cli.js', codex: '@openai/codex/bin/codex.js', claude: '@anthropic-ai/claude-code/cli.js' }[tool]);
67
+ if (fsImpl.existsSync(script)) return { binary: nodePath, prefix: [script] };
68
+ } catch (e) { if (!['ENOENT', 'EACCES'].includes(e.code)) throw e; }
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+ const commands = {};
74
+ function start(command, argv, spawnOptions = {}) {
75
+ if (!command) throw new Error('Programa não encontrado.');
76
+ return spawnImpl(command.binary, [...command.prefix, ...argv], { ...spawnOptions, ...(command.cwd ? { cwd: command.cwd } : {}), env: commandEnv, shell: false, windowsHide: true });
77
+ }
78
+ async function run(command, argv, interactive = false) {
79
+ return new Promise((resolve, reject) => {
80
+ let child, timer, bytes = 0, stdout = '', stderr = '', done = false;
81
+ const finish = (error, value) => { if (done) return; done = true; clearTimeout(timer); error ? reject(error) : resolve(value); };
82
+ const stop = () => { try { child?.kill('SIGKILL'); } catch { /* somente filho próprio */ } finish(new Error('O comando demorou demais; tente novamente.')); };
83
+ try {
84
+ child = start(command, argv, { stdio: interactive ? 'inherit' : ['ignore', 'pipe', 'pipe'] });
85
+ for (const stream of ['stdout', 'stderr']) child[stream]?.on('data', chunk => {
86
+ bytes += Buffer.byteLength(chunk); if (bytes > 1024 * 1024) return stop();
87
+ if (stream === 'stdout') stdout += chunk; else stderr += chunk;
88
+ });
89
+ child.once('error', error => finish(error));
90
+ child.once('close', code => code === 0 ? finish(null, stdout.trim()) : finish(Object.assign(new Error('Não foi possível concluir o comando.'), { permission: /EACCES|EPERM|permission denied/i.test(stderr) })));
91
+ timer = setTimeout(stop, interactive ? 5 * 60_000 : 120_000);
92
+ } catch (error) { finish(error); }
93
+ });
94
+ }
95
+
96
+ announce(`Conferindo este computador · ambiente ${environment}${verify ? ' · somente diagnóstico' : ''}.`);
97
+ // Nada instala startup ou abre login com o ENTRY efêmero do npx.
98
+ let persistent = entryPath;
99
+ if (verify) {
100
+ try {
101
+ commands.npm = resolveCommand('npm');
102
+ const root = await run(commands.npm, ['root', '-g']);
103
+ if (!p.isAbsolute(root)) throw new Error('Pasta global inválida.');
104
+ const installed = json(p.join(root, 'chatflux-gestao-agente/package.json'))?.version;
105
+ let latest = null;
106
+ try { latest = await run(commands.npm, ['view', 'chatflux-gestao-agente', 'version']); } catch { /* sem registro */ }
107
+ const valid = v => typeof v === 'string' && /^\d+\.\d+\.\d+$/.test(v);
108
+ if (!valid(installed)) result('Programa do portal', false, 'instalação permanente não encontrada');
109
+ else if (!valid(latest)) result('Programa do portal', 'warn', `${installed}; versão do npm não confirmada`);
110
+ else if (installed !== latest) result('Programa do portal', false, `${installed} → ${latest} disponível`);
111
+ else result('Programa do portal', true, installed);
112
+ } catch { result('Programa do portal', 'warn', 'instalação permanente não confirmada'); }
113
+ } else if (cached(entryPath)) {
114
+ persistent = null;
115
+ try {
116
+ commands.npm = resolveCommand('npm');
117
+ await run(commands.npm, ['install', '-g', `chatflux-gestao-agente@${version}`]);
118
+ const root = await run(commands.npm, ['root', '-g']);
119
+ if (!p.isAbsolute(root)) throw new Error('Pasta global inválida.');
120
+ const candidate = fsImpl.realpathSync(p.join(root, 'chatflux-gestao-agente/bin/gestao.mjs'));
121
+ if (cached(candidate) || !fsImpl.statSync(candidate).isFile() || json(p.join(root, 'chatflux-gestao-agente/package.json'))?.version !== version) throw new Error('Instalação permanente não confirmada.');
122
+ persistent = candidate;
123
+ result('Programa do portal instalado', true, version);
124
+ } catch (error) {
125
+ const permission = error.permission || ['EACCES', 'EPERM'].includes(error.code);
126
+ result('Programa do portal instalado', false, permission
127
+ ? 'Sem permissão para instalar. Configure o npm/Node em uma pasta do seu usuário e repita gestao configurar, sem sudo nem administrador.'
128
+ : 'Instalação não confirmada. Confira o npm e a conexão; repita gestao configurar.');
129
+ }
130
+ } else result('Programa do portal instalado', true, version);
131
+
132
+ for (const tool of ['claude', 'codex']) {
133
+ try { commands[tool] = resolveCommand(tool); result(names[tool], true, commands[tool] ? 'instalado' : 'não instalado; etapa pulada'); }
134
+ catch { result(names[tool], false, 'não foi possível localizar o programa'); }
135
+ }
136
+ function source(tool) {
137
+ if (tool === 'claude') {
138
+ const data = json(p.join(home, '.claude/plugins/known_marketplaces.json'));
139
+ const entry = data?.[MARKETPLACE];
140
+ return { exists: !!entry, official: entry?.source?.source === 'github' && entry.source.repo === REPO };
141
+ }
142
+ const text = read(p.join(env.CODEX_HOME ?? p.join(home, '.codex'), 'config.toml')) ?? '';
143
+ let inside = false, exists = false;
144
+ const values = {};
145
+ for (const line of text.split(/\r?\n/)) {
146
+ const section = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
147
+ if (section) {
148
+ inside = /^marketplaces\.(?:chatflux-marketplace|"chatflux-marketplace"|'chatflux-marketplace')$/.test(section[1].trim());
149
+ if (inside) { if (exists) throw new Error('Loja duplicada no Codex.'); exists = true; }
150
+ } else if (inside && /^\s*source(?:_type)?\s*=/.test(line)) {
151
+ const field = line.match(/^\s*(source_type|source)\s*=\s*("(?:[^"\\]|\\.)*"|'[^']*')\s*(?:#.*)?$/);
152
+ if (!field || Object.hasOwn(values, field[1])) throw new Error('Fonte do Codex não reconhecida.');
153
+ values[field[1]] = field[2][0] === '"' ? JSON.parse(field[2]) : field[2].slice(1, -1);
154
+ }
155
+ }
156
+ return { exists, official: values.source_type === 'git' && values.source === URL_GIT };
157
+ }
158
+ const snapshots = {}, ready = [];
159
+ for (const tool of ['claude', 'codex']) {
160
+ if (!commands[tool]) continue;
161
+ try {
162
+ const store = source(tool);
163
+ if (verify) {
164
+ result(`${names[tool]} — loja de plugins`, store.official, store.official ? 'fonte oficial; pode atualizar' : 'falta trocar para a fonte oficial');
165
+ if (tool === 'claude') {
166
+ const installed = json(p.join(home, '.claude/plugins/installed_plugins.json'));
167
+ const rows = installed?.plugins?.[PLUGIN];
168
+ if (Array.isArray(rows)) snapshots[tool] = { version: installedVersion(tool, JSON.stringify(rows.map(row => ({ ...row, id: PLUGIN })))) };
169
+ } else try { snapshots[tool] = { version: installedVersion(tool, await run(commands[tool], LIST[tool])) }; } catch { /* versão desconhecida */ }
170
+ continue;
171
+ }
172
+ const output = await run(commands[tool], LIST[tool]);
173
+ const data = JSON.parse(output), rows = tool === 'claude' ? data : data.installed;
174
+ if (!Array.isArray(rows)) throw new Error('Lista de plugins não reconhecida.');
175
+ snapshots[tool] = { output, version: installedVersion(tool, output) };
176
+ const plugins = rows.filter(row => tool === 'claude' ? row?.id?.endsWith(`@${MARKETPLACE}`) : row?.installed === true && row.pluginId?.endsWith(`@${MARKETPLACE}`))
177
+ .map(row => ({ id: tool === 'claude' ? row.id : row.pluginId, scope: tool === 'claude' ? row.scope ?? 'user' : 'user', cwd: row.projectPath }));
178
+ if (!plugins.some(row => row.id === PLUGIN && row.scope === 'user')) plugins.push({ id: PLUGIN, scope: 'user' });
179
+ if (plugins.some(row => !/^[a-zA-Z0-9_-]+@chatflux-marketplace$/.test(row.id) || !['user', 'project', 'local'].includes(row.scope) || (row.scope !== 'user' && !p.isAbsolute(row.cwd ?? '')))) throw new Error('Não foi possível preservar os plugins instalados.');
180
+ if (!store.official) {
181
+ if (!yes && !await confirm('Trocar a loja de plugins para a oficial? (s/n)')) {
182
+ result(`${names[tool]} — loja de plugins`, false, 'troca não autorizada; repita no terminal ou use --sim para autorizar'); continue;
183
+ }
184
+ restart = true;
185
+ if (store.exists) await run(commands[tool], ['plugin', 'marketplace', 'remove', MARKETPLACE]);
186
+ await run(commands[tool], ['plugin', 'marketplace', 'add', tool === 'claude' ? REPO : URL_GIT]);
187
+ }
188
+ restart = true;
189
+ await run(commands[tool], UPDATES[tool][0]);
190
+ ready.push(tool);
191
+ const missingApp = !rows.some(row => tool === 'claude' ? row.id === PLUGIN && row.scope === 'user' : row.pluginId === PLUGIN && row.installed);
192
+ let restored = true;
193
+ for (const row of !store.official ? plugins : missingApp ? [{ id: PLUGIN, scope: 'user' }] : []) {
194
+ try {
195
+ // O escopo de projeto usa o diretório original, sem alterar o cwd do processo.
196
+ await run({ ...commands[tool], cwd: row.cwd }, tool === 'claude' ? ['plugin', 'install', row.id, '--scope', row.scope] : ['plugin', 'add', row.id]);
197
+ } catch { restored = false; }
198
+ }
199
+ result(`${names[tool]} — loja de plugins`, restored, restored ? 'fonte oficial atualizada; plugins preservados' : `fonte oficial atualizada; falta reinstalar um plugin. ${accessHelp}`);
200
+ } catch {
201
+ result(`${names[tool]} — loja de plugins`, false, `Não foi possível conferir ou atualizar a loja. ${accessHelp}`);
202
+ }
203
+ }
204
+ if (verify) {
205
+ let latest = null, origin = '';
206
+ try {
207
+ const data = readToken();
208
+ if (data?.env === environment && expiraEm(data.token) > now()) {
209
+ const host = environment === 'dev' ? 'https://agreeable-woodpecker-190.convex.cloud' : 'https://dependable-stingray-656.convex.cloud';
210
+ const response = await (options.fetchImpl ?? fetch)(`${host}/api/query`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` }, body: JSON.stringify({ path: 'ops/plugin:meuStatus', args: {}, format: 'json' }), signal: AbortSignal.timeout(10000) });
211
+ const body = await response.json();
212
+ if (response.ok && body.status === 'success') latest = body.value?.atual;
213
+ }
214
+ } catch { /* tenta loja local */ }
215
+ if (!/^\d+\.\d+\.\d+$/.test(latest ?? '')) {
216
+ try { latest = json(p.join(home, '.claude/plugins/marketplaces/chatflux-marketplace/plugins/chatflux-app/.claude-plugin/plugin.json'))?.version; origin = ' (versão da loja)'; } catch { /* indisponível */ }
217
+ }
218
+ if (!/^\d+\.\d+\.\d+$/.test(latest ?? '')) latest = null;
219
+ for (const tool of ['claude', 'codex']) if (commands[tool]) {
220
+ const installed = snapshots[tool]?.version;
221
+ if (!installed) result(`${names[tool]} — chatflux-app`, 'warn', `versão não confirmada${latest && origin ? `; versão da loja: ${latest}` : ''}`);
222
+ else if (!latest) result(`${names[tool]} — chatflux-app`, 'warn', `${installed}; versão atual não confirmada`);
223
+ else if (installed !== latest) result(`${names[tool]} — chatflux-app`, false, `${installed} → atual ${latest}${origin} (vai atualizar)`);
224
+ else result(`${names[tool]} — chatflux-app`, true, `${installed}${origin}`);
225
+ }
226
+ } else {
227
+ const bridge = (tool, argv, spawnOptions) => start(commands[tool], argv, spawnOptions);
228
+ const updated = await updatePlugins({ platform, tools: ready, marketplaceUpdated: ready,
229
+ ...(platform === 'win32' ? { windowsAdapter: bridge } : { spawnImpl: (binary, argv, spawnOptions) => bridge(binary.endsWith('/claude') ? 'claude' : 'codex', argv, spawnOptions) }) });
230
+ restart ||= updated.restartRequired;
231
+ for (const tool of ['claude', 'codex']) if (commands[tool]) {
232
+ const item = updated[tool], ok = ready.includes(tool) && item.status === 'atualizado';
233
+ result(`${names[tool]} — chatflux-app`, ok, ok ? item.pluginVersion : `atualização não confirmada${item.pluginVersion ? `; versão ${item.pluginVersion}` : ''}. ${accessHelp}`);
234
+ }
235
+ }
236
+
237
+ const credential = () => { const value = readToken(); return value?.env === environment ? value : null; };
238
+ const expiry = value => value?.token ? expiraEm(value.token) : 0;
239
+ const until = value => new Date(expiry(value)).toLocaleString('pt-BR');
240
+ let loginOk = false;
241
+ try {
242
+ let data = credential();
243
+ if (expiry(data) - now() <= 15 * 86400000) {
244
+ if (verify) result('Login do portal', false, 'ausente, vencido ou vence em até 15 dias; precisa renovar no navegador');
245
+ else if (!persistent) result('Login do portal', false, 'pulado; instale o programa do portal em uma pasta permanente');
246
+ else {
247
+ await run({ binary: nodePath, prefix: [persistent] }, ['login'], true);
248
+ data = credential(); loginOk = expiry(data) > now();
249
+ result('Login do portal', loginOk, loginOk ? `conectado até ${until(data)}` : 'login não confirmado; rode gestao login');
250
+ }
251
+ } else { loginOk = true; result('Login do portal', true, `válido até ${until(data)}`); }
252
+ } catch { result('Login do portal', false, 'não foi possível conectar; rode gestao login e autorize no navegador'); }
253
+
254
+ // statusComputer protege arquivos e openJournal cria diretórios. O diagnóstico
255
+ // usa apenas um retrato dos metadados, sem ACL, journal aberto ou canal local.
256
+ function readOnlyComputer() {
257
+ const baseDir = p.join(home, '.config/gestao/computadores');
258
+ const active = json(p.join(baseDir, environment, 'ativo.json'));
259
+ if (!active?.subject) return { computerId: null, startupInstalled: false, startupHealthy: false };
260
+ const directory = p.join(baseDir, environment, p.basename(scopeDirectory({ env: environment, subject: active.subject })));
261
+ const identity = json(p.join(directory, 'identidade.json'));
262
+ const journal = json(p.join(directory, 'journal.json'));
263
+ const startupRoot = platform === 'darwin' ? p.join(home, '.config/gestao/startup') : p.join(env.LOCALAPPDATA || home, 'ChatFluxGestao/startup');
264
+ const config = json(p.join(startupRoot, environment, 'startup.json'));
265
+ const launcher = platform === 'darwin'
266
+ ? p.join(home, 'Library/LaunchAgents', `ai.chatflux.gestao-computador.${environment}.plist`)
267
+ : p.join(startupRoot, environment, 'launcher.vbs');
268
+ const configured = !!config && config.env === environment && config.platform === platform && fsImpl.existsSync(launcher) &&
269
+ [config.nodePath, config.entryPath, config.identityPath].every(file => typeof file === 'string' && !cached(file) && fsImpl.existsSync(file));
270
+ return { directory, computerId: identity?.computerId, startupInstalled: configured, startupHealthy: configured,
271
+ state: journal?.stopped ? 'desligado' : 'configurado; execução não conferida' };
272
+ }
273
+ async function checkResident(directory) {
274
+ const file = p.join(directory, 'controle.json');
275
+ let stat;
276
+ try { stat = fsImpl.lstatSync(file); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
277
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 8192) throw new Error('Controle inválido.');
278
+ const data = json(file);
279
+ if (data?.version !== 1 || !Number.isSafeInteger(data.port) || data.port < 1 || data.port > 65535 || !Number.isSafeInteger(data.pid) || data.pid < 1 || !/^[a-f0-9]{64}$/.test(data.key)) throw new Error('Controle inválido.');
280
+ return new Promise((resolve, reject) => {
281
+ const req = http.request({ host: '127.0.0.1', port: data.port, path: '/status', method: 'POST', headers: { Authorization: `Bearer ${data.key}` }, timeout: 3000, agent: false }, res => {
282
+ let body = ''; res.setEncoding('utf8');
283
+ res.on('data', chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
284
+ res.on('end', () => { try { if (res.statusCode !== 200) throw new Error(); resolve(JSON.parse(body)); } catch { reject(new Error('Controle indisponível.')); } });
285
+ });
286
+ req.on('timeout', () => req.destroy()); req.on('error', reject); req.end();
287
+ });
288
+ }
289
+ try {
290
+ const status = verify ? (options.readOnlyComputer ?? readOnlyComputer)() : await (options.statusComputer ?? (await import('./computador-cli.mjs')).statusComputer)();
291
+ if (verify && status.computerId && status.startupInstalled && status.startupHealthy && status.state !== 'desligado') {
292
+ let live;
293
+ try { live = await (options.checkResident ?? checkResident)(status.directory); }
294
+ catch { live = undefined; }
295
+ if (live?.state) result('Computador', ['conectado', 'residente iniciado'].includes(live.state) ? true : 'warn', `residente em execução; ${live.state}`);
296
+ else if (live === null) result('Computador', false, 'configurado; residente não está rodando');
297
+ else result('Computador', 'warn', 'configurado; não deu para conferir se está rodando');
298
+ } else {
299
+ const connected = !!status.computerId && status.startupInstalled && status.startupHealthy &&
300
+ ['conectado', 'residente iniciado'].includes(status.state);
301
+ if (connected) result('Computador', true, verify ? status.state : 'já registrado e ligado');
302
+ else if (verify) result('Computador', false, 'precisa registrar ou ligar; rode gestao configurar');
303
+ else if (!persistent || !loginOk) result('Computador', false, 'etapa pulada; conclua a instalação e o login, depois repita gestao configurar');
304
+ else {
305
+ await run({ binary: nodePath, prefix: [persistent] }, ['ligar'], true);
306
+ result('Computador', true, 'registrado e ligado');
307
+ }
308
+ }
309
+ } catch { result('Computador', false, 'não foi possível conferir ou ligar; rode gestao ligar e siga a orientação exibida'); }
310
+ const pending = results.filter(item => item.ok !== true);
311
+ if (!pending.length) announce('\nTudo pronto neste computador ✓');
312
+ else {
313
+ announce('\nResumo:');
314
+ for (const item of pending) announce(`${item.item}: ${item.message} ${item.ok === 'warn' ? '⚠' : '✗'}`);
315
+ }
316
+ if (restart) announce('Para terminar: reinicie o VS Code e abra uma nova conversa no Claude Code/Codex.');
317
+ if (verify && pending.length) announce('Para resolver, rode: npx -y chatflux-gestao-agente@latest configurar');
318
+ return { results, restartRequired: restart };
319
+ }
package/lib/gestao.mjs CHANGED
@@ -73,7 +73,7 @@ let cache = null;
73
73
  * disco — é cache, não fonte: só serve pra listar tools quando a credencial
74
74
  * expirou, e qualquer chamada de verdade continua exigindo login.
75
75
  *
76
- * Isso existe porque o token vence (6h): sem o cache, a pessoa veria o servidor
76
+ * Isso existe porque o token vence (1 ano): sem o cache, a pessoa veria o servidor
77
77
  * MCP "falhar ao conectar" a cada expiração, sem pista do motivo.
78
78
  */
79
79
  export async function contrato({ permitirCache = false } = {}) {
package/lib/login.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // `gestao login` — o handshake de identidade.
2
2
  //
3
3
  // Sobe um listener em 127.0.0.1 numa porta efêmera, abre o portal com `port` e
4
- // `state`, e espera a página devolver o JWT do template `convex-agente` (6h). O browser já
4
+ // `state`, e espera a página devolver o JWT do template `convex-agente` (1 ano). O browser já
5
5
  // tem a sessão do Clerk; nós nunca vemos senha, e o backend não ganha rota nova.
6
6
  //
7
7
  // Por que NÃO OAuth: o Clerk não documenta caminho de credencial OAuth → JWT com
package/lib/mcp.mjs CHANGED
@@ -13,7 +13,7 @@ import path from "node:path";
13
13
  import { contrato, chamar, euSou } from "./gestao.mjs";
14
14
  import { loginVerificado } from "./login.mjs";
15
15
  import { nomeServico } from "./servico.mjs";
16
- import { ambiente, descreverValidade } from "./config.mjs";
16
+ import { ambiente, avisoVencimento, descreverValidade, lerToken } from "./config.mjs";
17
17
  import { montarTools } from "./tools.mjs";
18
18
 
19
19
  const PROTOCOLO = "2024-11-05";
@@ -41,7 +41,7 @@ let tools = null;
41
41
  /**
42
42
  * A tool que CONECTA — não a que pede pra pessoa digitar comando.
43
43
  *
44
- * Fica SEMPRE na lista, autenticado ou não, porque a sessão vence (6h no
44
+ * Fica SEMPRE na lista, autenticado ou não, porque a sessão vence (1 ano no
45
45
  * template `convex-agente`): quando qualquer ação falhar por sessão, o modelo chama esta e a pessoa
46
46
  * só clica no navegador. Ela nunca precisa saber que existe uma CLI.
47
47
  */
@@ -49,7 +49,7 @@ const TOOL_CONECTAR = {
49
49
  name: "gestao_conectar",
50
50
  description:
51
51
  "Conecta esta máquina ao Gestão. ABRE O NAVEGADOR para a pessoa autorizar com " +
52
- "o login dela (leva segundos) e vale algumas horas (6h no padrão).\n\n" +
52
+ "o login dela (leva segundos) e vale 1 ano.\n\n" +
53
53
  "CHAME ISTO SEM PEDIR PERMISSÃO sempre que uma ação do Gestão falhar por " +
54
54
  "sessão expirada ou ausente, ou quando não houver nenhuma outra tool do Gestão " +
55
55
  "disponível. Depois de conectar, refaça a ação que falhou.\n\n" +
@@ -60,6 +60,20 @@ const TOOL_CONECTAR = {
60
60
  _endpoint: null,
61
61
  };
62
62
 
63
+ /** Uma vez por processo (= por sessão do harness), na primeira chamada que deu
64
+ * certo: se o login vence em até 15 dias, devolve o aviso para ir junto da
65
+ * resposta. Modo serviço não tem login de pessoa. */
66
+ let vencimentoConferido = false;
67
+ function conferirVencimento() {
68
+ if (vencimentoConferido || nomeServico()) return "";
69
+ vencimentoConferido = true;
70
+ try {
71
+ return avisoVencimento(lerToken()?.token);
72
+ } catch {
73
+ return "";
74
+ }
75
+ }
76
+
63
77
  /** Estamos no estado degradado (só a tool de conectar)? */
64
78
  const soConectar = (t) => t?.length === 1 && t[0]._endpoint === null;
65
79
 
@@ -167,7 +181,10 @@ async function tratar(msg) {
167
181
  }
168
182
  try {
169
183
  const valor = await chamar(tool._endpoint, params.arguments ?? {}, tool._kind);
170
- return ok(id, texto(valor ?? { ok: true }));
184
+ const resultado = texto(valor ?? { ok: true });
185
+ const aviso = conferirVencimento();
186
+ if (aviso) resultado.content.push({ type: "text", text: aviso });
187
+ return ok(id, resultado);
171
188
  } catch (e) {
172
189
  // Erro de REGRA (ex.: "Empresa arquivada…") volta como conteúdo, não como
173
190
  // erro de protocolo: é informação que o modelo deve ler e agir em cima.
@@ -179,7 +196,7 @@ async function tratar(msg) {
179
196
  }
180
197
 
181
198
  export async function servir() {
182
- // NUNCA sai por falta de credencial. O token vence (6h); se o servidor morresse
199
+ // NUNCA sai por falta de credencial. O token vence (1 ano); se o servidor morresse
183
200
  // ao expirar, a pessoa veria "Failed to connect — Connection closed" a cada
184
201
  // expiração, sem pista do motivo. Aqui ele sobe sempre, e a falta de sessão
185
202
  // aparece como uma tool que EXPLICA o que fazer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatflux-gestao-agente",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Opere o portal Gestão da ChatFlux pelo Claude Code, Claude Desktop ou qualquer harness MCP — com a sua identidade e o seu papel.",
6
6
  "keywords": [