primocode 8.0.0 → 8.0.2

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/bin/primocode.js CHANGED
@@ -24,6 +24,18 @@ const pasta = require('../lib/pasta');
24
24
  const catalogo = require('../lib/catalogo');
25
25
  const effort = require('../lib/effort');
26
26
 
27
+ // Rede de segurança de último recurso: sem isto, uma exceção fora de um
28
+ // try/catch (ex.: dentro de um callback de socket, como o parser de SSE) não
29
+ // era pega por ninguém e derrubava o processo INTEIRO em silêncio — o usuário
30
+ // via a resposta parar no meio e cair de volta pro shell, sem mensagem
31
+ // nenhuma. Agora vira um erro visível, e o processo continua de pé.
32
+ process.on('uncaughtException', (e) => {
33
+ console.log('\n' + c.err(ui.friendlyError(e)));
34
+ });
35
+ process.on('unhandledRejection', (e) => {
36
+ console.log('\n' + c.err(ui.friendlyError(e instanceof Error ? e : new Error(String(e)))));
37
+ });
38
+
27
39
  // A pasta de onde o comando foi dado. Não é mais onde ele trabalha (o padrão é
28
40
  // ~/primocode), mas continua servindo de ponto de partida para achar pasta que
29
41
  // o usuário mencionar por nome relativo.
package/lib/act.js CHANGED
@@ -165,7 +165,7 @@ function buildMemoryContext(projectDir, projectName) {
165
165
  const geral = cfg.readGeneralMemory();
166
166
  if (geral.trim()) parts.push(`[memory-geral.md]\n${geral}`);
167
167
  const projeto = cfg.readProjectMemory(projectDir);
168
- if (projeto.trim()) parts.push(`[${projectName}/memory.md]\n${projeto}`);
168
+ if (projeto.trim()) parts.push(`[${projectName || 'projeto'}/memory.md]\n${projeto}`);
169
169
  return parts.join('\n\n');
170
170
  }
171
171
 
@@ -299,9 +299,19 @@ async function runAct(userPrompt, opts) {
299
299
  const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
300
300
  logResult(tc.name, result, profundidade);
301
301
 
302
- // Uma escrita bem-sucedida limpa a dívida: o agente se corrigiu.
303
- if (result && result.ok === false) falhasPendentes.push(`${tc.name}(${tc.arguments?.path || ''}): ${result.error}`);
304
- else if (['write_file', 'edit_file'].includes(tc.name)) falhasPendentes.length = 0;
302
+ // Uma escrita bem-sucedida limpa a dívida — mas só a do MESMO
303
+ // arquivo. Escrever algo sem relação (ou um run_shell qualquer)
304
+ // não corrige um teste quebrado noutro lugar; antes isto zerava
305
+ // a lista inteira e o agente conseguia dizer "concluído" com uma
306
+ // falha real ainda pendente.
307
+ if (result && result.ok === false) {
308
+ falhasPendentes.push(`${tc.name}(${tc.arguments?.path || ''}): ${result.error}`);
309
+ } else if (['write_file', 'edit_file'].includes(tc.name) && tc.arguments?.path) {
310
+ const alvo = tc.arguments.path;
311
+ const mantidas = falhasPendentes.filter((f) => !f.includes(`(${alvo})`));
312
+ falhasPendentes.length = 0;
313
+ falhasPendentes.push(...mantidas);
314
+ }
305
315
 
306
316
  messages.push({ role: 'tool', tool_call_id: tc.id, content: summarizeForModel(result) });
307
317
  }
