project-foundry 1.0.9 → 1.1.1

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/foundry.js CHANGED
@@ -6,7 +6,7 @@ import * as p from '@clack/prompts';
6
6
  import pc from 'picocolors';
7
7
  import { hasConfig, writeConfig, readConfig, getDefaultStorageDir } from '../src/config.js';
8
8
  import { registerNodeWithCloud } from '../src/api-client.js';
9
- import { installAutostartService, isServerHealthy, startDaemonBackground } from '../src/service-manager.js';
9
+ import { installAutostartService, isServerHealthy, startDaemonBackground, stopRunningDaemon } from '../src/service-manager.js';
10
10
  import { runInteractiveTui } from '../src/tui.js';
11
11
  import { startDaemon } from '../src/daemon.js';
12
12
 
@@ -31,6 +31,7 @@ program
31
31
  .description('Inicia o servidor local e o túnel com logs visíveis no terminal')
32
32
  .action(() => {
33
33
  console.log(pc.bold(pc.cyan('Iniciando Project Foundry Local Server...')));
34
+ stopRunningDaemon();
34
35
  startDaemon();
35
36
  });
36
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-foundry",
3
- "version": "1.0.9",
3
+ "version": "1.1.1",
4
4
  "description": "Project Foundry CLI & Local Node Daemon Manager",
5
5
  "bin": {
6
6
  "project-foundry": "bin/foundry.js",
package/src/daemon.js CHANGED
@@ -90,7 +90,27 @@ export function startDaemon() {
90
90
  },
91
91
  }, (cloudRes) => {
92
92
  res.writeHead(cloudRes.statusCode, cloudRes.headers);
93
- cloudRes.pipe(res);
93
+ let responseBody = '';
94
+ cloudRes.on('data', (chunk) => {
95
+ res.write(chunk);
96
+ responseBody += chunk.toString();
97
+ });
98
+ cloudRes.on('end', () => {
99
+ res.end();
100
+ try {
101
+ const parsed = JSON.parse(responseBody);
102
+ const originalName = parsed?.files?.local?.name;
103
+ const fileId = parsed?.flowSystem3D?.fileId;
104
+ if (originalName) {
105
+ fs.copyFileSync(tempFilePath, path.join(storageDir, originalName));
106
+ }
107
+ if (fileId) {
108
+ const ext = (originalName && originalName.endsWith('.3mf')) ? '.3mf' : '.gcode';
109
+ fs.copyFileSync(tempFilePath, path.join(storageDir, `${fileId}${ext}`));
110
+ }
111
+ fs.unlinkSync(tempFilePath);
112
+ } catch {}
113
+ });
94
114
  });
95
115
 
96
116
  cloudReq.on('error', (err) => {
@@ -147,6 +167,14 @@ export function startDaemon() {
147
167
  }
148
168
  });
149
169
 
