@haltera/cli 1.0.0 → 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 ADDED
@@ -0,0 +1,58 @@
1
+ [![npm version](https://img.shields.io/npm/v/@haltera/cli.svg)](https://www.npmjs.com/package/@haltera/cli)
2
+ ![License: UNLICENSED](https://img.shields.io/badge/License-UNLICENSED-yellow.svg)
3
+
4
+ # @haltera/cli
5
+
6
+ CLI ufficiale di Haltera per la gestione operativa del Match Station su Raspberry Pi.
7
+
8
+ ## Requisiti
9
+
10
+ - Node.js >= 20
11
+ - Raspberry Pi con accesso di rete configurato
12
+
13
+ ## Installazione
14
+ ```bash
15
+ npm install -g @haltera/cli
16
+ ```
17
+
18
+ ## Configurazione
19
+
20
+ Al primo avvio, inizializza la configurazione:
21
+ ```bash
22
+ haltera config init --deviceToken <token>
23
+ ```
24
+
25
+ Il file di configurazione viene salvato in `/etc/haltera/config.json`.
26
+
27
+ Per leggere o modificare singole chiavi:
28
+ ```bash
29
+ haltera config get
30
+ haltera config set
31
+ ```
32
+
33
+ ## Comandi
34
+ ```bash
35
+ haltera --help
36
+ ```
37
+
38
+ | Comando | Descrizione |
39
+ |---|---|
40
+ | `haltera config init` | Avvia il wizard di configurazione |
41
+ | `haltera config get <key>` | Legge un valore di configurazione |
42
+ | `haltera config set <key> <value>` | Imposta un valore di configurazione |
43
+ | `haltera wifi` | Gestisce la connessione WiFi |
44
+ | `haltera update status` | Gestisce gli aggiornamenti dei servizi |
45
+
46
+ ## Percorsi
47
+
48
+ | Percorso | Contenuto |
49
+ |---|---|
50
+ | `/etc/haltera/config.json` | Configurazione |
51
+
52
+ ---
53
+
54
+ Haltera è un software di gestione gare per sport da combattimento. Per maggiori informazioni visita [haltera.it](https://haltera.it).
55
+
56
+ ## Licenza
57
+
58
+ Tutti i diritti riservati — [Alberti Group](https://albertigroup.it)
@@ -0,0 +1,28 @@
1
+ import { Command } from "commander";
2
+ import { CONFIG_SCHEMA } from "../../helpers/configSchema.js";
3
+ import { loadConfig, saveConfig } from "../../helpers/config.js";
4
+ export const getCommand = new Command('get')
5
+ .description("Legge un valore dalla config")
6
+ .argument('<key>', 'Chiave di configurazione')
7
+ .action((key) => {
8
+ try {
9
+ if (!CONFIG_SCHEMA[key]) {
10
+ console.error(`Chiave non consentita: "${key}"`);
11
+ process.exit(1);
12
+ }
13
+ const cfg = loadConfig();
14
+ const value = cfg[key];
15
+ const def = CONFIG_SCHEMA[key];
16
+ if (value === undefined || value === '') {
17
+ console.log(def.default);
18
+ cfg[key] = def.default;
19
+ saveConfig(cfg);
20
+ }
21
+ else {
22
+ console.log(value);
23
+ }
24
+ }
25
+ catch (err) {
26
+ console.log(err instanceof Error ? err.message : String(err));
27
+ }
28
+ });
@@ -0,0 +1,13 @@
1
+ import { Command } from "commander";
2
+ import { getCommand } from "./getConfig.js";
3
+ import { setCommand } from "./setConfig.js";
4
+ import { listCommand } from "./listConfig.js";
5
+ import { showCommand } from "./showConfig.js";
6
+ import { initCommand } from "./initConfig.js";
7
+ export const configCommand = new Command('config');
8
+ configCommand.description('Initialize and manage the Match Station config file');
9
+ configCommand.addCommand(getCommand);
10
+ configCommand.addCommand(setCommand);
11
+ configCommand.addCommand(listCommand);
12
+ configCommand.addCommand(showCommand);
13
+ configCommand.addCommand(initCommand);
@@ -0,0 +1,37 @@
1
+ import { Command } from "commander";
2
+ import { loadConfig, promptParam, saveConfig } from "../../helpers/config.js";
3
+ import { CONFIG_SCHEMA } from "../../helpers/configSchema.js";
4
+ import { confirm } from '@inquirer/prompts';
5
+ import { ExitPromptError } from '@inquirer/core';
6
+ export const initCommand = new Command('init')
7
+ .description('Configurazione guidata interattiva')
8
+ .option('-d, --skipdefaults', 'Salta le voci con un valore di default', false)
9
+ .action(async () => {
10
+ try {
11
+ const config = loadConfig();
12
+ const updated = { ...config };
13
+ const options = initCommand.opts();
14
+ console.log('\n🔧 Haltera Match Station — Configurazione iniziale\n');
15
+ for (const [key, param] of Object.entries(CONFIG_SCHEMA)) {
16
+ if (!param.hidden || (options.skipdefaults && param.default != undefined)) {
17
+ const current = config[key];
18
+ updated[key] = await promptParam(param, current);
19
+ }
20
+ }
21
+ console.log('');
22
+ const confirmed = await confirm({ message: 'Salvare la configurazione?', default: true });
23
+ if (confirmed) {
24
+ saveConfig(updated);
25
+ console.log('\n✅ Configurazione salvata.\n');
26
+ }
27
+ else {
28
+ console.log('\n⚠️ Configurazione annullata.\n');
29
+ }
30
+ }
31
+ catch (err) {
32
+ if (err instanceof ExitPromptError)
33
+ console.log('\n⚠️ Configurazione annullata.');
34
+ else if (err instanceof Error)
35
+ console.log(err.message);
36
+ }
37
+ });
@@ -0,0 +1,10 @@
1
+ import { Command } from "commander";
2
+ import { CONFIG_SCHEMA } from "../../helpers/configSchema.js";
3
+ export const listCommand = new Command('list')
4
+ .description('Elenca i parametri configurabili')
5
+ .action(() => {
6
+ console.log('\nParametri configurabili:\n');
7
+ for (const [key, def] of Object.entries(CONFIG_SCHEMA))
8
+ if (!def.hidden)
9
+ console.log(` ${key.padEnd(38)} ${def.description}`);
10
+ });
@@ -0,0 +1,31 @@
1
+ import { Command } from "commander";
2
+ import { CONFIG_SCHEMA } from "../../helpers/configSchema.js";
3
+ import { loadConfig, saveConfig } from "../../helpers/config.js";
4
+ export const setCommand = new Command('set')
5
+ .description("Setta un valore nella config")
6
+ .argument('<key>', 'Chiave di configurazione')
7
+ .argument('<value>', 'Valore da assegnare alla chiave')
8
+ .action((key, value) => {
9
+ try {
10
+ const def = CONFIG_SCHEMA[key];
11
+ if (!def) {
12
+ console.error(`Chiave non consentita: "${key}"`);
13
+ console.info(`Chiavi disponibili:\n${Object.keys(CONFIG_SCHEMA).map(k => ` ${k}`).join('\n')}`);
14
+ process.exit(1);
15
+ }
16
+ if (def.validate) {
17
+ const result = def.validate(value);
18
+ if (result !== true) {
19
+ console.error(`Valore non valido per "${key}": ${result}`);
20
+ process.exit(1);
21
+ }
22
+ }
23
+ const cfg = loadConfig();
24
+ cfg[key] = value;
25
+ saveConfig(cfg);
26
+ console.log(`✓ ${key} = ${def.sensitive ? '***' : value}`);
27
+ }
28
+ catch (err) {
29
+ console.log(err instanceof Error ? err.message : String(err));
30
+ }
31
+ });
@@ -0,0 +1,20 @@
1
+ import { Command } from "commander";
2
+ import { loadConfig, redactConfig } from "../../helpers/config.js";
3
+ export const showCommand = new Command('show')
4
+ .description('Mostra la config completa (oscura i valori sensitive)')
5
+ .option('--decrypt', 'Mostra le chiavi sensibili come testo')
6
+ .action(() => {
7
+ try {
8
+ const options = showCommand.opts();
9
+ const cfg = loadConfig();
10
+ if (options.decrypt) {
11
+ console.log(JSON.stringify(cfg, null, 2));
12
+ }
13
+ else {
14
+ console.log(JSON.stringify(redactConfig(cfg), null, 2));
15
+ }
16
+ }
17
+ catch (err) {
18
+ console.log(err instanceof Error ? err.message : String(err));
19
+ }
20
+ });
@@ -0,0 +1,10 @@
1
+ import { configCommand } from "./config/index.js";
2
+ import { installCommand } from "./install.js";
3
+ import { networkCommand } from "./network/index.js";
4
+ import { updateCommand } from "./update/index.js";
5
+ export const commands = [
6
+ configCommand,
7
+ networkCommand,
8
+ updateCommand,
9
+ installCommand
10
+ ];
@@ -0,0 +1,69 @@
1
+ import { Command } from "commander";
2
+ import { checkPrerequisites, createDirectoriesAndFiles, createSystemdService, installService } from "../helpers/install.js";
3
+ import { loadConfig, saveConfig } from "../helpers/config.js";
4
+ import { checkDeviceToken, getLatestRelease, saveVersions } from "../helpers/version.js";
5
+ import { execSync } from "child_process";
6
+ export const installCommand = new Command("install")
7
+ .description('Install tutti i servizi sul dispositivo')
8
+ .option('-d --deviceToken <token>', 'Token di identificazione del dispositivo')
9
+ .option('-t --gitlabToken <token>', 'Token di gitlab per il primo avvio')
10
+ .action(async (options) => {
11
+ try {
12
+ if (!options.deviceToken) {
13
+ console.error('--deviceToken è obbligatorio per l\'installazione');
14
+ process.exit(1);
15
+ }
16
+ console.log(options.deviceToken);
17
+ const cfg = loadConfig();
18
+ if (await checkDeviceToken(options.deviceToken)) {
19
+ cfg['auth.deviceToken'] = options.deviceToken;
20
+ saveConfig(cfg);
21
+ }
22
+ checkPrerequisites();
23
+ const token = cfg['auth.gitlabToken'] || options.gitlabToken;
24
+ const rawServices = cfg['services'];
25
+ const serviceDir = cfg['path.service'];
26
+ const tmpDir = cfg['path.tmp'];
27
+ const dbPath = cfg['database.path'];
28
+ const logPath = cfg['log.path'];
29
+ const versionsPath = cfg['path.versions'];
30
+ if (!token) {
31
+ //TODO: HANDLE THIS CASE
32
+ console.error('HANDLE THIS CASE');
33
+ process.exit(1);
34
+ }
35
+ if (!rawServices || !serviceDir || !tmpDir) {
36
+ console.error('Configurazione mancante. Esegui `haltera config init`');
37
+ process.exit(1);
38
+ }
39
+ const services = String(rawServices).trim().split(',');
40
+ createDirectoriesAndFiles([serviceDir, tmpDir], [dbPath, logPath, versionsPath]);
41
+ const versions = {};
42
+ for (const service of services) {
43
+ try {
44
+ const latest = await getLatestRelease(service, token);
45
+ if (!latest) {
46
+ console.warn(`⚠ ${service}: nessuna release trovata, saltato`);
47
+ continue;
48
+ }
49
+ await installService(service, latest, token, tmpDir, serviceDir);
50
+ createSystemdService(service, serviceDir);
51
+ versions[service] = latest;
52
+ }
53
+ catch (err) {
54
+ console.error(`✗ ${service}: ${err instanceof Error ? err.message : String(err)}`);
55
+ }
56
+ execSync('systemctl daemon-reload', { stdio: 'inherit' });
57
+ for (const service of Object.keys(versions)) {
58
+ execSync(`systemctl enable haltera-${service}`, { stdio: 'inherit' });
59
+ execSync(`systemctl start haltera-${service}`, { stdio: 'inherit' });
60
+ console.log(`✓ haltera-${service} avviato`);
61
+ }
62
+ saveVersions(versions);
63
+ console.log('\n✓ Installazione completata');
64
+ }
65
+ }
66
+ catch (err) {
67
+ console.error(err instanceof Error ? err.message : String(err));
68
+ }
69
+ });
@@ -0,0 +1,36 @@
1
+ import { Command } from "commander";
2
+ import { loadConfig } from "../../helpers/config.js";
3
+ import { checkConnectivity } from "../../helpers/network.js";
4
+ export const checkCommand = new Command('check')
5
+ .description("Controlla lo stato della connessione")
6
+ .option('-r, --retries <n>', 'Numero di tentativi in caso di errore')
7
+ .option('-d, --delay <ms>', 'Attesa in ms tra un tentativo e il successivo')
8
+ .action(async (options) => {
9
+ try {
10
+ const cfg = loadConfig();
11
+ const r = Number.parseInt(options.retries);
12
+ const d = Number.parseInt(options.delay);
13
+ const retries = r || cfg['server.retryCount'];
14
+ const delay = d || cfg['server.retryDelay'];
15
+ if (!retries || !delay) {
16
+ console.error('Configurazione mancante. Esegui haltera config init o specifica --retries e --delay');
17
+ process.exit();
18
+ }
19
+ for (let attempt = 1; attempt <= retries; attempt++) {
20
+ if (attempt > 1) {
21
+ console.log(`Retry ${attempt}/${retries}...`);
22
+ await new Promise(r => setTimeout(r, delay));
23
+ }
24
+ const result = await checkConnectivity();
25
+ if (result.internet && result.syncServer) {
26
+ console.log('✓ Internet: OK');
27
+ console.log('✓ Sync server: OK');
28
+ process.exit(0);
29
+ }
30
+ console.error(`✗ ${result.error}`);
31
+ }
32
+ }
33
+ catch (err) {
34
+ console.error(err instanceof Error ? err.message : String(err));
35
+ }
36
+ });
@@ -0,0 +1,29 @@
1
+ import { Command } from "commander";
2
+ import { loadConfig } from "../../helpers/config.js";
3
+ import { execSync } from "child_process";
4
+ export const connectCommand = new Command('connect')
5
+ .description('Connette a una rete WiFi')
6
+ .option('--ssid <ssid>', 'Nome della rete WiFi')
7
+ .option('--password <password>', 'Password della rete WiFi')
8
+ .action((options) => {
9
+ try {
10
+ const cfg = loadConfig();
11
+ const s = options.ssid;
12
+ const p = options.password;
13
+ const ssid = s || cfg['network.wifiSSID'];
14
+ const password = p || cfg['network.wifiPassword'];
15
+ if (!ssid) {
16
+ console.error('Configurazione mancante. Esegui haltera config init o specifica --ssid e --password');
17
+ process.exit();
18
+ }
19
+ if (password) {
20
+ execSync(`nmcli device wifi connect "${ssid}" password "${password}"`, { stdio: 'inherit' });
21
+ }
22
+ else {
23
+ execSync(`nmcli device wifi connect "${ssid}"`, { stdio: 'inherit' });
24
+ }
25
+ }
26
+ catch (err) {
27
+ console.error(err instanceof Error ? err.message : String(err));
28
+ }
29
+ });
@@ -0,0 +1,9 @@
1
+ import { Command } from "commander";
2
+ import { checkCommand } from "./checkConfig.js";
3
+ import { statusCommand } from "./statusConfig.js";
4
+ import { connectCommand } from "./connectConfig.js";
5
+ export const networkCommand = new Command('network');
6
+ networkCommand.description('Manage raspberry connecctivity');
7
+ networkCommand.addCommand(checkCommand);
8
+ networkCommand.addCommand(statusCommand);
9
+ networkCommand.addCommand(connectCommand);
@@ -0,0 +1,35 @@
1
+ import { execSync } from "child_process";
2
+ import { Command } from "commander";
3
+ export const statusCommand = new Command('status')
4
+ .description('Mostra lo stato della connessione di rete')
5
+ .action(() => {
6
+ try {
7
+ requireNmcli();
8
+ const output = execSync('nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status', { encoding: 'utf-8' });
9
+ const lines = output.trim().split('\n');
10
+ for (const line of lines) {
11
+ const [device, type, state, connection] = line.split(':');
12
+ if (type === 'wifi' || type === 'ethernet') {
13
+ console.log(`Interfaccia : ${device}`);
14
+ console.log(`Tipo : ${type}`);
15
+ console.log(`Stato : ${state}`);
16
+ console.log(`Connessione : ${connection || '—'}`);
17
+ if (state === 'connected' && type === 'wifi') {
18
+ const info = execSync(`nmcli -t -f SSID,SIGNAL,FREQ dev wifi list ifname ${device} --rescan no`, { encoding: 'utf-8' }).split('\n').find(l => l.trim());
19
+ if (info) {
20
+ const [ssid, signal, freq] = info.split(':');
21
+ console.log(`SSID : ${ssid}`);
22
+ console.log(`Segnale : ${signal}%`);
23
+ console.log(`Frequenza : ${freq}`);
24
+ }
25
+ }
26
+ console.log();
27
+ }
28
+ }
29
+ }
30
+ catch (err) {
31
+ console.error(err instanceof Error ? err.message : String(err));
32
+ }
33
+ });
34
+ function requireNmcli() {
35
+ }
@@ -0,0 +1,62 @@
1
+ import { Command } from "commander";
2
+ import { loadConfig } from "../../helpers/config.js";
3
+ import { applyUpdate, downloadTarball, getLatestRelease, loadVersions, saveVersions } from "../../helpers/version.js";
4
+ import { unlinkSync } from "fs";
5
+ export const applyCommand = new Command('apply')
6
+ .description('Scarica e install gli aggiornamenti disponibili')
7
+ .option('--only <service...>', 'Aggiorna solo i servizi selezionati')
8
+ .action(async (options) => {
9
+ try {
10
+ const cfg = loadConfig();
11
+ const token = cfg['auth.gitlabToken'];
12
+ const tmpDir = cfg['path.tmp'];
13
+ const serviceDir = cfg['path.service'];
14
+ if (!token) {
15
+ //TODO: HANDLE THIS CASE
16
+ console.error('HANDLE THIS CASE');
17
+ process.exit(1);
18
+ }
19
+ if (!tmpDir || !serviceDir) {
20
+ console.error('Configurazione mancante. Esegui `haltera config init`');
21
+ process.exit(1);
22
+ }
23
+ const rawServices = cfg['services'];
24
+ if (!rawServices) {
25
+ console.error('Nessun servizio installabile');
26
+ process.exit(1);
27
+ }
28
+ const services = options.only ? options.only : String(rawServices).trim().split(',');
29
+ const versions = loadVersions();
30
+ for (const service of services) {
31
+ const current = versions[service];
32
+ if (!current) {
33
+ console.warn(`⚠ ${service}: versione locale non trovata`);
34
+ continue;
35
+ }
36
+ const latest = await getLatestRelease(service, token);
37
+ if (!latest) {
38
+ console.warn(`⚠ ${service}: nessuna release trovata su GitLab`);
39
+ continue;
40
+ }
41
+ if (current === latest) {
42
+ console.log(`✓ ${service}: aggiornato (${current})`);
43
+ continue;
44
+ }
45
+ console.log(`↑ ${service}: ${current ?? 'non installato'} → ${latest}`);
46
+ try {
47
+ const tarball = await downloadTarball(service, latest, token, tmpDir);
48
+ await applyUpdate(service, tarball, serviceDir);
49
+ versions[service] = latest;
50
+ saveVersions(versions);
51
+ unlinkSync(tarball);
52
+ console.log(`✓ ${service}: aggiornato a ${latest}`);
53
+ }
54
+ catch (err) {
55
+ console.error(`✗ ${service}: ${err instanceof Error ? err.message : String(err)}`);
56
+ }
57
+ }
58
+ }
59
+ catch (err) {
60
+ console.error(err instanceof Error ? err.message : String(err));
61
+ }
62
+ });
@@ -0,0 +1,48 @@
1
+ import { Command } from "commander";
2
+ import { loadConfig } from "../../helpers/config.js";
3
+ import { getLatestRelease, loadVersions } from "../../helpers/version.js";
4
+ export const checkCommand = new Command("check")
5
+ .description('Controlla gli aggiornamenti disponibili per i servizi')
6
+ .option('--only <service...>', 'Nomi dei servizi da controllare')
7
+ .action(async (options) => {
8
+ try {
9
+ const cfg = loadConfig();
10
+ const token = cfg['auth.gitlabToken'];
11
+ const rawServices = cfg['services'];
12
+ if (!token) {
13
+ //TODO: HANDLE THIS CASE
14
+ console.error('HANDLE THIS CASE');
15
+ process.exit(1);
16
+ }
17
+ if (!rawServices) {
18
+ console.error('Nessun servizio installabile');
19
+ process.exit(1);
20
+ }
21
+ const versions = loadVersions();
22
+ let updatesAvailable = false;
23
+ const services = options.only ? options.only : String(rawServices).trim().split(',');
24
+ for (const service of services) {
25
+ const current = versions[service];
26
+ if (!current) {
27
+ console.warn(`⚠ ${service}: versione locale non trovata`);
28
+ continue;
29
+ }
30
+ const latest = await getLatestRelease(service, token);
31
+ if (!latest) {
32
+ console.warn(`⚠ ${service}: nessuna release trovata su GitLab`);
33
+ continue;
34
+ }
35
+ if (current === latest) {
36
+ console.log(`✓ ${service}: aggiornato (${current})`);
37
+ }
38
+ else {
39
+ console.log(`↑ ${service}: ${current} → ${latest}`);
40
+ updatesAvailable = true;
41
+ }
42
+ }
43
+ process.exit(updatesAvailable ? 1 : 0);
44
+ }
45
+ catch (err) {
46
+ console.error(err instanceof Error ? err.message : String(err));
47
+ }
48
+ });
@@ -0,0 +1,9 @@
1
+ import { Command } from "commander";
2
+ import { checkCommand } from "./checkConfig.js";
3
+ import { applyCommand } from "./applyConfig.js";
4
+ import { statusCommand } from "./statusConfig.js";
5
+ export const updateCommand = new Command('update');
6
+ updateCommand.description("Manage service updates");
7
+ updateCommand.addCommand(checkCommand);
8
+ updateCommand.addCommand(applyCommand);
9
+ updateCommand.addCommand(statusCommand);
@@ -0,0 +1,38 @@
1
+ import { Command } from 'commander';
2
+ import { loadConfig } from '../../helpers/config.js';
3
+ import { compareVersions, loadVersions } from '../../helpers/version.js';
4
+ import { getLatestRelease } from '../../helpers/version.js';
5
+ export const statusCommand = new Command('status')
6
+ .description('Mostra lo stato degli aggiornamenti dei servizi')
7
+ .action(async () => {
8
+ try {
9
+ const cfg = loadConfig();
10
+ const token = cfg['auth.gitlabToken'];
11
+ const rawServices = cfg['services'];
12
+ if (!token) {
13
+ //TODO: HANDLE THIS CASE
14
+ console.error('HANDLE THIS CASE');
15
+ process.exit(1);
16
+ }
17
+ if (!rawServices) {
18
+ console.error('Nessun servizio installabile');
19
+ process.exit(1);
20
+ }
21
+ const services = String(rawServices).trim().split(',');
22
+ const versions = loadVersions();
23
+ for (const service of services) {
24
+ const current = versions[service] ?? 'non installato';
25
+ const latest = await getLatestRelease(service, token);
26
+ if (!latest) {
27
+ console.log(`${service}: locale=${current} | GitLab=non trovato`);
28
+ continue;
29
+ }
30
+ const cmp = compareVersions(current, latest);
31
+ const indicator = cmp === 0 ? '✓' : cmp > 0 ? '⚠' : '↑';
32
+ console.log(`${indicator} ${service}: locale=${current} | latest=${latest}`);
33
+ }
34
+ }
35
+ catch (err) {
36
+ console.error(err instanceof Error ? err.message : String(err));
37
+ }
38
+ });
@@ -0,0 +1,61 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { CONFIG_SCHEMA } from "./configSchema.js";
4
+ import { confirm, input, password } from "@inquirer/prompts";
5
+ const CONFIG_PATH = path.join('/etc', 'haltera', 'config.json');
6
+ export function loadConfig() {
7
+ if (!fs.existsSync(CONFIG_PATH)) {
8
+ throw new Error(`Config non trovata in ${CONFIG_PATH}. Esegui prima: haltera config init`);
9
+ }
10
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
11
+ }
12
+ export function saveConfig(config) {
13
+ if (!fs.existsSync(CONFIG_PATH)) {
14
+ fs.mkdirSync(CONFIG_PATH, { recursive: true });
15
+ }
16
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
17
+ }
18
+ export function redactConfig(obj) {
19
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => {
20
+ const fullKey = k;
21
+ return [k, CONFIG_SCHEMA[fullKey]?.sensitive ? '***' : v];
22
+ }));
23
+ }
24
+ export async function promptParam(param, currentValue) {
25
+ const hint = param.default ?? currentValue;
26
+ const message = `${param.description}:`;
27
+ switch (param.type) {
28
+ case 'boolean':
29
+ return confirm({
30
+ message,
31
+ default: hint ?? false
32
+ });
33
+ case 'number':
34
+ return input({
35
+ message,
36
+ default: hint,
37
+ validate: (v) => {
38
+ if (isNaN(Number(v)))
39
+ return 'Valore numerico richiesto';
40
+ return param.validate ? param.validate(Number(v)) : true;
41
+ }
42
+ });
43
+ default:
44
+ if (param.sensitive) {
45
+ return password({
46
+ message,
47
+ mask: '*',
48
+ validate: (v) => {
49
+ if (!v)
50
+ return true;
51
+ return param.validate ? param.validate(v) : true;
52
+ }
53
+ });
54
+ }
55
+ return input({
56
+ message,
57
+ default: hint,
58
+ validate: (v) => param.validate ? param.validate(v) : true,
59
+ });
60
+ }
61
+ }
@@ -0,0 +1,152 @@
1
+ export const CONFIG_SCHEMA = {
2
+ // STATION
3
+ 'station.id': {
4
+ description: 'Identificatore univoco della station',
5
+ type: 'string',
6
+ validate: (v) => (typeof v === 'string' && v.length > 0) || 'Deve essere una stringa non vuota',
7
+ },
8
+ 'station.name': {
9
+ description: 'Nome leggibile della stazione',
10
+ type: 'string',
11
+ validate: (v) => (typeof v === 'string' && v.length > 0) || 'Deve essere una stringa non vuota',
12
+ },
13
+ // AUTH
14
+ 'auth.deviceToken': {
15
+ description: 'Token di autenticazione personal del raspberry',
16
+ type: 'string',
17
+ sensitive: true,
18
+ validate: (v) => (typeof v === 'string' && v.length > 0) || 'Deve essere una stringa non vuota',
19
+ },
20
+ 'auth.gitlabToken': {
21
+ description: 'Token temporaneo di autenticazione per GitLab',
22
+ type: 'string',
23
+ sensitive: true,
24
+ hidden: true,
25
+ validate: (v) => (typeof v === 'string' && v.length > 0) || 'Deve essere una stringa non vuota',
26
+ },
27
+ // SERVER
28
+ 'server.url': {
29
+ description: 'URL base del server centrale Haltera',
30
+ type: 'string',
31
+ validate: (v) => {
32
+ try {
33
+ const u = new URL(v);
34
+ return ['http:', 'https:'].includes(u.protocol) || 'Protocollo non valido (http/https)';
35
+ }
36
+ catch {
37
+ return 'URL non valido';
38
+ }
39
+ },
40
+ },
41
+ 'server.retryCount': {
42
+ description: 'Numero di tentativi in caso di errore di comunicazione col server',
43
+ type: 'number',
44
+ default: 3,
45
+ validate: (v) => (Number.isInteger(v) && v >= 0) || 'Deve essere un intero >= 0',
46
+ },
47
+ 'server.retryDelay': {
48
+ description: 'Millisecondi di attesa tra un tentativo e il successivo',
49
+ type: 'number',
50
+ default: 5000,
51
+ validate: (v) => (Number.isInteger(v) && v >= 0) || 'Deve essere un intero >= 0',
52
+ },
53
+ // DATABASE
54
+ 'database.path': {
55
+ description: 'Percorso al file SQLite del Match Station',
56
+ type: 'string',
57
+ validate: (v) => (typeof v === 'string' && v.startsWith('/')) || 'Deve essere un percorso assoluto',
58
+ },
59
+ // SYNC
60
+ 'sync.onStartup': {
61
+ description: 'Esegui una sincronizzazione immediata all\'avvio del service',
62
+ type: 'boolean',
63
+ default: true,
64
+ validate: (v) => typeof v === 'boolean' || 'Deve essere un booleano',
65
+ },
66
+ // PATHS
67
+ 'path.service': {
68
+ description: 'Directory root di installazione dei service Haltera',
69
+ type: 'string',
70
+ validate: (v) => (typeof v === 'string' && v.startsWith('/')) || 'Deve essere un percorso assoluto',
71
+ },
72
+ 'path.tmp': {
73
+ description: 'Directory temporanea per download tarball e file di aggiornamento',
74
+ type: 'string',
75
+ validate: (v) => (typeof v === 'string' && v.startsWith('/')) || 'Deve essere un percorso assoluto',
76
+ },
77
+ 'path.versions': {
78
+ description: 'Percordo al file delle versone deii servizi',
79
+ type: 'string',
80
+ hidden: true,
81
+ validate: (v) => (typeof v === 'string' && v.startsWith('/')) || 'Deve essere un percorso assoluto',
82
+ },
83
+ // UPDATE
84
+ 'update.auto': {
85
+ description: 'Scarica e applica automaticamente gli aggiornamenti disponibili all\'avvio',
86
+ type: 'boolean',
87
+ default: false,
88
+ validate: (v) => typeof v === 'boolean' || 'Deve essere un booleano',
89
+ },
90
+ 'update.channel': {
91
+ description: 'Canale di aggiornamento da seguire (stable = produzione, beta = pre-release)',
92
+ type: 'string',
93
+ default: 'stable',
94
+ validate: (v) => ['stable', 'beta'].includes(v) || 'Valori accettati: stable, beta',
95
+ },
96
+ 'update.restartServiceAfterUpdate': {
97
+ description: 'Riavvia automaticamente i service systemd dopo un aggiornamento completato',
98
+ type: 'boolean',
99
+ default: true,
100
+ validate: (v) => typeof v === 'boolean' || 'Deve essere un booleano',
101
+ },
102
+ // LOGGING
103
+ 'log.level': {
104
+ description: 'Livello minimo di log da registrare (debug include tutti i livelli)',
105
+ type: 'string',
106
+ default: 'info',
107
+ validate: (v) => ['debug', 'info', 'warn', 'error'].includes(v) || 'Valori accettati: debug, info, warn, error',
108
+ },
109
+ 'log.path': {
110
+ description: 'Percorso del file di log principale',
111
+ type: 'string',
112
+ validate: (v) => (typeof v === 'string' && v.startsWith('/')) || 'Deve essere un percorso assoluto',
113
+ },
114
+ // TIME & LOCALE
115
+ 'timezone': {
116
+ description: 'Timezone del device per log e scheduling',
117
+ type: 'string',
118
+ validate: (v) => {
119
+ try {
120
+ new Intl.DateTimeFormat(undefined, { timeZone: v });
121
+ return true;
122
+ }
123
+ catch {
124
+ return 'Timezone non valida';
125
+ }
126
+ },
127
+ },
128
+ 'locale': {
129
+ description: 'Locale per la formattazione di date e numeri',
130
+ type: 'string',
131
+ validate: (v) => /^[a-z]{2}-[A-Z]{2}$/.test(v) || 'Formato atteso: it-IT, en-US, ecc.',
132
+ },
133
+ // NETWORK
134
+ 'network.wifiSSID': {
135
+ description: 'SSID della rete WiFi a cui connettersi automaticamente all\'avvio',
136
+ type: 'string',
137
+ validate: (v) => typeof v === 'string' || 'Deve essere una stringa',
138
+ },
139
+ 'network.wifiPassword': {
140
+ description: 'Password della rete WiFi (usata da nmcli per la connessione automatica)',
141
+ type: 'string',
142
+ sensitive: true,
143
+ validate: (v) => typeof v === 'string' || 'Deve essere una stringa',
144
+ },
145
+ // SERVICES
146
+ 'services': {
147
+ description: 'Nomi dei servizi, separati da una virgola',
148
+ type: "string",
149
+ hidden: true,
150
+ validate: (v) => typeof v === 'string' || 'Deve essere una stringa',
151
+ }
152
+ };
@@ -0,0 +1,70 @@
1
+ import { execSync } from "child_process";
2
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs";
3
+ import path from "path";
4
+ export function checkPrerequisites() {
5
+ try {
6
+ execSync('which pnpm', { stdio: 'ignore' });
7
+ }
8
+ catch {
9
+ console.error('pnpm non trovato.');
10
+ execSync('npm install -g pnpm', { stdio: ['ignore', 'ignore', 'inherit'] });
11
+ }
12
+ }
13
+ export function createDirectoriesAndFiles(dirs = [], files = []) {
14
+ for (const dir of dirs) {
15
+ if (!existsSync(dir)) {
16
+ mkdirSync(dir, { recursive: true });
17
+ console.log(`✓ Directory creata: ${dir}`);
18
+ }
19
+ else {
20
+ console.log(`✓ Directory già presente: ${dir}`);
21
+ }
22
+ }
23
+ for (const file of files) {
24
+ if (!existsSync(file)) {
25
+ mkdirSync(path.dirname(file), { recursive: true });
26
+ writeFileSync(file, '', { flag: 'wx' });
27
+ console.log(`✓ File creato: ${file}`);
28
+ }
29
+ else {
30
+ console.log(`✓ File già presente: ${file}`);
31
+ }
32
+ }
33
+ }
34
+ export async function installService(service, version, token, tmpDir, serviceDir) {
35
+ const url = `https://gitlab.com/api/v4/projects/haltera%2F${service}/packages/generic/${service}/${version}/${service}-v${version}.tgz`;
36
+ const tarball = path.join(tmpDir, `${service}-v${version}.tgz`);
37
+ console.log(`↓ Download ${service}@${version}...`);
38
+ const res = await fetch(url, { headers: { 'PRIVATE-TOKEN': token } });
39
+ if (!res.ok)
40
+ throw new Error(`Errore download ${service}: ${res.status}`);
41
+ const buffer = await res.arrayBuffer();
42
+ writeFileSync(tarball, Buffer.from(buffer));
43
+ const dest = path.join(serviceDir, service);
44
+ mkdirSync(dest, { recursive: true });
45
+ console.log(`📦 Estrazione ${service}...`);
46
+ execSync(`tar -xzf ${tarball} -C ${dest} --strip-components=1`);
47
+ console.log(`📦 Installazione dipendenze ${service}...`);
48
+ execSync(`cd ${dest} && pnpm install --frozen-lockfile`, { stdio: ['ignore', 'ignore', 'inherit'] });
49
+ unlinkSync(tarball);
50
+ console.log(`✓ ${service} installato`);
51
+ }
52
+ export function createSystemdService(service, serviceDir) {
53
+ const content = `[Unit]
54
+ Description=Haltera ${service} service
55
+ After=network.target
56
+
57
+ [Service]
58
+ Type=simple
59
+ WorkingDirectory=${path.join(serviceDir, service)}
60
+ ExecStart=/usr/bin/node ${path.join(serviceDir, service, 'dist/index.js')}
61
+ Restart=on-failure
62
+ RestartSec=5s
63
+
64
+ [Install]
65
+ WantedBy=multi-user.target
66
+ `;
67
+ const dest = `/etc/systemd/system/haltera-${service}.service`;
68
+ writeFileSync(dest, content, 'utf-8');
69
+ console.log(`✓ Service systemd creato: ${dest}`);
70
+ }
@@ -0,0 +1,34 @@
1
+ import { execSync } from 'child_process';
2
+ import { loadConfig } from './config.js';
3
+ const DNS_CHECK_URL = 'https://1.1.1.1';
4
+ export async function checkConnectivity() {
5
+ let internet = false;
6
+ try {
7
+ await fetch(DNS_CHECK_URL, { method: 'HEAD', signal: AbortSignal.timeout(3000) });
8
+ internet = true;
9
+ }
10
+ catch {
11
+ return { internet: false, syncServer: false, error: 'Nessuna connessione internet' };
12
+ }
13
+ let syncServer = false;
14
+ try {
15
+ const cfg = loadConfig();
16
+ const url = cfg['server.url'];
17
+ if (!url)
18
+ return { internet, syncServer: false, error: 'server.url non configurato' };
19
+ await fetch(`${url}/health`, { method: 'GET', signal: AbortSignal.timeout(5000) });
20
+ syncServer = true;
21
+ }
22
+ catch {
23
+ return { internet, syncServer: false, error: 'Sync server non raggiungibile' };
24
+ }
25
+ return { internet, syncServer };
26
+ }
27
+ export function requireNmcli() {
28
+ try {
29
+ execSync('which nmcli', { stdio: 'ignore' });
30
+ }
31
+ catch {
32
+ console.error('nmcli non trovato. Installa NetworkManager per usare i comandi di rete.');
33
+ }
34
+ }
@@ -0,0 +1,87 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { loadConfig } from "./config.js";
3
+ import path from "path";
4
+ import { execSync } from "child_process";
5
+ export function loadVersions() {
6
+ try {
7
+ const cfg = loadConfig();
8
+ const versionPath = cfg['path.versions'];
9
+ if (versionPath && existsSync(versionPath)) {
10
+ return JSON.parse(readFileSync(versionPath, 'utf-8'));
11
+ }
12
+ else {
13
+ console.error('Services not found, run `haltera install` to install it');
14
+ process.exit(1);
15
+ }
16
+ }
17
+ catch (err) {
18
+ console.error(err instanceof Error ? err.message : String(err));
19
+ }
20
+ }
21
+ export function saveVersions(versions) {
22
+ try {
23
+ const cfg = loadConfig();
24
+ const versionPath = cfg['path.versions'];
25
+ if (versionPath && existsSync(versionPath)) {
26
+ writeFileSync(versionPath, JSON.stringify(versions, null, 2), 'utf-8');
27
+ }
28
+ else {
29
+ console.error('Services not found, run `haltera install` to install it');
30
+ process.exit(1);
31
+ }
32
+ }
33
+ catch (err) {
34
+ console.error(err instanceof Error ? err.message : String(err));
35
+ }
36
+ }
37
+ export async function getLatestRelease(service, token) {
38
+ const url = `https://gitlab.com/api/v4/projects/haltera%2F${service}/releases?per_page=1`;
39
+ const res = await fetch(url, {
40
+ headers: { 'PRIVATE-TOKEN': token }
41
+ });
42
+ if (!res.ok)
43
+ throw new Error(`Errore GitLab API per ${service}: ${res.status}`);
44
+ const releases = await res.json();
45
+ if (!releases.length)
46
+ return null;
47
+ const tag = releases[0].tag_name;
48
+ return tag.startsWith('v') ? tag.slice(1) : tag;
49
+ }
50
+ export async function downloadTarball(service, version, token, dir) {
51
+ const url = `https://gitlab.com/api/v4/projects/haltera%2F${service}/packages/generic/${service}/${version}/${service}-v${version}.tgz`;
52
+ const dest = path.join(dir, `${service}-v${version}.tgz`);
53
+ const res = await fetch(url, {
54
+ headers: { 'PRIVATE-TOKEN': token }
55
+ });
56
+ if (!res.ok)
57
+ throw new Error(`Errore download ${service}: ${res.status}`);
58
+ const buffer = await res.arrayBuffer();
59
+ writeFileSync(dest, Buffer.from(buffer));
60
+ return dest;
61
+ }
62
+ export async function applyUpdate(service, tarball, dir) {
63
+ const dest = path.join(dir, service);
64
+ mkdirSync(dest, { recursive: true });
65
+ execSync(`tar -xzf ${tarball} -C ${dest} --strip-components=1`, { stdio: 'inherit' });
66
+ execSync(`pnpm install --frozen-lockfile --prefix ${dest}`, { stdio: 'ignore' });
67
+ try {
68
+ execSync(`systemctl restart haltera-${service}`, { stdio: 'inherit' });
69
+ }
70
+ catch (err) {
71
+ console.error(`Error restarting the service, try restarting it manually with: \`systemctl restart haltera-${service}\``);
72
+ }
73
+ }
74
+ export function compareVersions(a, b) {
75
+ const pa = a.split('.').map(Number);
76
+ const pb = b.split('.').map(Number);
77
+ for (let i = 0; i < 3; i++) {
78
+ if (pa[i] > pb[i])
79
+ return 1;
80
+ if (pa[i] < pb[i])
81
+ return -1;
82
+ }
83
+ return 0;
84
+ }
85
+ export async function checkDeviceToken(token) {
86
+ return true;
87
+ }
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import 'dotenv/config';
4
+ import { commands } from './commands/index.js';
5
+ function main() {
6
+ const program = new Command();
7
+ program.name('haltera').description('CLI per gestire Haltera su Raspberry').version("1.0.0");
8
+ commands.forEach(command => program.addCommand(command));
9
+ program.parse(process.argv);
10
+ }
11
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haltera/cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Handle haltera operativity via CLI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,9 +14,9 @@
14
14
  "test": "echo \"Error: no test specified\" && exit 1",
15
15
  "build": "tsc && chmod +x ./dist/index.js"
16
16
  },
17
- "keywords": [ "haltera" ],
17
+ "keywords": [ "haltera", "albertigroup", "gym", "match software", "competitions" ],
18
18
  "author": "Alberti Group",
19
- "license": "ISC",
19
+ "license": "UNLICENSED",
20
20
  "packageManager": "pnpm@10.33.0",
21
21
  "dependencies": {
22
22
  "@inquirer/core": "^11.1.7",