@danieltmn/tamnora 0.21.0 → 0.21.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/CHANGELOG.md +6 -0
- package/harness/packages/cli/src/cli.js +3 -0
- package/harness/packages/cli/src/commands/config.js +141 -0
- package/harness/packages/core/src/config/portable.js +138 -0
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/test/portable.test.js +150 -0
- package/harness/packages/server/public/index.html +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.21.1] - 2026-10-16
|
|
9
|
+
|
|
10
|
+
### Cambios
|
|
11
|
+
|
|
12
|
+
- feat(config): export/import de la configuracion de la casa
|
|
13
|
+
|
|
8
14
|
## [0.21.0] - 2026-10-14
|
|
9
15
|
|
|
10
16
|
### Cambios
|
|
@@ -10,6 +10,7 @@ import { bridgeCommand } from './commands/bridge.js';
|
|
|
10
10
|
import { updateCommand } from './commands/update.js';
|
|
11
11
|
import { autostartCommand } from './commands/autostart.js';
|
|
12
12
|
import { doctorCommand } from './commands/doctor.js';
|
|
13
|
+
import { configCommand } from './commands/config.js';
|
|
13
14
|
import { tuiCommand } from './commands/tui.js';
|
|
14
15
|
import { ensureUtf8Console } from './lib/util.js';
|
|
15
16
|
import { readPackageVersion } from './lib/core.js';
|
|
@@ -29,6 +30,7 @@ const COMMANDS = {
|
|
|
29
30
|
update: updateCommand,
|
|
30
31
|
autostart: autostartCommand,
|
|
31
32
|
doctor: doctorCommand,
|
|
33
|
+
config: configCommand,
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
function readVersion() {
|
|
@@ -76,6 +78,7 @@ Uso:
|
|
|
76
78
|
tamnora autostart install|remove Arranque automatico del server
|
|
77
79
|
tamnora serve Consola web local (primer plano)
|
|
78
80
|
tamnora doctor Verificar Node, casa, puerto y base
|
|
81
|
+
tamnora config export|import Exportar/importar la configuracion (proveedores + ajustes)
|
|
79
82
|
tamnora update Buscar/instalar una version nueva
|
|
80
83
|
tamnora init Crear tamnora.json
|
|
81
84
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
appConfigPath,
|
|
5
|
+
databasePath,
|
|
6
|
+
exportConfig,
|
|
7
|
+
homeDir,
|
|
8
|
+
importConfig,
|
|
9
|
+
Store,
|
|
10
|
+
} from '../../../core/src/index.js';
|
|
11
|
+
import { parse } from '../lib/util.js';
|
|
12
|
+
import { applyHomeOverride, stopRunningServer } from '../lib/daemon.js';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_FILE = 'tamnora-config.json';
|
|
15
|
+
|
|
16
|
+
const OPTIONS = {
|
|
17
|
+
home: { type: 'string' },
|
|
18
|
+
out: { type: 'string', short: 'o' },
|
|
19
|
+
'with-secrets': { type: 'boolean' },
|
|
20
|
+
stdout: { type: 'boolean' },
|
|
21
|
+
'dry-run': { type: 'boolean' },
|
|
22
|
+
help: { type: 'boolean', short: 'h' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function configCommand(argv) {
|
|
26
|
+
const { values, positionals } = parse(argv, OPTIONS);
|
|
27
|
+
if (values.help) {
|
|
28
|
+
printHelp();
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
applyHomeOverride(values);
|
|
32
|
+
|
|
33
|
+
const sub = (positionals[0] || 'path').toLowerCase();
|
|
34
|
+
const target = positionals[1] || values.out;
|
|
35
|
+
|
|
36
|
+
if (sub === 'export') return doExport(values, target);
|
|
37
|
+
if (sub === 'import') return doImport(values, target);
|
|
38
|
+
if (sub === 'path') {
|
|
39
|
+
printPaths();
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
printHelp();
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function withStore(fn) {
|
|
47
|
+
const store = new Store(databasePath());
|
|
48
|
+
try {
|
|
49
|
+
return fn(store);
|
|
50
|
+
} finally {
|
|
51
|
+
store.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function doExport(values, target) {
|
|
56
|
+
const data = withStore((store) => exportConfig({ store, withSecrets: Boolean(values['with-secrets']) }));
|
|
57
|
+
const json = `${JSON.stringify(data, null, 2)}\n`;
|
|
58
|
+
if (values.stdout || target === '-') {
|
|
59
|
+
process.stdout.write(json);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
const file = path.resolve(target || DEFAULT_FILE);
|
|
63
|
+
writeFileSync(file, json);
|
|
64
|
+
console.log(`Configuracion exportada a ${file}`);
|
|
65
|
+
console.log(` proveedores: ${data.providers.length} · ajustes: ${Object.keys(data.settings).length}`);
|
|
66
|
+
console.log(` secretos: ${data.secrets ? 'incluidos' : 'enmascarados (usa --with-secrets para incluirlos)'}`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function doImport(values, target) {
|
|
71
|
+
if (!target) {
|
|
72
|
+
console.error('Falta el archivo: tamnora config import <archivo.json>');
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
const file = path.resolve(target);
|
|
76
|
+
let data;
|
|
77
|
+
try {
|
|
78
|
+
data = JSON.parse(readFileSync(file, 'utf8'));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(`No pude leer ${file}: ${error.message}`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const dryRun = Boolean(values['dry-run']);
|
|
85
|
+
if (!dryRun && stopRunningServer()) {
|
|
86
|
+
console.log('Habia un server corriendo; lo detuve para aplicar los cambios.');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let summary;
|
|
90
|
+
try {
|
|
91
|
+
summary = withStore((store) => importConfig(store, data, { dryRun }));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error(error.message);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (dryRun) {
|
|
98
|
+
console.log('Simulacion (--dry-run): no se cambio nada.');
|
|
99
|
+
printSummary(summary);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
printSummary(summary);
|
|
103
|
+
console.log('Volve a arrancar el server: tamnora server --start');
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function printSummary(summary) {
|
|
108
|
+
console.log(`Importado: ${summary.providers} proveedor(es), ${summary.settings} ajuste(s), app.json actualizado.`);
|
|
109
|
+
if (summary.missingKeys.length) {
|
|
110
|
+
console.log(` Ojo: ${summary.missingKeys.join(', ')} quedaron sin API key (venian enmascaradas); cargalas en la consola.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function printPaths() {
|
|
115
|
+
console.log(`Casa de Tamnora : ${homeDir()}`);
|
|
116
|
+
console.log(` config app : ${appConfigPath()}`);
|
|
117
|
+
console.log(` base de datos : ${databasePath()}`);
|
|
118
|
+
console.log(' por proyecto : <cwd>/tamnora.json y <cwd>/.tamnora/');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function printHelp() {
|
|
122
|
+
console.log(`Uso: tamnora config <export|import|path> [archivo] [opciones]
|
|
123
|
+
|
|
124
|
+
Exporta o importa la configuracion de la casa (app.json + proveedores + ajustes).
|
|
125
|
+
No incluye sesiones, historial, MCP servers ni tareas programadas.
|
|
126
|
+
|
|
127
|
+
export [archivo] Vuelca la configuracion a JSON (default: ${DEFAULT_FILE})
|
|
128
|
+
import <archivo> Aplica el JSON sobre esta instalacion
|
|
129
|
+
path Muestra donde vive la configuracion
|
|
130
|
+
|
|
131
|
+
Opciones:
|
|
132
|
+
--with-secrets Incluye API keys y secretos reales (default: enmascarados)
|
|
133
|
+
-o, --out <archivo> Archivo de salida para export
|
|
134
|
+
--stdout Imprime el JSON por stdout (export)
|
|
135
|
+
--dry-run Muestra que haria el import sin escribir nada
|
|
136
|
+
--home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
|
|
137
|
+
|
|
138
|
+
Ejemplos:
|
|
139
|
+
tamnora config export tamnora-config.json --with-secrets
|
|
140
|
+
tamnora config import tamnora-config.json`);
|
|
141
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readApp, writeApp } from './app.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Export/import portable de la configuracion de la casa (app.json + tablas
|
|
5
|
+
* `providers` y `settings` de la base global). Los secretos (API keys, hash de
|
|
6
|
+
* usuario, token de Telegram, `authSecret`) se enmascaran salvo `withSecrets`;
|
|
7
|
+
* al importar, los valores enmascarados se saltan y se conserva lo existente.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const CONFIG_KIND = 'tamnora-config';
|
|
11
|
+
export const CONFIG_VERSION = 1;
|
|
12
|
+
export const MASKED = '<<tamnora:secreto>>';
|
|
13
|
+
|
|
14
|
+
const LINKAGE_PREFIXES = ['telegram:', 'scheduleSession:'];
|
|
15
|
+
|
|
16
|
+
function isSecretValue(value) {
|
|
17
|
+
return typeof value === 'string' && value !== '' && !value.startsWith('env:');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isLinkageKey(key) {
|
|
21
|
+
return LINKAGE_PREFIXES.some((prefix) => key.startsWith(prefix));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function maskApp(app) {
|
|
25
|
+
const out = structuredClone(app);
|
|
26
|
+
if (isSecretValue(out.authSecret)) out.authSecret = MASKED;
|
|
27
|
+
if (out.user) out.user = MASKED;
|
|
28
|
+
if (out.telegram && isSecretValue(out.telegram.token)) out.telegram.token = MASKED;
|
|
29
|
+
if (out.tunnel && isSecretValue(out.tunnel.token)) out.tunnel.token = MASKED;
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function providerToPortable(provider, withSecrets) {
|
|
34
|
+
const apiKey = withSecrets || !isSecretValue(provider.apiKey) ? provider.apiKey ?? '' : MASKED;
|
|
35
|
+
return {
|
|
36
|
+
id: provider.id,
|
|
37
|
+
label: provider.label,
|
|
38
|
+
baseUrl: provider.baseUrl ?? '',
|
|
39
|
+
apiKey,
|
|
40
|
+
models: provider.models ?? [],
|
|
41
|
+
requiresKey: provider.requiresKey,
|
|
42
|
+
custom: Boolean(provider.custom),
|
|
43
|
+
enabled: provider.enabled !== false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function exportableSettings(all) {
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const [key, value] of Object.entries(all)) {
|
|
50
|
+
if (isLinkageKey(key)) continue;
|
|
51
|
+
out[key] = value;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizePayload(data) {
|
|
57
|
+
if (!data || typeof data !== 'object') {
|
|
58
|
+
throw new Error('El archivo no es un objeto JSON valido.');
|
|
59
|
+
}
|
|
60
|
+
if (data.kind !== CONFIG_KIND) {
|
|
61
|
+
throw new Error(`No parece un export de Tamnora (kind: ${data.kind ?? 'desconocido'}).`);
|
|
62
|
+
}
|
|
63
|
+
if (Number(data.version) > CONFIG_VERSION) {
|
|
64
|
+
throw new Error(`Version de export no soportada: ${data.version} (max ${CONFIG_VERSION}).`);
|
|
65
|
+
}
|
|
66
|
+
return data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function mergeMaskAware(target, source) {
|
|
70
|
+
for (const [key, value] of Object.entries(source)) {
|
|
71
|
+
if (value === MASKED) continue;
|
|
72
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
73
|
+
const base = target[key] && typeof target[key] === 'object' && !Array.isArray(target[key]) ? target[key] : {};
|
|
74
|
+
target[key] = mergeMaskAware({ ...base }, value);
|
|
75
|
+
} else {
|
|
76
|
+
target[key] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return target;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Arma el payload portable a partir del store y `app.json`. */
|
|
83
|
+
export function exportConfig({ store, withSecrets = false, now } = {}) {
|
|
84
|
+
const app = readApp();
|
|
85
|
+
return {
|
|
86
|
+
kind: CONFIG_KIND,
|
|
87
|
+
version: CONFIG_VERSION,
|
|
88
|
+
exportedAt: new Date(now ?? Date.now()).toISOString(),
|
|
89
|
+
secrets: withSecrets,
|
|
90
|
+
app: withSecrets ? app : maskApp(app),
|
|
91
|
+
providers: store.listProviders().map((provider) => providerToPortable(provider, withSecrets)),
|
|
92
|
+
settings: exportableSettings(store.allSettings()),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Aplica un payload portable sobre el store y `app.json`. Con `dryRun` no
|
|
98
|
+
* escribe nada. Devuelve un resumen (proveedores, ajustes y claves faltantes).
|
|
99
|
+
*/
|
|
100
|
+
export function importConfig(store, data, { dryRun = false } = {}) {
|
|
101
|
+
const payload = normalizePayload(data);
|
|
102
|
+
const summary = { app: true, providers: 0, settings: 0, missingKeys: [] };
|
|
103
|
+
|
|
104
|
+
const app = readApp();
|
|
105
|
+
mergeMaskAware(app, payload.app ?? {});
|
|
106
|
+
|
|
107
|
+
for (const provider of payload.providers ?? []) {
|
|
108
|
+
if (!provider?.id) continue;
|
|
109
|
+
const existing = store.getProvider(provider.id);
|
|
110
|
+
let apiKey = provider.apiKey ?? '';
|
|
111
|
+
if (apiKey === MASKED) {
|
|
112
|
+
apiKey = existing?.apiKey ?? '';
|
|
113
|
+
if (!apiKey) summary.missingKeys.push(provider.id);
|
|
114
|
+
}
|
|
115
|
+
if (!dryRun) {
|
|
116
|
+
store.saveProvider({
|
|
117
|
+
id: provider.id,
|
|
118
|
+
label: provider.label,
|
|
119
|
+
baseUrl: provider.baseUrl,
|
|
120
|
+
apiKey,
|
|
121
|
+
models: provider.models,
|
|
122
|
+
requiresKey: provider.requiresKey,
|
|
123
|
+
custom: provider.custom,
|
|
124
|
+
enabled: provider.enabled !== false,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
summary.providers += 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const [key, value] of Object.entries(payload.settings ?? {})) {
|
|
131
|
+
if (isLinkageKey(key)) continue;
|
|
132
|
+
if (!dryRun) store.setSetting(key, value);
|
|
133
|
+
summary.settings += 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!dryRun) writeApp(app);
|
|
137
|
+
return summary;
|
|
138
|
+
}
|
|
@@ -70,6 +70,7 @@ export {
|
|
|
70
70
|
migrateLegacyDb,
|
|
71
71
|
} from './config/home.js';
|
|
72
72
|
export { DEFAULT_APP, readApp, writeApp, ensureApp, randomToken } from './config/app.js';
|
|
73
|
+
export { exportConfig, importConfig, CONFIG_KIND, CONFIG_VERSION, MASKED } from './config/portable.js';
|
|
73
74
|
export { hashPassword, verifyPassword, userValidName, makeUser } from './config/identity.js';
|
|
74
75
|
export { composeProviderSettings } from './config/providers.js';
|
|
75
76
|
export {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { test, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
setHome,
|
|
8
|
+
databasePath,
|
|
9
|
+
Store,
|
|
10
|
+
ensureApp,
|
|
11
|
+
readApp,
|
|
12
|
+
writeApp,
|
|
13
|
+
exportConfig,
|
|
14
|
+
importConfig,
|
|
15
|
+
MASKED,
|
|
16
|
+
} from '../src/index.js';
|
|
17
|
+
|
|
18
|
+
const prevHome = process.env.TAMNORA_HOME;
|
|
19
|
+
const prevDb = process.env.TAMNORA_DB;
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
setHome(null);
|
|
23
|
+
if (prevHome === undefined) delete process.env.TAMNORA_HOME;
|
|
24
|
+
else process.env.TAMNORA_HOME = prevHome;
|
|
25
|
+
if (prevDb === undefined) delete process.env.TAMNORA_DB;
|
|
26
|
+
else process.env.TAMNORA_DB = prevDb;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
async function freshHome() {
|
|
30
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-portable-'));
|
|
31
|
+
setHome(dir);
|
|
32
|
+
process.env.TAMNORA_DB = path.join(dir, 'tamnora.db');
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('exportConfig enmascara los secretos y respeta las referencias env:', async () => {
|
|
37
|
+
await freshHome();
|
|
38
|
+
ensureApp();
|
|
39
|
+
const app = readApp();
|
|
40
|
+
app.user = { name: 'daniel', password: { algo: 'scrypt', salt: 'x', hash: 'y' } };
|
|
41
|
+
app.telegram = { token: '123:abc', allowedChatIds: ['7'] };
|
|
42
|
+
writeApp(app);
|
|
43
|
+
|
|
44
|
+
const store = new Store(databasePath());
|
|
45
|
+
store.saveProvider({ id: 'deepseek', apiKey: 'sk-real', baseUrl: 'https://deepseek', enabled: true });
|
|
46
|
+
store.saveProvider({ id: 'env', apiKey: 'env:OPENAI_API_KEY' });
|
|
47
|
+
store.setSetting('activeProvider', 'deepseek');
|
|
48
|
+
store.setSetting('telegram:chat:7', 'sess-1');
|
|
49
|
+
const data = exportConfig({ store, now: 0 });
|
|
50
|
+
store.close();
|
|
51
|
+
|
|
52
|
+
assert.equal(data.kind, 'tamnora-config');
|
|
53
|
+
assert.equal(data.version, 1);
|
|
54
|
+
assert.equal(data.secrets, false);
|
|
55
|
+
assert.equal(data.app.authSecret, MASKED);
|
|
56
|
+
assert.equal(data.app.user, MASKED);
|
|
57
|
+
assert.equal(data.app.telegram.token, MASKED);
|
|
58
|
+
assert.deepEqual(data.app.telegram.allowedChatIds, ['7']);
|
|
59
|
+
assert.equal(data.providers.find((p) => p.id === 'deepseek').apiKey, MASKED);
|
|
60
|
+
assert.equal(data.providers.find((p) => p.id === 'env').apiKey, 'env:OPENAI_API_KEY');
|
|
61
|
+
assert.equal(data.settings.activeProvider, 'deepseek');
|
|
62
|
+
assert.equal('telegram:chat:7' in data.settings, false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('exportConfig con withSecrets incluye las claves reales', async () => {
|
|
66
|
+
await freshHome();
|
|
67
|
+
ensureApp();
|
|
68
|
+
const app = readApp();
|
|
69
|
+
app.authSecret = 'super-secret';
|
|
70
|
+
writeApp(app);
|
|
71
|
+
|
|
72
|
+
const store = new Store(databasePath());
|
|
73
|
+
store.saveProvider({ id: 'deepseek', apiKey: 'sk-real' });
|
|
74
|
+
const data = exportConfig({ store, withSecrets: true });
|
|
75
|
+
store.close();
|
|
76
|
+
|
|
77
|
+
assert.equal(data.secrets, true);
|
|
78
|
+
assert.equal(data.app.authSecret, 'super-secret');
|
|
79
|
+
assert.equal(data.providers[0].apiKey, 'sk-real');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('importConfig aplica proveedores y ajustes y conserva los secretos enmascarados', async () => {
|
|
83
|
+
await freshHome();
|
|
84
|
+
ensureApp();
|
|
85
|
+
const current = readApp();
|
|
86
|
+
current.authSecret = 'keep-me';
|
|
87
|
+
writeApp(current);
|
|
88
|
+
|
|
89
|
+
const store = new Store(databasePath());
|
|
90
|
+
store.saveProvider({ id: 'deepseek', apiKey: 'old-key' });
|
|
91
|
+
store.close();
|
|
92
|
+
|
|
93
|
+
const payload = {
|
|
94
|
+
kind: 'tamnora-config',
|
|
95
|
+
version: 1,
|
|
96
|
+
app: { port: 4599, authSecret: MASKED, user: MASKED, profile: { name: 'Daniel' } },
|
|
97
|
+
providers: [
|
|
98
|
+
{ id: 'deepseek', baseUrl: 'https://api.deepseek.com', apiKey: MASKED, models: ['chat'], enabled: true },
|
|
99
|
+
{ id: 'groq', baseUrl: 'https://groq', apiKey: 'gsk-new', enabled: true },
|
|
100
|
+
],
|
|
101
|
+
settings: { activeProvider: 'groq', strictScope: true, 'telegram:chat:7': 'ignored' },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const store2 = new Store(databasePath());
|
|
105
|
+
const summary = importConfig(store2, payload);
|
|
106
|
+
const deepseek = store2.getProvider('deepseek');
|
|
107
|
+
const groq = store2.getProvider('groq');
|
|
108
|
+
const strict = store2.getSetting('strictScope');
|
|
109
|
+
store2.close();
|
|
110
|
+
|
|
111
|
+
assert.equal(summary.providers, 2);
|
|
112
|
+
assert.equal(summary.settings, 2);
|
|
113
|
+
assert.equal(readApp().port, 4599);
|
|
114
|
+
assert.equal(readApp().authSecret, 'keep-me');
|
|
115
|
+
assert.equal(readApp().profile.name, 'Daniel');
|
|
116
|
+
assert.equal(deepseek.apiKey, 'old-key');
|
|
117
|
+
assert.equal(deepseek.baseUrl, 'https://api.deepseek.com');
|
|
118
|
+
assert.equal(groq.apiKey, 'gsk-new');
|
|
119
|
+
assert.equal(strict, true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('importConfig avisa cuando una clave enmascarada no tiene existente y no escribe con dryRun', async () => {
|
|
123
|
+
await freshHome();
|
|
124
|
+
const store = new Store(databasePath());
|
|
125
|
+
const payload = {
|
|
126
|
+
kind: 'tamnora-config',
|
|
127
|
+
version: 1,
|
|
128
|
+
providers: [{ id: 'nuevo', apiKey: MASKED }],
|
|
129
|
+
settings: { activeModel: 'x' },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const dry = importConfig(store, payload, { dryRun: true });
|
|
133
|
+
assert.deepEqual(dry.missingKeys, ['nuevo']);
|
|
134
|
+
assert.equal(store.getProvider('nuevo'), null);
|
|
135
|
+
assert.equal(store.getSetting('activeModel'), null);
|
|
136
|
+
|
|
137
|
+
const real = importConfig(store, payload);
|
|
138
|
+
assert.deepEqual(real.missingKeys, ['nuevo']);
|
|
139
|
+
assert.ok(store.getProvider('nuevo'));
|
|
140
|
+
assert.equal(store.getSetting('activeModel'), 'x');
|
|
141
|
+
store.close();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('importConfig rechaza archivos que no son un export de Tamnora', async () => {
|
|
145
|
+
await freshHome();
|
|
146
|
+
const store = new Store(databasePath());
|
|
147
|
+
assert.throws(() => importConfig(store, { hello: 'world' }), /no parece un export/i);
|
|
148
|
+
assert.throws(() => importConfig(store, { kind: 'tamnora-config', version: 99 }), /no soportada/i);
|
|
149
|
+
store.close();
|
|
150
|
+
});
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
</head>
|
|
76
76
|
<body>
|
|
77
77
|
<header class="topbar">
|
|
78
|
-
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.21.
|
|
78
|
+
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.21.1</span></div>
|
|
79
79
|
<nav class="tabs">
|
|
80
80
|
<button class="tab active" data-tab="chat">chat</button>
|
|
81
81
|
<button class="tab" data-tab="providers">proveedores</button>
|
package/package.json
CHANGED