project-foundry 0.0.0-stage → 1.0.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/README.md CHANGED
@@ -1,3 +1,41 @@
1
- # Temporary Holding Version
1
+ # Project Foundry CLI & Edge Node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Gerenciador local e agente de farm para o [Project Foundry](https://foundry.projectcore.site).
4
+
5
+ Permite conectar qualquer máquina local (Orange Pi, Raspberry Pi, Mini PC ou computador Windows) posicionada na rede das impressoras 3D ao painel de controle na nuvem com um único comando.
6
+
7
+ ## Instalação e Pareamento
8
+
9
+ No painel do Project Foundry (Configurações → Servidores Locais), clique em **Adicionar Servidor** para gerar seu token de pareamento. Em seguida, execute no terminal da sua máquina local:
10
+
11
+ ```bash
12
+ npx project-foundry setup --token <seu-token>
13
+ ```
14
+
15
+ Para direcionar os arquivos pesados (G-codes, 3MFs e timelapses) diretamente para um HD externo conectado:
16
+
17
+ ```bash
18
+ npx project-foundry setup --token <seu-token> --storage /mnt/hd1tb/storage
19
+ ```
20
+
21
+ ## Recursos
22
+
23
+ - **Túnel WebSocket Reverso**: Conexão segura de saída para a nuvem sem abrir portas no roteador da empresa.
24
+ - **Suporte a HD Externo**: Salva arquivos 3D e timelapses no disco que você escolher.
25
+ - **Inicialização Automática**: Cria serviço do sistema (`systemd` no Linux ou Tarefa Agendada no Windows) para reiniciar automaticamente após quedas de energia.
26
+ - **Terminal UI (TUI)**: Menu interativo com opção de rodar em segundo plano (`Hide to Tray`) com zero risco de desligamento acidental.
27
+ - **Ponte OrcaSlicer e Bambu Studio**: Recepção de fatiamentos direto na LAN em alta velocidade.
28
+
29
+ ## Uso
30
+
31
+ Para reabrir o menu de controle no terminal a qualquer momento:
32
+
33
+ ```bash
34
+ npx project-foundry
35
+ ```
36
+
37
+ Ou se instalado globalmente (`npm install -g project-foundry`):
38
+
39
+ ```bash
40
+ foundry
41
+ ```
package/bin/foundry.js ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'node:path';
4
+ import { Command } from 'commander';
5
+ import * as p from '@clack/prompts';
6
+ import pc from 'picocolors';
7
+ import { hasConfig, writeConfig, readConfig, getDefaultStorageDir } from '../src/config.js';
8
+ import { registerNodeWithCloud } from '../src/api-client.js';
9
+ import { installAutostartService, isServerHealthy, startDaemonBackground } from '../src/service-manager.js';
10
+ import { runInteractiveTui } from '../src/tui.js';
11
+ import { startDaemon } from '../src/daemon.js';
12
+
13
+ const program = new Command();
14
+
15
+ program
16
+ .name('foundry')
17
+ .description('Project Foundry CLI & Edge Node Manager')
18
+ .version('1.0.0');
19
+
20
+ // Comando: daemon (segundo plano)
21
+ program
22
+ .command('daemon')
23
+ .description('Inicia o servidor e o túnel reverso em segundo plano')
24
+ .action(() => {
25
+ startDaemon();
26
+ });
27
+
28
+ // Comando: setup
29
+ program
30
+ .command('setup')
31
+ .description('Configura e pareia o servidor local com a conta do Project Foundry')
32
+ .requiredOption('-t, --token <token>', 'Token de pareamento descartável gerado na nuvem')
33
+ .option('-s, --server <url>', 'URL do servidor Cloud', 'https://foundry.projectcore.site')
34
+ .option('-n, --name <name>', 'Nome descritivo deste servidor')
35
+ .option('--storage <path>', 'Caminho do diretório de armazenamento (ex: HD externo)')
36
+ .action(async (options) => {
37
+ p.intro(pc.bold(pc.cyan('Configuração do Servidor Local Project Foundry')));
38
+
39
+ const s = p.spinner();
40
+ s.start('Conectando à nuvem e validando token de pareamento...');
41
+
42
+ try {
43
+ const regData = await registerNodeWithCloud({
44
+ serverUrl: options.server,
45
+ token: options.token,
46
+ name: options.name,
47
+ });
48
+
49
+ s.stop(pc.green('Pareamento realizado com sucesso!'));
50
+
51
+ // Configuração de Armazenamento (HD Externo ou Padrão)
52
+ let storagePath = options.storage;
53
+ if (!storagePath) {
54
+ const storageChoice = await p.select({
55
+ message: 'Onde deseja salvar os arquivos 3D (3MF/G-code) e timelapses?',
56
+ options: [
57
+ {
58
+ value: 'default',
59
+ label: 'Pasta padrão do sistema',
60
+ hint: `(${getDefaultStorageDir()})`,
61
+ },
62
+ {
63
+ value: 'custom',
64
+ label: 'HD Externo / Diretório Personalizado',
65
+ hint: process.platform === 'win32' ? 'Ex: D:\\Storage' : 'Ex: /mnt/hd1tb/storage',
66
+ },
67
+ ],
68
+ });
69
+
70
+ if (storageChoice === 'custom') {
71
+ const customInput = await p.text({
72
+ message: 'Digite o caminho completo da pasta no seu HD/Disco:',
73
+ placeholder: process.platform === 'win32' ? 'D:\\Storage' : '/mnt/hd1tb/storage',
74
+ validate: (val) => {
75
+ if (!val || !val.trim()) return 'O caminho é obrigatório';
76
+ },
77
+ });
78
+ if (!p.isCancel(customInput)) {
79
+ storagePath = customInput.trim();
80
+ }
81
+ }
82
+ }
83
+
84
+ const finalStorage = storagePath ? path.resolve(storagePath) : getDefaultStorageDir();
85
+
86
+ // Salva configuração local
87
+ writeConfig({
88
+ nodeId: regData.nodeId,
89
+ nodeName: regData.nodeName,
90
+ organizationId: regData.organizationId,
91
+ apiKey: regData.apiKey,
92
+ serverUrl: regData.serverUrl,
93
+ storagePath: finalStorage,
94
+ localPort: 3010,
95
+ configuredAt: new Date().toISOString(),
96
+ });
97
+
98
+ p.log.success(`Servidor vinculado à organização: ${pc.bold(regData.organizationId)}`);
99
+ p.log.success(`Armazenamento configurado em: ${pc.bold(finalStorage)}`);
100
+
101
+ // Configuração de serviço
102
+ const installService = await p.confirm({
103
+ message: 'Deseja configurar inicialização automática com o sistema operacional?',
104
+ initialValue: true,
105
+ });
106
+
107
+ if (installService && !p.isCancel(installService)) {
108
+ s.start('Registrando serviço de autostart no sistema operacional...');
109
+ const serviceRes = installAutostartService(process.argv[1]);
110
+ if (serviceRes.success) {
111
+ s.stop(pc.green(`Serviço registrado (${serviceRes.type}): ${serviceRes.name}`));
112
+ } else {
113
+ s.stop(pc.yellow(`Não foi possível registrar serviço automático: ${serviceRes.error}`));
114
+ }
115
+ }
116
+
117
+ // Garante que o daemon suba em segundo plano se ainda não estiver rodando
118
+ const isUp = await isServerHealthy(3010);
119
+ if (!isUp) {
120
+ startDaemonBackground(process.argv[1]);
121
+ await new Promise((r) => setTimeout(r, 1500));
122
+ }
123
+
124
+ p.outro(pc.green('Configuração concluída! Abrindo interface interativa...'));
125
+ await runInteractiveTui();
126
+ } catch (err) {
127
+ s.stop(pc.red('Erro durante o pareamento'));
128
+ p.log.error(err.message || 'Falha ao registrar nó');
129
+ if (err.cause) {
130
+ p.log.error(pc.dim(`Detalhes técnicos: ${err.cause?.message || err.cause?.code || err.cause}`));
131
+ }
132
+ process.exit(1);
133
+ }
134
+ });
135
+
136
+ // Comando padrão: se nenhum subcomando for passado, abre o TUI
137
+ program
138
+ .action(async () => {
139
+ if (!hasConfig()) {
140
+ p.intro(pc.bold(pc.yellow('Project Foundry - Primeiro Uso')));
141
+ p.note(
142
+ 'Este servidor local ainda não foi vinculado a uma conta.\n' +
143
+ 'Gere um token de pareamento no painel web (Configurações -> Servidores Locais) e execute:\n\n' +
144
+ pc.cyan(' npx project-foundry setup --token <seu-token>'),
145
+ 'Como Vincular'
146
+ );
147
+ process.exit(0);
148
+ }
149
+
150
+ await runInteractiveTui();
151
+ });
152
+
153
+ program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "project-foundry",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Project Foundry CLI & Local Node Daemon Manager",
5
+ "bin": {
6
+ "project-foundry": "bin/foundry.js",
7
+ "foundry": "bin/foundry.js"
8
+ },
9
+ "type": "module",
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "start": "node ./bin/foundry.js"
17
+ },
18
+ "dependencies": {
19
+ "@clack/prompts": "^0.9.1",
20
+ "commander": "^12.1.0",
21
+ "open": "^10.1.0",
22
+ "picocolors": "^1.1.1",
23
+ "ws": "^8.22.0"
24
+ }
25
+ }
@@ -0,0 +1,76 @@
1
+ import https from 'node:https';
2
+ import http from 'node:http';
3
+ import os from 'node:os';
4
+
5
+ async function postJson(targetUrl, payload) {
6
+ const parsed = new URL(targetUrl);
7
+ const isHttps = parsed.protocol === 'https:';
8
+ const transport = isHttps ? https : http;
9
+ const postData = JSON.stringify(payload);
10
+
11
+ return new Promise((resolve, reject) => {
12
+ const req = transport.request(parsed, {
13
+ method: 'POST',
14
+ family: 4,
15
+ rejectUnauthorized: false,
16
+ headers: {
17
+ 'Content-Type': 'application/json',
18
+ 'Content-Length': Buffer.byteLength(postData),
19
+ 'User-Agent': 'Project-Foundry-CLI/1.0',
20
+ },
21
+ }, (res) => {
22
+ let body = '';
23
+ res.on('data', (chunk) => { body += chunk; });
24
+ res.on('end', () => {
25
+ try {
26
+ const parsedBody = body ? JSON.parse(body) : {};
27
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
28
+ resolve(parsedBody.data);
29
+ } else {
30
+ const err = new Error(parsedBody?.error?.message || `HTTP ${res.statusCode}: ${body.slice(0, 100)}`);
31
+ err.code = parsedBody?.error?.code;
32
+ reject(err);
33
+ }
34
+ } catch {
35
+ reject(new Error(`Resposta inválida do servidor: HTTP ${res.statusCode}`));
36
+ }
37
+ });
38
+ });
39
+
40
+ req.on('error', (err) => {
41
+ reject(new Error(`Falha de conexão com ${parsed.host} (${err.code || err.message})`));
42
+ });
43
+
44
+ req.setTimeout(15000, () => {
45
+ req.destroy();
46
+ reject(new Error(`Tempo limite esgotado ao contatar ${parsed.host}`));
47
+ });
48
+
49
+ req.write(postData);
50
+ req.end();
51
+ });
52
+ }
53
+
54
+ export async function registerNodeWithCloud({ serverUrl, token, name }) {
55
+ const url = `${serverUrl.replace(/\/$/, '')}/api/v1/nodes/register`;
56
+
57
+ const payload = {
58
+ token,
59
+ hostname: os.hostname(),
60
+ platform: process.platform,
61
+ arch: process.arch,
62
+ name: name || `Servidor ${os.hostname()}`,
63
+ };
64
+
65
+ return await postJson(url, payload);
66
+ }
67
+
68
+ export async function fetchServerTelemetry(port = 3010) {
69
+ try {
70
+ const res = await fetch(`http://127.0.0.1:${port}/api/v1/ready`);
71
+ if (!res.ok) return null;
72
+ return await res.json();
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
package/src/config.js ADDED
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ export function getConfigDir() {
6
+ if (process.platform === 'win32') {
7
+ return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Foundry');
8
+ }
9
+ return path.join(os.homedir(), '.foundry');
10
+ }
11
+
12
+ export function getConfigPath() {
13
+ return path.join(getConfigDir(), 'config.json');
14
+ }
15
+
16
+ export function getDefaultStorageDir() {
17
+ return path.join(getConfigDir(), 'storage');
18
+ }
19
+
20
+ export function hasConfig() {
21
+ return fs.existsSync(getConfigPath());
22
+ }
23
+
24
+ export function readConfig() {
25
+ const filePath = getConfigPath();
26
+ if (!fs.existsSync(filePath)) return null;
27
+ try {
28
+ const config = JSON.parse(fs.readFileSync(filePath, 'utf8'));
29
+ if (!config.storagePath) {
30
+ config.storagePath = getDefaultStorageDir();
31
+ }
32
+ return config;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function writeConfig(data) {
39
+ const dir = getConfigDir();
40
+ if (!fs.existsSync(dir)) {
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ }
43
+
44
+ // Se não definido, salva o caminho padrão
45
+ if (!data.storagePath) {
46
+ data.storagePath = getDefaultStorageDir();
47
+ }
48
+
49
+ // Garante que o diretório de storage existe
50
+ if (!fs.existsSync(data.storagePath)) {
51
+ try {
52
+ fs.mkdirSync(data.storagePath, { recursive: true });
53
+ } catch {
54
+ // Ignora erro de permissão imediato, será reportado no setup
55
+ }
56
+ }
57
+
58
+ const filePath = getConfigPath();
59
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
60
+ }
61
+
62
+ export function updateStoragePath(newPath) {
63
+ const config = readConfig() || {};
64
+ config.storagePath = path.resolve(newPath);
65
+ if (!fs.existsSync(config.storagePath)) {
66
+ fs.mkdirSync(config.storagePath, { recursive: true });
67
+ }
68
+ writeConfig(config);
69
+ return config.storagePath;
70
+ }
package/src/daemon.js ADDED
@@ -0,0 +1,156 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { readConfig } from './config.js';
5
+ import { edgeTunnelClient } from './tunnel-client.js';
6
+
7
+ export function startDaemon() {
8
+ const config = readConfig();
9
+ if (!config) {
10
+ console.error('Configuração não encontrada. Execute "foundry setup" primeiro.');
11
+ process.exit(1);
12
+ }
13
+
14
+ const port = config.localPort || 3010;
15
+
16
+ // 1. Inicia cliente do túnel WebSocket com a Nuvem
17
+ edgeTunnelClient.start();
18
+ console.log(`[Foundry Daemon] Túnel WebSocket conectado a ${config.serverUrl}`);
19
+
20
+ // 2. Servidor HTTP local (Porta 3010) para Slicer e diagnósticos
21
+ const server = http.createServer(async (req, res) => {
22
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
23
+
24
+ // CORS básico
25
+ res.setHeader('Access-Control-Allow-Origin', '*');
26
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
27
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key, Authorization');
28
+
29
+ if (req.method === 'OPTIONS') {
30
+ res.writeHead(204);
31
+ res.end();
32
+ return;
33
+ }
34
+
35
+ // Healthcheck local
36
+ if (url.pathname === '/api/v1/ready' || url.pathname === '/api/v1/health') {
37
+ res.writeHead(200, { 'Content-Type': 'application/json' });
38
+ res.end(JSON.stringify({
39
+ success: true,
40
+ data: {
41
+ ready: true,
42
+ status: 'healthy',
43
+ nodeId: config.nodeId,
44
+ organizationId: config.organizationId,
45
+ storagePath: config.storagePath,
46
+ timestamp: new Date().toISOString(),
47
+ },
48
+ }));
49
+ return;
50
+ }
51
+
52
+ // Slicer version handshake
53
+ if (url.pathname.match(/^\/api\/v1\/slicer-host\/[^/]+\/api\/version$/)) {
54
+ res.writeHead(200, { 'Content-Type': 'application/json' });
55
+ res.end(JSON.stringify({
56
+ api: '0.1',
57
+ server: '1.10.0',
58
+ text: 'OctoPrint 1.10.0 (Project Foundry Local Bridge)',
59
+ }));
60
+ return;
61
+ }
62
+
63
+ // Upload do OrcaSlicer recebido na LAN: salva direto no HD local e repassa à nuvem
64
+ if (url.pathname.match(/^\/api\/v1\/slicer-host\/[^/]+\/api\/files\/local$/) && req.method === 'POST') {
65
+ const storageDir = config.storagePath || path.join(process.cwd(), 'storage');
66
+ if (!fs.existsSync(storageDir)) {
67
+ fs.mkdirSync(storageDir, { recursive: true });
68
+ }
69
+
70
+ const tempFileName = `upload_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.tmp`;
71
+ const tempFilePath = path.join(storageDir, tempFileName);
72
+ const fileStream = fs.createWriteStream(tempFilePath);
73
+
74
+ req.pipe(fileStream);
75
+
76
+ fileStream.on('finish', async () => {
77
+ try {
78
+ // Encaminha requisição completa com cabeçalhos para a nuvem
79
+ const cloudUrl = `${config.serverUrl.replace(/\/$/, '')}${url.pathname}`;
80
+ const cloudReq = http.request(cloudUrl, {
81
+ method: 'POST',
82
+ headers: {
83
+ ...req.headers,
84
+ host: new URL(config.serverUrl).host,
85
+ },
86
+ }, (cloudRes) => {
87
+ res.writeHead(cloudRes.statusCode, cloudRes.headers);
88
+ cloudRes.pipe(res);
89
+ });
90
+
91
+ cloudReq.on('error', (err) => {
92
+ res.writeHead(502, { 'Content-Type': 'application/json' });
93
+ res.end(JSON.stringify({
94
+ success: false,
95
+ error: { message: `Erro ao notificar nuvem: ${err.message}` },
96
+ }));
97
+ });
98
+
99
+ fs.createReadStream(tempFilePath).pipe(cloudReq);
100
+ } catch (err) {
101
+ res.writeHead(500, { 'Content-Type': 'application/json' });
102
+ res.end(JSON.stringify({ success: false, error: { message: err.message } }));
103
+ }
104
+ });
105
+
106
+ fileStream.on('error', (err) => {
107
+ res.writeHead(500, { 'Content-Type': 'application/json' });
108
+ res.end(JSON.stringify({ success: false, error: { message: err.message } }));
109
+ });
110
+
111
+ return;
112
+ }
113
+
114
+ // Proxy reverso padrão para outras rotas em direção à Nuvem
115
+ try {
116
+ const targetUrl = new URL(req.url, config.serverUrl);
117
+ const isHttps = targetUrl.protocol === 'https:';
118
+ const transport = isHttps ? await import('node:https') : await import('node:http');
119
+
120
+ const proxyReq = transport.request(targetUrl, {
121
+ method: req.method,
122
+ headers: {
123
+ ...req.headers,
124
+ host: targetUrl.host,
125
+ },
126
+ }, (proxyRes) => {
127
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
128
+ proxyRes.pipe(res);
129
+ });
130
+
131
+ proxyReq.on('error', (err) => {
132
+ res.writeHead(502, { 'Content-Type': 'application/json' });
133
+ res.end(JSON.stringify({ success: false, error: { message: `Erro no proxy da nuvem: ${err.message}` } }));
134
+ });
135
+
136
+ req.pipe(proxyReq);
137
+ } catch (err) {
138
+ res.writeHead(500, { 'Content-Type': 'application/json' });
139
+ res.end(JSON.stringify({ success: false, error: { message: err.message } }));
140
+ }
141
+ });
142
+
143
+ server.listen(port, '0.0.0.0', () => {
144
+ console.log(`[Foundry Daemon] Servidor local escutando em http://0.0.0.0:${port}`);
145
+ console.log(`[Foundry Daemon] Diretório de arquivos: ${config.storagePath}`);
146
+ });
147
+
148
+ const handleExit = () => {
149
+ console.log('[Foundry Daemon] Parando serviços...');
150
+ edgeTunnelClient.stop();
151
+ server.close(() => process.exit(0));
152
+ };
153
+
154
+ process.on('SIGINT', handleExit);
155
+ process.on('SIGTERM', handleExit);
156
+ }
@@ -0,0 +1,74 @@
1
+ import { execSync, spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ export async function isServerHealthy(port = 3010) {
7
+ try {
8
+ const controller = new AbortController();
9
+ const timer = setTimeout(() => controller.abort(), 1500);
10
+ const res = await fetch(`http://127.0.0.1:${port}/api/v1/ready`, { signal: controller.signal });
11
+ clearTimeout(timer);
12
+ return res.status === 200;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export function installAutostartService(serverEntryPath) {
19
+ const isWin = process.platform === 'win32';
20
+
21
+ if (isWin) {
22
+ try {
23
+ // Cria tarefa no Agendador do Windows com início automático em modo daemon
24
+ const cmd = `schtasks /create /tn "ProjectFoundry" /tr "node \\"${serverEntryPath}\\" daemon" /sc onstart /f`;
25
+ execSync(cmd, { stdio: 'pipe' });
26
+ return { success: true, type: 'windows-task', name: 'ProjectFoundry' };
27
+ } catch (err) {
28
+ return { success: false, error: err.message };
29
+ }
30
+ }
31
+
32
+ // Linux (systemd)
33
+ try {
34
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
35
+ const serviceContent = `[Unit]
36
+ Description=Project Foundry Local Server
37
+ After=network.target
38
+
39
+ [Service]
40
+ Type=simple
41
+ ExecStart=/usr/bin/node ${serverEntryPath} daemon
42
+ Restart=always
43
+ RestartSec=5
44
+ Environment=NODE_ENV=production
45
+
46
+ [Install]
47
+ WantedBy=default.target
48
+ `;
49
+
50
+ if (isRoot) {
51
+ fs.writeFileSync('/etc/systemd/system/foundry.service', serviceContent, 'utf8');
52
+ execSync('systemctl daemon-reload && systemctl enable foundry.service', { stdio: 'pipe' });
53
+ return { success: true, type: 'systemd-system', name: 'foundry.service' };
54
+ } else {
55
+ const userSystemdDir = path.join(os.homedir(), '.config', 'systemd', 'user');
56
+ fs.mkdirSync(userSystemdDir, { recursive: true });
57
+ fs.writeFileSync(path.join(userSystemdDir, 'foundry.service'), serviceContent, 'utf8');
58
+ execSync('systemctl --user daemon-reload && systemctl --user enable foundry.service', { stdio: 'pipe' });
59
+ return { success: true, type: 'systemd-user', name: 'foundry.service' };
60
+ }
61
+ } catch (err) {
62
+ return { success: false, error: err.message };
63
+ }
64
+ }
65
+
66
+ export function startDaemonBackground(serverEntryPath) {
67
+ // Inicia desacoplado do terminal para não travar
68
+ const child = spawn(process.execPath, [serverEntryPath, 'daemon'], {
69
+ detached: true,
70
+ stdio: 'ignore',
71
+ });
72
+ child.unref();
73
+ return child.pid;
74
+ }
package/src/tui.js ADDED
@@ -0,0 +1,118 @@
1
+ import * as p from '@clack/prompts';
2
+ import pc from 'picocolors';
3
+ import open from 'open';
4
+ import { readConfig, updateStoragePath } from './config.js';
5
+ import { isServerHealthy } from './service-manager.js';
6
+
7
+ export async function runInteractiveTui() {
8
+ const config = readConfig();
9
+ const version = 'v1.0.0';
10
+ const localPort = config?.localPort || 3010;
11
+ const localUrl = `http://localhost:${localPort}`;
12
+
13
+ console.clear();
14
+
15
+ p.intro(
16
+ `${pc.bold(pc.cyan('======================================================'))}\n` +
17
+ ` ${pc.bold(pc.yellow(`Project Foundry Interface (${version})`))}\n` +
18
+ ` 🚀 Server: ${pc.green(localUrl)}\n` +
19
+ ` 💾 Storage: ${pc.cyan(config?.storagePath || 'Padrão')}\n` +
20
+ `${pc.bold(pc.cyan('======================================================'))}`
21
+ );
22
+
23
+ const healthy = await isServerHealthy(localPort);
24
+
25
+ if (healthy) {
26
+ p.note(
27
+ `${pc.green('●')} Servidor Local Online\n` +
28
+ `Empresa: ${config?.organizationId ? pc.bold(config.organizationId) : 'Autônoma'}\n` +
29
+ `Nó: ${pc.bold(config?.nodeName || 'Servidor Local')}\n` +
30
+ `Armazenamento de Arquivos: ${pc.bold(config?.storagePath || 'Padrão')}`,
31
+ 'Status da Farm'
32
+ );
33
+ } else {
34
+ p.note(
35
+ `${pc.yellow('○')} O servidor local não respondeu em ${localUrl}.\n` +
36
+ `Ele pode estar iniciando ou parado.`,
37
+ 'Atenção'
38
+ );
39
+ }
40
+
41
+ while (true) {
42
+ const choice = await p.select({
43
+ message: 'Escolha uma opção:',
44
+ options: [
45
+ { value: 'web', label: 'Web UI (Open in Browser)', hint: 'Abre o painel no navegador' },
46
+ { value: 'status', label: 'Terminal UI (Status & Health)', hint: 'Verifica conexões locais' },
47
+ { value: 'storage', label: 'Storage (HD Externo)', hint: 'Visualizar ou alterar pasta de arquivos' },
48
+ { value: 'update', label: `Update (current: ${version})`, hint: 'Verifica atualizações' },
49
+ { value: 'hide', label: 'Hide to Tray (Background)', hint: 'Mantém servidor rodando em segundo plano' },
50
+ { value: 'exit', label: 'Exit', hint: 'Fechar console' },
51
+ ],
52
+ });
53
+
54
+ if (p.isCancel(choice) || choice === 'hide') {
55
+ p.outro(pc.green('Servidor mantido rodando em segundo plano. Digite "foundry" para reabrir este menu.'));
56
+ process.exit(0);
57
+ }
58
+
59
+ if (choice === 'web') {
60
+ p.log.info(`Abrindo ${localUrl} no navegador...`);
61
+ await open(localUrl).catch(() => {
62
+ p.log.warn(`Não foi possível abrir o navegador automaticamente. Acesse: ${localUrl}`);
63
+ });
64
+ }
65
+
66
+ if (choice === 'status') {
67
+ const isUp = await isServerHealthy(localPort);
68
+ if (isUp) {
69
+ p.log.success(`Servidor respondendo perfeitamente em ${localUrl}`);
70
+ } else {
71
+ p.log.error(`Servidor inacessível na porta ${localPort}`);
72
+ }
73
+ }
74
+
75
+ if (choice === 'storage') {
76
+ const currentConf = readConfig();
77
+ p.log.info(`Pasta atual de arquivos: ${pc.bold(currentConf?.storagePath || 'Padrão')}`);
78
+ const wantChange = await p.confirm({
79
+ message: 'Deseja alterar a pasta de armazenamento (ex: novo HD)?',
80
+ initialValue: false,
81
+ });
82
+
83
+ if (wantChange && !p.isCancel(wantChange)) {
84
+ const newPath = await p.text({
85
+ message: 'Digite o novo caminho completo da pasta do HD:',
86
+ placeholder: process.platform === 'win32' ? 'D:\\Storage' : '/mnt/hd1tb/storage',
87
+ validate: (val) => {
88
+ if (!val || !val.trim()) return 'Caminho é obrigatório';
89
+ },
90
+ });
91
+ if (!p.isCancel(newPath) && newPath.trim()) {
92
+ try {
93
+ const updated = updateStoragePath(newPath.trim());
94
+ p.log.success(`Caminho de armazenamento atualizado para: ${pc.bold(updated)}`);
95
+ } catch (err) {
96
+ p.log.error(`Erro ao atualizar pasta: ${err.message}`);
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ if (choice === 'update') {
103
+ p.log.info('Você já está na versão mais recente instalada.');
104
+ }
105
+
106
+ if (choice === 'exit') {
107
+ const shouldStop = await p.confirm({
108
+ message: 'Deseja apenas fechar o console ou parar o serviço?',
109
+ active: 'Apenas fechar console (manter servidor ligado)',
110
+ inactive: 'Fechar console',
111
+ initialValue: true,
112
+ });
113
+
114
+ p.outro(pc.cyan('Até logo! O servidor continua em segundo plano.'));
115
+ process.exit(0);
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,160 @@
1
+ import WebSocket from 'ws';
2
+ import { readConfig } from './config.js';
3
+
4
+ export class EdgeTunnelClient {
5
+ constructor() {
6
+ this.ws = null;
7
+ this.pingTimer = null;
8
+ this.reconnectTimer = null;
9
+ this.isStopping = false;
10
+ }
11
+
12
+ start() {
13
+ const config = readConfig();
14
+ if (!config || !config.serverUrl || !config.apiKey) {
15
+ return;
16
+ }
17
+
18
+ this.isStopping = false;
19
+ this.connect();
20
+ }
21
+
22
+ stop() {
23
+ this.isStopping = true;
24
+ if (this.pingTimer) clearInterval(this.pingTimer);
25
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
26
+ if (this.ws) {
27
+ try {
28
+ this.ws.close();
29
+ } catch {}
30
+ this.ws = null;
31
+ }
32
+ }
33
+
34
+ connect() {
35
+ if (this.isStopping) return;
36
+
37
+ const config = readConfig();
38
+ if (!config) return;
39
+
40
+ const base = config.serverUrl.replace(/\/$/, '');
41
+ const wsProto = base.startsWith('https') ? 'wss' : 'ws';
42
+ const hostPart = base.replace(/^https?:\/\//, '');
43
+ const url = `${wsProto}://${hostPart}/api/v1/nodes/tunnel?token=${encodeURIComponent(config.apiKey)}`;
44
+
45
+ try {
46
+ this.ws = new WebSocket(url, {
47
+ rejectUnauthorized: false,
48
+ family: 4,
49
+ });
50
+
51
+ this.ws.on('open', () => {
52
+ // Envia PING a cada 25 segundos
53
+ if (this.pingTimer) clearInterval(this.pingTimer);
54
+ this.pingTimer = setInterval(() => {
55
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
56
+ this.ws.send(JSON.stringify({ type: 'PING' }));
57
+ }
58
+ }, 25000);
59
+ });
60
+
61
+ this.ws.on('message', async (data) => {
62
+ try {
63
+ const msg = JSON.parse(data.toString());
64
+ if (msg.type === 'COMMAND' && msg.correlationId) {
65
+ await this.handleCloudCommand(msg);
66
+ }
67
+ } catch {}
68
+ });
69
+
70
+ this.ws.on('close', () => {
71
+ if (this.pingTimer) clearInterval(this.pingTimer);
72
+ this.scheduleReconnect();
73
+ });
74
+
75
+ this.ws.on('error', () => {
76
+ if (this.pingTimer) clearInterval(this.pingTimer);
77
+ this.scheduleReconnect();
78
+ });
79
+ } catch {
80
+ this.scheduleReconnect();
81
+ }
82
+ }
83
+
84
+ scheduleReconnect() {
85
+ if (this.isStopping) return;
86
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
87
+ this.reconnectTimer = setTimeout(() => {
88
+ this.connect();
89
+ }, 5000);
90
+ }
91
+
92
+ async handleCloudCommand(msg) {
93
+ const config = readConfig();
94
+ const localPort = config?.localPort || 3010;
95
+ const localBase = `http://127.0.0.1:${localPort}/api/v1`;
96
+ const headers = {
97
+ 'Content-Type': 'application/json',
98
+ 'X-Api-Key': config.apiKey,
99
+ };
100
+
101
+ try {
102
+ let resultData = null;
103
+
104
+ if (msg.action === 'camera/snapshot') {
105
+ const snapRes = await fetch(`${localBase}/printers/${msg.printerId}/camera/snapshot`, {
106
+ headers: { 'X-Api-Key': config.apiKey },
107
+ });
108
+ if (!snapRes.ok) throw new Error(`Snapshot falhou: HTTP ${snapRes.status}`);
109
+ const arrayBuf = await snapRes.arrayBuffer();
110
+ resultData = { base64: Buffer.from(arrayBuf).toString('base64') };
111
+ } else if (msg.action === 'control') {
112
+ const cRes = await fetch(`${localBase}/printers/${msg.printerId}/control`, {
113
+ method: 'POST',
114
+ headers,
115
+ body: JSON.stringify(msg.payload || {}),
116
+ });
117
+ const cJson = await cRes.json();
118
+ if (!cRes.ok) throw new Error(cJson?.error?.message || 'Falha no comando de controle');
119
+ resultData = cJson.data;
120
+ } else if (msg.action === 'connect') {
121
+ const connRes = await fetch(`${localBase}/printers/${msg.printerId}/connect`, {
122
+ method: 'POST',
123
+ headers,
124
+ body: JSON.stringify({}),
125
+ });
126
+ const connJson = await connRes.json();
127
+ if (!connRes.ok) throw new Error(connJson?.error?.message || 'Falha ao conectar à impressora local');
128
+ resultData = connJson.data;
129
+ } else if (msg.action.startsWith('job/')) {
130
+ const jobAction = msg.action.replace('job/', '');
131
+ const jRes = await fetch(`${localBase}/jobs/${msg.payload?.jobId}/${jobAction}`, {
132
+ method: 'POST',
133
+ headers,
134
+ body: JSON.stringify({}),
135
+ });
136
+ const jJson = await jRes.json();
137
+ if (!jRes.ok) throw new Error(jJson?.error?.message || `Falha no comando do trabalho: ${jobAction}`);
138
+ resultData = jJson.data;
139
+ }
140
+
141
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
142
+ this.ws.send(JSON.stringify({
143
+ type: 'COMMAND_RESPONSE',
144
+ correlationId: msg.correlationId,
145
+ data: resultData,
146
+ }));
147
+ }
148
+ } catch (err) {
149
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
150
+ this.ws.send(JSON.stringify({
151
+ type: 'COMMAND_RESPONSE',
152
+ correlationId: msg.correlationId,
153
+ error: err.message,
154
+ }));
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ export const edgeTunnelClient = new EdgeTunnelClient();