@@ -332,7 +342,12 @@ async function runAct(userPrompt, opts) {
332
342
  // Nada de arquivo mexido = nada para lembrar. E grava em `pastaAtual`, não em
333
343
  // `projectDir`: quando a pasta do projeto nasceu no meio do caminho, a memória
334
344
  // precisa ir para ela, não para a raiz onde a sessão começou.
335
- if (!subagent && projectName && temPasta) {
345
+ //
346
+ // `projectName` NÃO entra na condição: ele só rotula a memória no prompt
347
+ // (buildMemoryContext), e sessão sem projeto nomeado (ex.: primeiro pedido
348
+ // antes de /project) tem `projectName` undefined mesmo já tendo mexido em
349
+ // arquivo de verdade — a memória era descartada nesse caso, sempre.
350
+ if (!subagent && temPasta) {
336
351
  cfg.appendProjectMemory(pastaAtual, `**Pedido:** ${userPrompt.slice(0, 300)}\n**Resultado:** ${finalText.slice(0, 500)}`);
337
352
  }
338
353
 
package/lib/api.js CHANGED
@@ -90,7 +90,19 @@ function requestStream(serverUrl, endpoint, payload, onEvent, token) {
90
90
  if (!line.startsWith('data: ')) continue;
91
91
  let evt;
92
92
  try { evt = JSON.parse(line.slice(6)); } catch { continue; }
93
- onEvent(evt);
93
+ // onEvent roda dentro de um callback de socket, fora de
94
+ // qualquer try/catch de quem chamou requestStream. Uma
95
+ // exceção aqui não seria pega por ninguém — sem process.on
96
+ // ('uncaughtException') no CLI, isso derrubava o processo
97
+ // inteiro em silêncio (era o "cai fora" sem mensagem
98
+ // nenhuma). Agora vira rejeição normal da Promise.
99
+ try {
100
+ onEvent(evt);
101
+ } catch (e) {
102
+ settled = true;
103
+ reject(e);
104
+ return;
105
+ }
94
106
  if (evt.done) finish();
95
107
  }
96
108
  });
