@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 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.0</span></div>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",