170
+ server.on('error', (err) => {
171
+ if (err.code === 'EADDRINUSE') {
172
+ console.warn(`[Foundry Daemon] Aviso: Porta ${port} já está em uso por outro processo. O túnel reverso segue operando normalmente.`);
173
+ return;
174
+ }
175
+ console.error(`[Foundry Daemon] Erro no servidor HTTP local: ${err.message}`);
176
+ });
177
+
150
178
  server.listen(port, '0.0.0.0', () => {
151
179
  console.log(`[Foundry Daemon] Servidor local escutando em http://0.0.0.0:${port}`);
152
180
  console.log(`[Foundry Daemon] Diretório de arquivos: ${config.storagePath}`);
@@ -1,8 +1,82 @@
1
1
  import mqtt from 'mqtt';
2
2
  import { connect as connectTls } from 'node:tls';
3
3
  import { Client as FtpClient } from 'basic-ftp';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import http from 'node:http';
7
+ import https from 'node:https';
4
8
  import { readConfig } from './config.js';
5
9
 
10
+ function downloadFileFromCloud({ serverUrl, fileId, pat }) {
11
+ return new Promise((resolve, reject) => {
12
+ const rawUrl = `${serverUrl}/api/v1/files/${fileId}/download`;
13
+ let parsed;
14
+ try {
15
+ parsed = new URL(rawUrl);
16
+ } catch {
17
+ return reject(new Error(`URL inválida para download: ${rawUrl}`));
18
+ }
19
+ const isHttps = parsed.protocol === 'https:';
20
+ const transport = isHttps ? https : http;
21
+
22
+ const req = transport.request(parsed, {
23
+ method: 'GET',
24
+ family: 4,
25
+ rejectUnauthorized: false,
26
+ headers: pat ? { 'Authorization': `Bearer ${pat}` } : {},
27
+ timeout: 60000,
28
+ }, (res) => {
29
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
30
+ return resolve(downloadFileFromCloud({ serverUrl: res.headers.location.replace(/\/api\/v1\/files\/.*$/, ''), fileId, pat }));
31
+ }
32
+ if (res.statusCode !== 200) {
33
+ return reject(new Error(`Nuvem retornou HTTP ${res.statusCode} ao baixar arquivo`));
34
+ }
35
+ const chunks = [];
36
+ res.on('data', chunk => chunks.push(chunk));
37
+ res.on('end', () => resolve(Buffer.concat(chunks)));
38
+ });
39
+
40
+ req.on('error', (err) => {
41
+ // Fallback: se DNS falhar para foundry.projectcore.site, tentar direto pelo IP da VPS
42
+ if (parsed.hostname === 'foundry.projectcore.site' && isHttps) {
43
+ const ipReq = https.request({
44
+ hostname: '164.152.35.52',
45
+ port: 443,
46
+ path: parsed.pathname + parsed.search,
47
+ method: 'GET',
48
+ family: 4,
49
+ rejectUnauthorized: false,
50
+ servername: 'foundry.projectcore.site',
51
+ headers: {
52
+ 'Host': 'foundry.projectcore.site',
53
+ ...(pat ? { 'Authorization': `Bearer ${pat}` } : {}),
54
+ },
55
+ timeout: 60000,
56
+ }, (ipRes) => {
57
+ if (ipRes.statusCode !== 200) {
58
+ return reject(new Error(`Nuvem (via IP) retornou HTTP ${ipRes.statusCode} ao baixar arquivo`));
59
+ }
60
+ const chunks = [];
61
+ ipRes.on('data', chunk => chunks.push(chunk));
62
+ ipRes.on('end', () => resolve(Buffer.concat(chunks)));
63
+ });
64
+ ipReq.on('error', (ipErr) => reject(new Error(`Falha ao baixar arquivo da nuvem (DNS e IP): ${ipErr.message}`)));
65
+ ipReq.on('timeout', () => { ipReq.destroy(); reject(new Error('Timeout de 60s ao baixar arquivo via IP')); });
66
+ ipReq.end();
67
+ return;
68
+ }
69
+ reject(new Error(`Falha ao baixar arquivo da nuvem: ${err.message}`));
70
+ });
71
+
72
+ req.on('timeout', () => {
73
+ req.destroy();
74
+ reject(new Error('Timeout de 60s ao baixar arquivo da nuvem'));
75
+ });
76
+ req.end();
77
+ });
78
+ }
79
+
6
80
  // Cache em memória das últimas telemetrias da Bambu (serialNumber -> { timestamp, print })
7
81
  const latestBambuReports = new Map();
8
82
 
@@ -558,34 +632,102 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
558
632
  // =====================================================
559
633
  if (action === 'job/start') {
560
634
  const cfg = readConfig();
561
- const serverUrl = (cfg?.serverUrl || '').replace(/\/$/, '');
635
+ const serverUrl = (cfg?.serverUrl || 'https://foundry.projectcore.site').replace(/\/$/, '');
562
636
  const pat = cfg?.apiKey;
637
+ const storageDir = cfg?.storagePath || path.join(process.cwd(), 'storage');
638
+
639
+ const originalName = payload?.originalName || payload?.filename || 'print.gcode';
640
+ const remoteName = payload?.remoteName || originalName;
641
+ const fileId = payload?.fileId;
642
+
643
+ let fileBuffer = null;
563
644
 
564
- if (!payload?.fileId) {
565
- throw new Error('ID do arquivo não fornecido para início do trabalho');
645
+ // A. Buffer recebido inline no WebSocket (Base64)
646
+ if (payload?.fileBase64) {
647
+ try {
648
+ fileBuffer = Buffer.from(payload.fileBase64, 'base64');
649
+ try {
650
+ if (!fs.existsSync(storageDir)) fs.mkdirSync(storageDir, { recursive: true });
651
+ fs.writeFileSync(path.join(storageDir, remoteName), fileBuffer);
652
+ } catch {}
653
+ } catch {}
566
654
  }
567
655
 
568
- // 1. Baixa o arquivo da nuvem usando o PAT
569
- const downloadUrl = `${serverUrl}/api/v1/files/${payload.fileId}/download`;
570
- let fileRes;
571
- try {
572
- fileRes = await fetch(downloadUrl, {
573
- headers: { 'Authorization': `Bearer ${pat}` },
574
- signal: AbortSignal.timeout(60000),
575
- });
576
- } catch (err) {
577
- throw new Error(`Falha ao baixar arquivo da nuvem para o nó local: ${err.message}`);
656
+ // B. Se não veio inline, buscar no HD local do Edge Node
657
+ if (!fileBuffer && fs.existsSync(storageDir)) {
658
+ const candidates = [
659
+ path.join(storageDir, remoteName),
660
+ path.join(storageDir, originalName),
661
+ fileId ? path.join(storageDir, `${fileId}.gcode`) : null,
662
+ fileId ? path.join(storageDir, `${fileId}.3mf`) : null,
663
+ ].filter(Boolean);
664
+
665
+ for (const cand of candidates) {
666
+ if (fs.existsSync(cand)) {
667
+ try {
668
+ fileBuffer = fs.readFileSync(cand);
669
+ break;
670
+ } catch {}
671
+ }
672
+ }
578
673
  }
579
674
 
580
- if (!fileRes.ok) {
581
- throw new Error(`Nuvem retornou HTTP ${fileRes.status} ao baixar arquivo ${payload.fileId}`);
675
+ // C. Se for Klipper e o arquivo já estiver na máquina, iniciar imediatamente
676
+ if (!fileBuffer && type === 'klipper') {
677
+ try {
678
+ const listRes = await fetch(`${baseUrl}/server/files/list?root=gcodes`, {
679
+ headers,
680
+ signal: AbortSignal.timeout(6000),
681
+ });
682
+ if (listRes.ok) {
683
+ const filesData = await listRes.json();
684
+ const items = Array.isArray(filesData?.result) ? filesData.result : [];
685
+ const matched = items.find(item =>
686
+ item.filename === remoteName ||
687
+ item.filename === originalName ||
688
+ item.filename === path.basename(remoteName)
689
+ );
690
+ if (matched) {
691
+ const startRes = await fetch(`${baseUrl}/printer/print/start?filename=${encodeURIComponent(matched.filename)}`, {
692
+ method: 'POST',
693
+ headers,
694
+ signal: AbortSignal.timeout(10000),
695
+ });
696
+ if (startRes.ok) {
697
+ return {
698
+ online: true,
699
+ status: 'PRINTING',
700
+ message: `Impressão iniciada via Klipper (${matched.filename})`,
701
+ filename: matched.filename,
702
+ };
703
+ }
704
+ }
705
+ }
706
+ } catch {}
582
707
  }
583
708
 
584
- const fileBuffer = Buffer.from(await fileRes.arrayBuffer());
709
+ // D. Se ainda não tiver o arquivo localmente, baixar da nuvem com resiliência total
710
+ if (!fileBuffer && fileId) {
711
+ try {
712
+ fileBuffer = await downloadFileFromCloud({ serverUrl, fileId, pat });
713
+ if (fileBuffer) {
714
+ try {
715
+ if (!fs.existsSync(storageDir)) fs.mkdirSync(storageDir, { recursive: true });
716
+ fs.writeFileSync(path.join(storageDir, remoteName), fileBuffer);
717
+ } catch {}
718
+ }
719
+ } catch (err) {
720
+ if (type !== 'bambu') {
721
+ throw err;
722
+ }
723
+ }
724
+ }
585
725
 
586
- // KLIPPER / MOONRAKER (ex: K1 Max)
726
+ // KLIPPER / MOONRAKER (K1, K1 Max, Ender 3, Voron, etc.)
587
727
  if (type === 'klipper') {
588
- const remoteName = payload.remoteName || payload.originalName || 'print.gcode';
728
+ if (!fileBuffer) {
729
+ throw new Error(`Arquivo "${originalName}" não encontrado localmente nem disponível para download`);
730
+ }
589
731
  const form = new FormData();
590
732
  form.append('file', new Blob([fileBuffer], { type: 'text/x-gcode' }), remoteName);
591
733
  form.append('root', 'gcodes');
@@ -600,7 +742,7 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
600
742
  signal: AbortSignal.timeout(120000),
601
743
  });
602
744
  } catch (err) {
603
- throw new Error(`Não foi possível enviar o arquivo para a K1 Max em ${baseUrl}: ${err.message}`);
745
+ throw new Error(`Não foi possível enviar o arquivo para Klipper em ${baseUrl}: ${err.message}`);
604
746
  }
605
747
 
606
748
  if (!uploadRes.ok) {
@@ -616,36 +758,37 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
616
758
  };
617
759
  }
618
760
 
619
- // BAMBU LAB (P1S, etc.)
761
+ // BAMBU LAB (P1S, P1P, X1 Carbon, X1E, A1, A1 mini)
620
762
  if (type === 'bambu') {
621
763
  const host = connectionConfig.host;
622
764
  const port = connectionConfig.ftpPort || 990;
623
765
  const accessCode = connectionConfig.accessCode;
624
766
  const serialNumber = connectionConfig.serialNumber;
625
- const remoteName = payload.remoteName || payload.originalName || 'print.gcode.3mf';
626
767
 
627
768
  if (!host || !accessCode || !serialNumber) {
628
769
  throw new Error('Configuração da Bambu incompleta para envio');
629
770
  }
630
771
 
631
- // Envia via FTPS
632
- const ftpClient = new FtpClient(60000);
633
- try {
634
- await ftpClient.access({
635
- host,
636
- port,
637
- user: 'bblp',
638
- password: accessCode,
639
- secure: 'implicit',
640
- secureOptions: { rejectUnauthorized: false },
641
- });
642
- await ftpClient.ensureDir('/');
643
- const { Readable } = await import('node:stream');
644
- await ftpClient.uploadFrom(Readable.from(fileBuffer), remoteName);
645
- } catch (err) {
646
- throw new Error(`Falha no FTPS local da Bambu: ${err.message}`);
647
- } finally {
648
- ftpClient.close();
772
+ // Se temos o buffer, tenta enviar via FTPS
773
+ if (fileBuffer) {
774
+ const ftpClient = new FtpClient(60000);
775
+ try {
776
+ await ftpClient.access({
777
+ host,
778
+ port,
779
+ user: 'bblp',
780
+ password: accessCode,
781
+ secure: 'implicit',
782
+ secureOptions: { rejectUnauthorized: false },
783
+ });
784
+ await ftpClient.ensureDir('/');
785
+ const { Readable } = await import('node:stream');
786
+ await ftpClient.uploadFrom(Readable.from(fileBuffer), remoteName);
787
+ } catch (err) {
788
+ // Se falhar FTPS, prossegue com MQTT caso o arquivo já exista no cartão
789
+ } finally {
790
+ ftpClient.close();
791
+ }
649
792
  }
650
793
 
651
794
  // Dispara comando MQTT
@@ -712,6 +855,17 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
712
855
  });
713
856
  }
714
857
 
858
+ // OCTOPRINT / PRUSALINK / OUTROS
859
+ if (type === 'octoprint') {
860
+ if (!fileBuffer) throw new Error('Arquivo não disponível para envio ao OctoPrint');
861
+ const form = new FormData();
862
+ form.append('file', new Blob([fileBuffer], { type: 'text/x-gcode' }), remoteName);
863
+ form.append('select', 'true');
864
+ form.append('print', 'true');
865
+ const res = await fetch(`${baseUrl}/api/files/local`, { method: 'POST', headers, body: form });
866
+ return { online: true, status: 'PRINTING', message: 'Iniciado via OctoPrint', filename: remoteName };
867
+ }
868
+
715
869
  throw new Error(`Protocolo "${type}" não suporta envio automático de arquivos`);
716
870
  }
717
871
 
@@ -32,6 +32,9 @@ export function stopRunningDaemon() {
32
32
  try {
33
33
  execSync("pkill -f 'foundry.*daemon' || true", { stdio: 'ignore' });
34
34
  } catch {}
35
+ try {
36
+ execSync("fuser -k 3010/tcp || true", { stdio: 'ignore' });
37
+ } catch {}
35
38
  }
36
39
  }
37
40