package/lib/catalogo.js CHANGED
@@ -91,6 +91,11 @@ const GRUPOS = [
91
91
  itens: [
92
92
  { tool: 'write_todos', escreve: false, faz: 'monta o plano e vai marcando o que terminou' },
93
93
  { tool: 'pesquisar_referencia', escreve: false, faz: 'lê docs e exemplos antes de escrever — sobe a qualidade' },
94
+ // Vai para memory-geral.md, FORA da pasta do projeto — por isso não
95
+ // é write_file/edit_file (a sandbox de resolveInProject bloqueia
96
+ // qualquer caminho fora do projeto, e é assim mesmo que tem que
97
+ // ser para os outros casos). Esta tool é a única porta pra lá.
98
+ { tool: 'remember_general', escreve: false, faz: 'grava algo sobre o usuário que vale para todo projeto (memory-geral.md)' },
94
99
  // Estas duas o loop do agente atende (lib/act.js), não o tools.execute.
95
100
  { tool: 'spawn_agent', escreve: false, faz: 'chama um subagente para uma parte da tarefa', noLoop: true },
96
101
  { tool: 'finish', escreve: false, faz: 'encerra dizendo o que fez', noLoop: true },
package/lib/config.js CHANGED
@@ -151,6 +151,18 @@ function appendProjectMemory(dir, entry) {
151
151
  } catch {}
152
152
  }
153
153
 
154
+ // A contraparte de appendProjectMemory, para o que vale em TODOS os projetos
155
+ // (nome do usuário, preferência, decisão de longo prazo). Existia a promessa
156
+ // no prompt — "escreve aqui o que descobrir sobre você" — mas nenhuma função
157
+ // gravava de fato: só ensureStructure() criava o template uma vez.
158
+ function appendGeneralMemory(entry) {
159
+ if (!entry) return;
160
+ try {
161
+ ensureStructure();
162
+ fs.appendFileSync(GENERAL_MEMORY, `\n### ${today()}\n${entry}\n`, 'utf-8');
163
+ } catch {}
164
+ }
165
+
154
166
  function loadConfig() {
155
167
  ensureStructure();
156
168
  let file = {};
@@ -198,5 +210,5 @@ module.exports = {
198
210
  CONFIG_DIR, CONFIG_FILE, HISTORY_FILE, ROOT_DIR, GENERAL_MEMORY,
199
211
  ensureStructure, slugify,
200
212
  createProjectDir, getProjectDir, listProjects, findSimilarProject,
201
- readGeneralMemory, readProjectMemory, appendProjectMemory,
213
+ readGeneralMemory, readProjectMemory, appendProjectMemory, appendGeneralMemory,
202
214
  };
package/lib/desktop.js CHANGED
@@ -19,7 +19,9 @@ const os = require('os');
19
19
  const PLATFORM = os.platform();
20
20
 
21
21
  function hasCmd(cmd) {
22
- try { execSync(`which ${cmd} || where ${cmd}`, { stdio: 'ignore' }); return true; } catch { return false; }
22
+ // cmd pode vir de texto do usuário (desktop_open_app com nome sem alias
23
+ // conhecido) — sem aspas aqui, um ";"/"&&" no meio vira comando extra.
24
+ try { execSync(`which ${sh1(cmd)} || where ${sh1(cmd)}`, { stdio: 'ignore' }); return true; } catch { return false; }
23
25
  }
24
26
 
25
27
  // Detectar ferramenta disponível
package/lib/modo.js CHANGED
@@ -37,6 +37,13 @@ const VERBOS = [
37
37
  ];
38
38
  const RE_VERBO = new RegExp(`(^|\\s)(${VERBOS.join('|')})\\b`, 'i');
39
39
 
40
+ // Pedido sem verbo de comando, só desejo: "quero um jogo da velha", "preciso
41
+ // de uma landing page". Muito comum em português — e sem isto caía direto no
42
+ // "sem pista de tarefa" porque não tem 'criar'/'fazer' escrito. A diferença
43
+ // para RE_CONCEITUAL é que aqui não vem pergunta ("como", "por que", "o que
44
+ // é") — só o pedido do que a pessoa quer que exista.
45
+ const RE_DESEJO = /\b(quero|queria|querendo|gostaria de|necessito de|precis[ao] de|precisava de)\b/i;
46
+
40
47
  // Coisas que só existem quando há trabalho de arquivo/máquina envolvido.
41
48
  const RE_ARTEFATO = /\.(js|ts|tsx|jsx|html|css|json|md|py|sh|yml|yaml|txt|csv|env)\b|[\w-]+\/[\w-]/i;
42
49
  const RE_FERRAMENTA = /\b(navegador|browser|desktop|terminal|shell|git|npm|node|deploy|servidor|localhost|porta \d+|screenshot|print da tela|mouse|teclado|janela|[áa]rea de trabalho|aplicativo|[íi]cone)\b/i;
@@ -65,6 +72,7 @@ function escolherModo(texto) {
65
72
 
66
73
  if (RE_ARTEFATO.test(t)) return { modo: 'primocode', motivo: 'cita arquivo ou caminho' };
67
74
  if (RE_VERBO.test(t)) return { modo: 'primocode', motivo: 'pede uma ação' };
75
+ if (RE_DESEJO.test(t)) return { modo: 'primocode', motivo: 'expressa o que quer que exista' };
68
76
  if (RE_FERRAMENTA.test(t)) return { modo: 'primocode', motivo: 'menciona ferramenta ou máquina' };
69
77
  if (RE_PASTA.test(t)) return { modo: 'primocode', motivo: 'fala de arquivo ou projeto' };
70
78
 
package/lib/referencia.js CHANGED
@@ -64,11 +64,15 @@ function viaProxy(alvo, { timeout }) {
64
64
  });
65
65
  }
66
66
 
67
- function pegar(url, { timeout = TIMEOUT_MS } = {}) {
67
+ function pegar(url, { timeout = TIMEOUT_MS, saltos = 0 } = {}) {
68
68
  return new Promise((resolve) => {
69
69
  let req;
70
70
  const alvo = (() => { try { return new URL(url); } catch { return null; } })();
71
71
  if (!alvo) return resolve(null);
72
+ // 5 saltos cobre qualquer doc real; sem teto, um redirect em loop
73
+ // (A→B→A→...) trava essa fonte — e a pesquisa inteira — para sempre.
74
+ if (saltos > 5) return resolve(null);
75
+ const cliente = alvo.protocol === 'http:' ? http : https;
72
76
 
73
77
  const opcoes = {
74
78
  headers: {
@@ -88,12 +92,12 @@ function pegar(url, { timeout = TIMEOUT_MS } = {}) {
88
92
  abrir.then((cfg) => {
89
93
  if (!cfg) return resolve(null);
90
94
  try {
91
- req = https.get(url, cfg, (res) => {
92
- // Redirecionamento: segue uma vez, o suficiente para docs.
95
+ req = cliente.get(url, cfg, (res) => {
96
+ // Redirecionamento: segue até o teto de saltos acima.
93
97
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
94
98
  res.resume();
95
99
  const destino = new URL(res.headers.location, url).href;
96
- return pegar(destino, { timeout }).then(resolve);
100
+ return pegar(destino, { timeout, saltos: saltos + 1 }).then(resolve);
97
101
  }
98
102
  if (res.statusCode !== 200) { res.resume(); return resolve(null); }
99
103
  let corpo = '';
package/lib/tools.js CHANGED
@@ -15,6 +15,7 @@ const browser = require('./browser');
15
15
  const desktop = require('./desktop');
16
16
  const referencia = require('./referencia');
17
17
  const studio = require('./studio');
18
+ const cfg = require('./config');
18
19
 
19
20
  const IGNORAR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.cache', 'vendor']);
20
21
  const MAX_ARQUIVOS_VARREDURA = 5000;
@@ -289,7 +290,7 @@ function multiEdit({ path: rawPath, edits }, projectDir) {
289
290
  if (ocorrencias > 1 && !replace_all) {
290
291
  return { ok: false, error: `edit ${i + 1}: trecho aparece ${ocorrencias}x — use replace_all ou inclua mais contexto para torná-lo único` };
291
292
  }
292
- conteudo = replace_all ? conteudo.split(old_string).join(new_string) : conteudo.replace(old_string, new_string);
293
+ conteudo = replace_all ? conteudo.split(old_string).join(new_string ?? '') : conteudo.replace(old_string, new_string ?? '');
293
294
  substituicoes++;
294
295
  }
295
296
 
@@ -399,6 +400,16 @@ async function despachar(name, args, ctx) {
399
400
  paginas: Math.max(1, Math.min(5, Number(args.paginas) || 3)),
400
401
  });
401
402
 
403
+ // memory-geral.md fica FORA da pasta do projeto — write_file/edit_file
404
+ // não alcançam por design (resolveInProject bloqueia ../ de propósito).
405
+ // Esta é a única porta para lá, sem reabrir a sandbox.
406
+ case 'remember_general': {
407
+ const entry = String(args.fato || args.entry || '').trim();
408
+ if (!entry) return { ok: false, error: 'Diga o que lembrar (fato).' };
409
+ cfg.appendGeneralMemory(entry);
410
+ return { ok: true, gravado: entry };
411
+ }
412
+
402
413
  // ── Navegador controlado (CDP) — o agente age numa página real ──
403
414
  case 'browser_open': {
404
415
  const alvo = /^https?:\/\//i.test(args.url || '')
@@ -422,7 +433,10 @@ async function despachar(name, args, ctx) {
422
433
  return texto == null ? { ok: false, error: 'nada na página (ou seletor não encontrado)' } : { ok: true, text: texto };
423
434
  }
424
435
  case 'browser_screenshot':
425
- return browser.screenshot(args.path ? resolveInProject(args.path, projectDir) : undefined);
436
+ // Sem path, cai num nome-padrão — mas SEMPRE dentro do projeto: sem
437
+ // isso o arquivo ia para o cwd real do processo, não para a pasta
438
+ // virtual que o CLI mostra ao usuário (o processo nunca faz chdir).
439
+ return browser.screenshot(resolveInProject(args.path || `screenshot-${Date.now()}.png`, projectDir));
426
440
  case 'browser_scroll':
427
441
  return browser.scroll(args.dy != null ? args.dy : 600);
428
442
  case 'browser_wait':
@@ -432,7 +446,9 @@ async function despachar(name, args, ctx) {
432
446
 
433
447
  // ── Desktop físico — mouse, teclado e tela do usuário ───────────
434
448
  case 'desktop_screenshot':
435
- return desktop.screenshot(args.path ? resolveInProject(args.path, projectDir) : undefined);
449
+ // Mesmo motivo do browser_screenshot: nome-padrão também precisa
450
+ // resolver dentro do projeto, não do cwd real do processo.
451
+ return desktop.screenshot(resolveInProject(args.path || `desktop-${Date.now()}.png`, projectDir));
436
452
  case 'desktop_click':
437
453
  return desktop.click(args.x, args.y, args.button || 'left');
438
454
  case 'desktop_move':
@@ -484,24 +500,6 @@ async function despachar(name, args, ctx) {
484
500
  }
485
501
  }
486
502
 
487
- function applyCodeBlocks(response) {
488
- const regex = /```[\w]*\n([\s\S]*?)```/g;
489
- const applied = [];
490
- let match;
491
- while ((match = regex.exec(response || '')) !== null) {
492
- const block = match[1];
493
- const nome = block.match(/\/\/\s*filename:\s*(.+)/);
494
- if (!nome) continue;
495
- const filename = nome[1].trim();
496
- const content = block.replace(/\/\/\s*filename:.+\n?/, '');
497
- const created = !fs.existsSync(filename);
498
- fs.mkdirSync(path.dirname(filename), { recursive: true });
499
- fs.writeFileSync(filename, content, 'utf-8');
500
- applied.push({ path: filename, created });
501
- }
502
- return applied;
503
- }
504
-
505
503
  async function uninstall() {
506
504
  const configDir = path.join(os.homedir(), '.primocode');
507
505
  console.log('Removendo PrimoCode…');
@@ -519,7 +517,7 @@ async function uninstall() {
519
517
  }
520
518
 
521
519
  module.exports = {
522
- fmtBytes, treeDir, applyCodeBlocks, runShell, openInBrowser, runInNewTerminal,
520
+ fmtBytes, treeDir, runShell, openInBrowser, runInNewTerminal,
523
521
  execute, uninstall, resolveInProject, globToRegex, globFiles, grepFiles, editFile,
524
522
  multiEdit, writeTodos, getTodos,
525
523
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.0.0",
3
+ "version": "8.0.2",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
package/studio/server.py CHANGED
@@ -9,6 +9,7 @@ Só biblioteca padrão do Python. Nada para instalar.
9
9
  """
10
10
 
11
11
  import base64
12
+ import functools
12
13
  import json
13
14
  import mimetypes
14
15
  import os
@@ -98,10 +99,17 @@ POR_SLUG = {f["slug"]: f for f in FERRAMENTAS_INFO}
98
99
 
99
100
  # -------------------------------------------------------------- utilidades --
100
101
 
101
- def slugify(texto):
102
+ def _normaliza(texto, tamanho):
103
+ """NFKD → ASCII → não-alfanumérico vira hífen → minúsculo → corta.
104
+ Compartilhada por slugify() e nome_seguro(), que faziam a mesma conta
105
+ com um corte de tamanho diferente cada uma."""
102
106
  texto = unicodedata.normalize("NFKD", texto or "").encode("ascii", "ignore").decode()
103
107
  texto = re.sub(r"[^a-zA-Z0-9]+", "-", texto).strip("-").lower()
104
- return (texto or "arquivo")[:40]
108
+ return (texto or "arquivo")[:tamanho]
109
+
110
+
111
+ def slugify(texto):
112
+ return _normaliza(texto, 40)
105
113
 
106
114
 
107
115
  def caminho(arq_id):
@@ -163,10 +171,7 @@ mimetypes.add_type("audio/mp4", ".m4a")
163
171
 
164
172
  def nome_seguro(nome):
165
173
  base, ext = os.path.splitext(nome or "arquivo")
166
- ext = ext.lower()
167
- base = unicodedata.normalize("NFKD", base).encode("ascii", "ignore").decode()
168
- base = re.sub(r"[^a-zA-Z0-9]+", "-", base).strip("-").lower()[:48] or "arquivo"
169
- return base, ext
174
+ return _normaliza(base, 48), ext.lower()
170
175
 
171
176
 
172
177
  def listar_midia():
@@ -218,16 +223,31 @@ def guardar_midia(dados):
218
223
 
219
224
 
220
225
  def criar(dados):
221
- ferramenta = (dados.get("ferramenta") or "prisma").lower()
226
+ # O cliente é um agente de IA falando JSON cru — um tipo errado aqui
227
+ # (titulo como lista, por exemplo) não pode virar AttributeError sem
228
+ # resposta HTTP nenhuma; melhor um 400 com o motivo.
229
+ ferramenta_bruta = dados.get("ferramenta")
230
+ if ferramenta_bruta is not None and not isinstance(ferramenta_bruta, str):
231
+ raise ValueError("ferramenta precisa ser texto")
232
+ ferramenta = (ferramenta_bruta or "prisma").lower()
222
233
  if ferramenta not in POR_SLUG:
223
234
  return None
224
- titulo = (dados.get("titulo") or "Sem título").strip()
235
+
236
+ titulo_bruto = dados.get("titulo")
237
+ if titulo_bruto is not None and not isinstance(titulo_bruto, str):
238
+ raise ValueError("titulo precisa ser texto")
239
+ titulo = (titulo_bruto or "Sem título").strip()
240
+
241
+ doc = dados.get("doc")
242
+ if doc is not None and not isinstance(doc, dict):
243
+ raise ValueError("doc precisa ser um objeto")
244
+
225
245
  arq_id = ferramenta + "-" + slugify(titulo) + "-" + uuid.uuid4().hex[:5]
226
246
  return gravar({
227
247
  "id": arq_id,
228
248
  "ferramenta": ferramenta,
229
249
  "titulo": titulo,
230
- "doc": dados.get("doc") or exemplo(ferramenta),
250
+ "doc": doc or exemplo(ferramenta),
231
251
  })
232
252
 
233
253
 
@@ -311,6 +331,23 @@ def manual():
311
331
 
312
332
  # ----------------------------------------------------------------- handler --
313
333
 
334
+ def seguro(metodo):
335
+ """Qualquer exceção não prevista na rota vira 500 em JSON, não conexão
336
+ fechada sem resposta nenhuma — era o comportamento padrão do
337
+ BaseHTTPRequestHandler para exceção não tratada num do_*, e o cliente (o
338
+ agente de IA) não tem como interpretar uma conexão que só morre."""
339
+ @functools.wraps(metodo)
340
+ def envolto(self, *a, **kw):
341
+ try:
342
+ return metodo(self, *a, **kw)
343
+ except Exception as e:
344
+ try:
345
+ self.js({"erro": "erro interno: %s" % e}, 500)
346
+ except Exception:
347
+ pass
348
+ return envolto
349
+
350
+
314
351
  class Handler(SimpleHTTPRequestHandler):
315
352
  server_version = "PrimoCode/1.0"
316
353
 
@@ -360,6 +397,7 @@ class Handler(SimpleHTTPRequestHandler):
360
397
 
361
398
  # -------------------------------------------------------------- rotas --
362
399
 
400
+ @seguro
363
401
  def do_GET(self):
364
402
  p = urlparse(self.path).path
365
403
 
@@ -426,10 +464,17 @@ class Handler(SimpleHTTPRequestHandler):
426
464
  if faixa:
427
465
  m = re.match(r"bytes=(\d*)-(\d*)", faixa)
428
466
  if m:
429
- if m.group(1):
430
- inicio = int(m.group(1))
431
- if m.group(2):
432
- fim = min(int(m.group(2)), tamanho - 1)
467
+ if not m.group(1) and m.group(2):
468
+ # "bytes=-500" é sufixo HTTP válido: os ÚLTIMOS 500 bytes,
469
+ # não os primeiros 500 — sem este caso à parte, group(2)
470
+ # virava fim absoluto e o pedido servia o pedaço errado.
471
+ inicio = max(0, tamanho - int(m.group(2)))
472
+ fim = tamanho - 1
473
+ else:
474
+ if m.group(1):
475
+ inicio = int(m.group(1))
476
+ if m.group(2):
477
+ fim = min(int(m.group(2)), tamanho - 1)
433
478
  if inicio > fim or inicio >= tamanho:
434
479
  self.send_response(416)
435
480
  self.send_header("Content-Range", "bytes */%d" % tamanho)
@@ -455,13 +500,18 @@ class Handler(SimpleHTTPRequestHandler):
455
500
  self.wfile.write(pedaco)
456
501
  restante -= len(pedaco)
457
502
 
503
+ @seguro
458
504
  def do_POST(self):
459
- if urlparse(self.path).path == "/api/midia":
505
+ rota = urlparse(self.path).path
506
+ if rota == "/api/midia":
460
507
  corpo, status = guardar_midia(self.corpo())
461
508
  return self.js(corpo, status)
462
509
 
463
- if urlparse(self.path).path == "/api/arquivos":
464
- a = criar(self.corpo())
510
+ if rota == "/api/arquivos":
511
+ try:
512
+ a = criar(self.corpo())
513
+ except ValueError as e:
514
+ return self.js({"erro": str(e)}, 400)
465
515
  if not a:
466
516
  return self.js({"erro": "ferramenta inválida",
467
517
  "validas": list(POR_SLUG)}, 400)
@@ -475,6 +525,7 @@ class Handler(SimpleHTTPRequestHandler):
475
525
  }, 201)
476
526
  return self.js({"erro": "não encontrado"}, 404)
477
527
 
528
+ @seguro
478
529
  def do_PUT(self):
479
530
  m = re.fullmatch(r"/api/arquivos/([^/]+)", urlparse(self.path).path)
480
531
  if not m:
@@ -483,13 +534,21 @@ class Handler(SimpleHTTPRequestHandler):
483
534
  if not a:
484
535
  return self.js({"erro": "não encontrado"}, 404)
485
536
  dados = self.corpo()
537
+ # Mesma validação de criar(): sem isso, um PUT com titulo/doc do tipo
538
+ # errado grava sujeira que só quebra depois, no consumo (export etc).
539
+ if "titulo" in dados and dados["titulo"] is not None and not isinstance(dados["titulo"], str):
540
+ return self.js({"erro": "titulo precisa ser texto"}, 400)
541
+ if "doc" in dados and dados["doc"] is not None and not isinstance(dados["doc"], dict):
542
+ return self.js({"erro": "doc precisa ser um objeto"}, 400)
486
543
  for chave in ("titulo", "doc"):
487
544
  if chave in dados:
488
545
  a[chave] = dados[chave]
489
546
  return self.js(gravar(a))
490
547
 
548
+ @seguro
491
549
  def do_DELETE(self):
492
- m = re.fullmatch(r"/api/midia/([^/]+)", urlparse(self.path).path)
550
+ rota = urlparse(self.path).path
551
+ m = re.fullmatch(r"/api/midia/([^/]+)", rota)
493
552
  if m:
494
553
  nome = unquote(m.group(1))
495
554
  alvo = os.path.join(MIDIA, nome)
@@ -498,7 +557,7 @@ class Handler(SimpleHTTPRequestHandler):
498
557
  return self.js({"ok": True})
499
558
  return self.js({"erro": "não encontrado"}, 404)
500
559
 
501
- m = re.fullmatch(r"/api/arquivos/([^/]+)", urlparse(self.path).path)
560
+ m = re.fullmatch(r"/api/arquivos/([^/]+)", rota)
502
561
  c = caminho(m.group(1)) if m else None
503
562
  if c and os.path.exists(c):
504
563
  os.remove(c)
@@ -535,7 +594,7 @@ def csv_celula(valor):
535
594
  if valor == int(valor):
536
595
  valor = int(valor)
537
596
  s = "" if valor is None else str(valor)
538
- return '"%s"' % s.replace('"', '""') if re.search(r'[",\n]', s) else s
597
+ return '"%s"' % s.replace('"', '""') if re.search(r'[",\n\r]', s) else s
539
598
 
540
599
 
541
600
  # ------------------------------------------------- fórmulas (espelha o JS) --
@@ -602,7 +661,11 @@ def calcular_aba(aba):
602
661
  linhas = [list(l) for l in aba.get("linhas", [])]
603
662
 
604
663
  def celula(c, l, prof):
605
- if prof > 24 or l >= len(linhas) or c >= len(linhas[l]):
664
+ # l/c negativos (referência tipo "A0", que vira índice -1) não podem
665
+ # passar: índice negativo do Python "funciona" contando do fim da
666
+ # lista, e a última linha da planilha aparecia como resposta de uma
667
+ # referência inválida em vez de virar 0.
668
+ if prof > 24 or l < 0 or c < 0 or l >= len(linhas) or c >= len(linhas[l]):
606
669
  return 0
607
670
  bruto = linhas[l][c]
608
671
  if isinstance(bruto, str) and bruto.startswith("="):
@@ -642,7 +705,14 @@ def calcular_aba(aba):
642
705
  elif parte:
643
706
  # qualquer outro argumento é uma expressão: resolve antes
644
707
  vals.append(_num(avaliar(parte, prof)))
645
- return repr(f(vals))
708
+ try:
709
+ # MAIOR/MENOR estouram IndexError com k maior que o intervalo;
710
+ # POT estoura OverflowError com base/expoente grandes demais.
711
+ # Uma fórmula ruim vira "#ERRO" NESSA célula, não derruba a
712
+ # exportação inteira (mesma regra do eval() logo abaixo).
713
+ return repr(f(vals))
714
+ except Exception:
715
+ return "#ERRO"
646
716
 
647
717
  # repete até esgotar: resolve de dentro para fora em fórmula aninhada
648
718
  e, voltas = expr, 0