llm-tuner 0.2.0 → 0.3.0
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/package.json +1 -1
- package/src/cli/index.js +19 -5
- package/src/core/index.js +1 -1
- package/src/core/updater.js +99 -0
- package/src/i18n/en.js +5 -0
- package/src/i18n/es.js +5 -0
- package/src/web/public/app.js +16 -0
- package/src/web/public/index.html +7 -0
- package/src/web/public/style.css +42 -16
- package/src/web/server.js +3 -1
package/package.json
CHANGED
package/src/cli/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { select, input, confirm, search } from '@inquirer/prompts';
|
|
3
|
-
import { detectHardware, detectEngines, Tuner, installPlans, runInstall, listPresets, presetsDir, fmtBytes, readSettings, writeSettings, THEMES, PROFILES, DEFAULT_PROFILE, TunerError } from '../core/index.js';
|
|
3
|
+
import { detectHardware, detectEngines, Tuner, installPlans, runInstall, listPresets, presetsDir, fmtBytes, readSettings, writeSettings, THEMES, PROFILES, DEFAULT_PROFILE, TunerError, checkUpdate, CURRENT_VERSION } from '../core/index.js';
|
|
4
4
|
import { t as translate, LOCALES, errorText } from '../i18n/index.js';
|
|
5
5
|
import { profileChoices, profileNotes, profileSwitchNote, profileEventLines, shouldAskProfile, parseContext } from './profile.js';
|
|
6
6
|
|
|
@@ -34,12 +34,18 @@ const isLight = () => theme === 'light';
|
|
|
34
34
|
const c = {
|
|
35
35
|
b: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
36
36
|
dim: (s) => isLight() ? `\x1b[90m${s}\x1b[0m` : `\x1b[2m${s}\x1b[0m`,
|
|
37
|
-
g: (s) => isLight() ? `\x1b[
|
|
37
|
+
g: (s) => isLight() ? `\x1b[38;2;40;138;0m${s}\x1b[0m` : `\x1b[38;2;101;255;0m${s}\x1b[0m`,
|
|
38
38
|
y: (s) => isLight() ? `\x1b[33m${s}\x1b[0m` : `\x1b[93m${s}\x1b[0m`,
|
|
39
|
-
r: (s) => isLight() ? `\x1b[
|
|
40
|
-
cyan: (s) => isLight() ? `\x1b[
|
|
39
|
+
r: (s) => isLight() ? `\x1b[38;2;214;21;0m${s}\x1b[0m` : `\x1b[38;2;255;26;0m${s}\x1b[0m`,
|
|
40
|
+
cyan: (s) => isLight() ? `\x1b[38;2;0;124;153m${s}\x1b[0m` : `\x1b[38;2;0;229;255m${s}\x1b[0m`,
|
|
41
|
+
purple: (s) => isLight() ? `\x1b[38;2;120;0;199m${s}\x1b[0m` : `\x1b[38;2;154;0;255m${s}\x1b[0m`,
|
|
41
42
|
};
|
|
42
43
|
|
|
44
|
+
if (args.version) {
|
|
45
|
+
console.log(CURRENT_VERSION);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
if (args.help) {
|
|
44
50
|
console.log(t('cli.help', { presetsDir: presetsDir() }));
|
|
45
51
|
process.exit(0);
|
|
@@ -114,6 +120,7 @@ async function settingsFlow() {
|
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
async function main() {
|
|
123
|
+
const updatePromise = checkUpdate().catch(() => null);
|
|
117
124
|
const isTTY = Boolean(process.stdout.isTTY && !args.json);
|
|
118
125
|
let lastProgress = null;
|
|
119
126
|
let lastTelemetry = null;
|
|
@@ -163,6 +170,12 @@ async function main() {
|
|
|
163
170
|
'',
|
|
164
171
|
].join('\n'));
|
|
165
172
|
|
|
173
|
+
const updateInfo = await updatePromise;
|
|
174
|
+
if (updateInfo?.updateAvailable) {
|
|
175
|
+
out(c.y(` ▲ ${t('cli.updateAvailable', { current: updateInfo.currentVersion, latest: updateInfo.latestVersion })}`));
|
|
176
|
+
out(c.dim(` ${t('cli.updateCommand', { cmd: 'npm install -g llm-tuner' })}\n`));
|
|
177
|
+
}
|
|
178
|
+
|
|
166
179
|
const hw = await detectHardware();
|
|
167
180
|
out(c.dim(` ${hw.cpu.brand} · ${fmtBytes(hw.ram.totalBytes)} RAM`));
|
|
168
181
|
for (const g of hw.gpus) out(c.dim(` GPU${g.index} ${g.name} · ${fmtBytes(g.totalBytes)}${g.pcieGen ? ` · PCIe ${g.pcieGen}.0 x${g.pcieWidth}` : ''}${g.displayAttached ? t('cli.display') : ''}`));
|
|
@@ -343,11 +356,12 @@ function vramStr(peaks = {}) {
|
|
|
343
356
|
}
|
|
344
357
|
|
|
345
358
|
function parseArgs(argv) {
|
|
346
|
-
const flags = new Set(['help', 'yes', 'dry-run', 'json', 'force', 'presets', 'settings']);
|
|
359
|
+
const flags = new Set(['help', 'version', 'yes', 'dry-run', 'json', 'force', 'presets', 'settings']);
|
|
347
360
|
const o = {};
|
|
348
361
|
for (let i = 0; i < argv.length; i++) {
|
|
349
362
|
const a = argv[i];
|
|
350
363
|
if (a === '-h') o.help = true;
|
|
364
|
+
if (a === '-v') o.version = true;
|
|
351
365
|
if (a === '/settings' || a === '/setting' || a === 'settings') o.settings = true;
|
|
352
366
|
if (!a.startsWith('--')) continue;
|
|
353
367
|
const k = a.slice(2);
|
package/src/core/index.js
CHANGED
|
@@ -9,4 +9,4 @@ export { findPreset, savePreset, listPresets, presetsDir, presetPath, variantsSi
|
|
|
9
9
|
export { readSettings, writeSettings, settingsPath, THEMES, DEFAULT_THEME, normalizeTheme } from './settings.js';
|
|
10
10
|
export { TunerError, benchError } from './errors.js';
|
|
11
11
|
export { sampleRam, sampleGpus, startTelemetrySampler } from './telemetry.js';
|
|
12
|
-
|
|
12
|
+
export { checkUpdate, isNewerVersion, CURRENT_VERSION } from './updater.js';
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { configDir, readJson, fetchJson } from './util.js';
|
|
4
|
+
|
|
5
|
+
const pkgUrl = new URL('../../package.json', import.meta.url);
|
|
6
|
+
const pkgContent = await fs.readFile(pkgUrl, 'utf8');
|
|
7
|
+
export const CURRENT_VERSION = JSON.parse(pkgContent).version;
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_REGISTRY_URL = 'https://registry.npmjs.org/llm-tuner/latest';
|
|
10
|
+
export const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
11
|
+
|
|
12
|
+
export const updateCachePath = () => path.join(configDir(), 'update-check.json');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a semver string into [major, minor, patch] numbers.
|
|
16
|
+
* Strips any leading 'v' and ignores pre-release/build identifiers.
|
|
17
|
+
*/
|
|
18
|
+
export function parseSemver(v) {
|
|
19
|
+
if (typeof v !== 'string') return null;
|
|
20
|
+
const clean = v.trim().replace(/^v/, '');
|
|
21
|
+
const [core] = clean.split('-');
|
|
22
|
+
const parts = core.split('.').map((p) => Number(p));
|
|
23
|
+
if (parts.length < 3 || parts.some((n) => Number.isNaN(n) || n < 0)) return null;
|
|
24
|
+
return parts.slice(0, 3);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns true if `latest` is strictly newer than `current`.
|
|
29
|
+
*/
|
|
30
|
+
export function isNewerVersion(latest, current) {
|
|
31
|
+
const l = parseSemver(latest);
|
|
32
|
+
const c = parseSemver(current);
|
|
33
|
+
if (!l || !c) return false;
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
if (l[i] > c[i]) return true;
|
|
36
|
+
if (l[i] < c[i]) return false;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Checks npm registry for updates with a 24h cache.
|
|
43
|
+
* Never throws; returns safe fallback on network or file errors.
|
|
44
|
+
*/
|
|
45
|
+
export async function checkUpdate({
|
|
46
|
+
force = false,
|
|
47
|
+
timeout = 2000,
|
|
48
|
+
cacheTtlMs = DEFAULT_CACHE_TTL_MS,
|
|
49
|
+
registryUrl = DEFAULT_REGISTRY_URL,
|
|
50
|
+
currentVersion = CURRENT_VERSION,
|
|
51
|
+
} = {}) {
|
|
52
|
+
try {
|
|
53
|
+
const cacheFile = updateCachePath();
|
|
54
|
+
if (!force) {
|
|
55
|
+
const cached = await readJson(cacheFile, null);
|
|
56
|
+
if (cached && typeof cached === 'object' && cached.lastChecked && cached.latestVersion) {
|
|
57
|
+
if (Date.now() - cached.lastChecked < cacheTtlMs) {
|
|
58
|
+
return {
|
|
59
|
+
updateAvailable: isNewerVersion(cached.latestVersion, currentVersion),
|
|
60
|
+
currentVersion,
|
|
61
|
+
latestVersion: cached.latestVersion,
|
|
62
|
+
cached: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const res = await fetchJson(registryUrl, { timeout });
|
|
69
|
+
if (res?.ok && res?.json?.version) {
|
|
70
|
+
const latest = res.json.version;
|
|
71
|
+
try {
|
|
72
|
+
await fs.mkdir(configDir(), { recursive: true });
|
|
73
|
+
await fs.writeFile(cacheFile, JSON.stringify({ lastChecked: Date.now(), latestVersion: latest }, null, 2));
|
|
74
|
+
} catch {
|
|
75
|
+
// Ignore cache write errors
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
updateAvailable: isNewerVersion(latest, currentVersion),
|
|
79
|
+
currentVersion,
|
|
80
|
+
latestVersion: latest,
|
|
81
|
+
cached: false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
updateAvailable: false,
|
|
87
|
+
currentVersion,
|
|
88
|
+
latestVersion: null,
|
|
89
|
+
cached: false,
|
|
90
|
+
};
|
|
91
|
+
} catch {
|
|
92
|
+
return {
|
|
93
|
+
updateAvailable: false,
|
|
94
|
+
currentVersion,
|
|
95
|
+
latestVersion: null,
|
|
96
|
+
cached: false,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/i18n/en.js
CHANGED
|
@@ -244,5 +244,10 @@ Presets are saved in {presetsDir}`,
|
|
|
244
244
|
'web.telemetryRam': 'RAM',
|
|
245
245
|
'web.telemetryVram': 'VRAM',
|
|
246
246
|
'web.telemetryGpu': 'GPU {index}',
|
|
247
|
+
'cli.updateAvailable': 'Update available: {current} → {latest}',
|
|
248
|
+
'cli.updateCommand': 'Run {cmd} to update',
|
|
249
|
+
'web.updateAvailable': 'Update available: v{latest}',
|
|
250
|
+
'web.updateLink': 'View release notes on GitHub',
|
|
251
|
+
'web.version': 'Version {version}',
|
|
247
252
|
};
|
|
248
253
|
|
package/src/i18n/es.js
CHANGED
|
@@ -244,5 +244,10 @@ Los presets se guardan en {presetsDir}`,
|
|
|
244
244
|
'web.telemetryRam': 'RAM',
|
|
245
245
|
'web.telemetryVram': 'VRAM',
|
|
246
246
|
'web.telemetryGpu': 'GPU {index}',
|
|
247
|
+
'cli.updateAvailable': 'Actualización disponible: {current} → {latest}',
|
|
248
|
+
'cli.updateCommand': 'Ejecutá {cmd} para actualizar',
|
|
249
|
+
'web.updateAvailable': 'Actualización disponible: v{latest}',
|
|
250
|
+
'web.updateLink': 'Ver notas de la versión en GitHub',
|
|
251
|
+
'web.version': 'Versión {version}',
|
|
247
252
|
};
|
|
248
253
|
|
package/src/web/public/app.js
CHANGED
|
@@ -136,6 +136,22 @@ async function boot() {
|
|
|
136
136
|
document.documentElement.lang = s.lang;
|
|
137
137
|
applyStatic();
|
|
138
138
|
setupDrawer();
|
|
139
|
+
api('/api/update').then((up) => {
|
|
140
|
+
if (up?.currentVersion) {
|
|
141
|
+
const vDisp = $('#version-display');
|
|
142
|
+
if (vDisp) vDisp.textContent = t('web.version', { version: up.currentVersion });
|
|
143
|
+
}
|
|
144
|
+
if (up?.updateAvailable) {
|
|
145
|
+
const dot = $('#update-dot');
|
|
146
|
+
if (dot) dot.hidden = false;
|
|
147
|
+
const badge = $('#update-badge');
|
|
148
|
+
const link = $('#update-link');
|
|
149
|
+
if (badge && link) {
|
|
150
|
+
link.textContent = t('web.updateAvailable', { latest: up.latestVersion });
|
|
151
|
+
badge.hidden = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}).catch(() => {});
|
|
139
155
|
state.storedProfile = s.profile ?? null;
|
|
140
156
|
state.profileIds = s.profiles;
|
|
141
157
|
renderProfile();
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
<button id="settings-open" class="settings-trigger" type="button" aria-haspopup="dialog" aria-expanded="false">
|
|
23
23
|
<span class="settings-trigger-icon">⚙</span>
|
|
24
24
|
<span data-i18n="web.settings">Settings</span>
|
|
25
|
+
<span id="update-dot" class="update-dot" hidden></span>
|
|
25
26
|
</button>
|
|
26
27
|
<ol class="steps" id="steps">
|
|
27
28
|
<li data-step="engine" class="active" data-i18n="web.stepEngine">Engine</li>
|
|
@@ -144,6 +145,12 @@
|
|
|
144
145
|
<option value="dark" data-i18n="web.themeDark">Dark</option>
|
|
145
146
|
</select>
|
|
146
147
|
</div>
|
|
148
|
+
<div class="drawer-field version-info">
|
|
149
|
+
<span class="muted" id="version-display"></span>
|
|
150
|
+
<div id="update-badge" hidden>
|
|
151
|
+
<a href="https://github.com/ronald32acunadev/llmtuner/releases" target="_blank" rel="noopener noreferrer" class="tag prio" id="update-link"></a>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
147
154
|
</div>
|
|
148
155
|
</aside>
|
|
149
156
|
<script src="app.js" type="module"></script>
|
package/src/web/public/style.css
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
--ink: #0f172a;
|
|
5
5
|
--muted: #475569;
|
|
6
6
|
--line: #cbd5e1;
|
|
7
|
-
--accent: #
|
|
7
|
+
--accent: #007c99; /* accessible electric cyan for light theme */
|
|
8
8
|
--accent-ink: #ffffff;
|
|
9
|
-
--gpu: #
|
|
9
|
+
--gpu: #007c99;
|
|
10
|
+
--purple: #7800c7; /* accessible electric purple */
|
|
10
11
|
--cpu: #94a3b8;
|
|
11
|
-
--ok: #
|
|
12
|
+
--ok: #288a00; /* accessible neon lime */
|
|
12
13
|
--warn: #d97706;
|
|
13
|
-
--bad: #
|
|
14
|
+
--bad: #d61500; /* accessible electric red */
|
|
14
15
|
--code-bg: #f8fafc;
|
|
15
16
|
--font: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
16
17
|
--mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
|
|
@@ -23,13 +24,14 @@
|
|
|
23
24
|
--ink: #e2e8f0;
|
|
24
25
|
--muted: #8b9bb0;
|
|
25
26
|
--line: #1e2c3d;
|
|
26
|
-
--accent: #00e5ff; /* electric cyan from
|
|
27
|
+
--accent: #00e5ff; /* electric cyan from brand */
|
|
27
28
|
--accent-ink: #05131d;
|
|
28
29
|
--gpu: #00e5ff;
|
|
30
|
+
--purple: #9a00ff; /* electric purple from brand */
|
|
29
31
|
--cpu: #3d4f63;
|
|
30
|
-
--ok: #
|
|
32
|
+
--ok: #65ff00; /* neon lime from brand */
|
|
31
33
|
--warn: #fbbf24;
|
|
32
|
-
--bad: #
|
|
34
|
+
--bad: #ff1a00; /* electric red from brand */
|
|
33
35
|
--code-bg: #0b121b;
|
|
34
36
|
color-scheme: dark;
|
|
35
37
|
}
|
|
@@ -40,13 +42,14 @@
|
|
|
40
42
|
--ink: #e2e8f0;
|
|
41
43
|
--muted: #8b9bb0;
|
|
42
44
|
--line: #1e2c3d;
|
|
43
|
-
--accent: #00e5ff; /* electric cyan from
|
|
45
|
+
--accent: #00e5ff; /* electric cyan from brand */
|
|
44
46
|
--accent-ink: #05131d;
|
|
45
47
|
--gpu: #00e5ff;
|
|
48
|
+
--purple: #9a00ff; /* electric purple from brand */
|
|
46
49
|
--cpu: #3d4f63;
|
|
47
|
-
--ok: #
|
|
50
|
+
--ok: #65ff00; /* neon lime from brand */
|
|
48
51
|
--warn: #fbbf24;
|
|
49
|
-
--bad: #
|
|
52
|
+
--bad: #ff1a00; /* electric red from brand */
|
|
50
53
|
--code-bg: #0b121b;
|
|
51
54
|
color-scheme: dark;
|
|
52
55
|
}
|
|
@@ -56,13 +59,14 @@
|
|
|
56
59
|
--ink: #0f172a;
|
|
57
60
|
--muted: #475569;
|
|
58
61
|
--line: #cbd5e1;
|
|
59
|
-
--accent: #
|
|
62
|
+
--accent: #007c99; /* accessible electric cyan for light theme */
|
|
60
63
|
--accent-ink: #ffffff;
|
|
61
|
-
--gpu: #
|
|
64
|
+
--gpu: #007c99;
|
|
65
|
+
--purple: #7800c7; /* accessible electric purple */
|
|
62
66
|
--cpu: #94a3b8;
|
|
63
|
-
--ok: #
|
|
67
|
+
--ok: #288a00; /* accessible neon lime */
|
|
64
68
|
--warn: #d97706;
|
|
65
|
-
--bad: #
|
|
69
|
+
--bad: #d61500; /* accessible electric red */
|
|
66
70
|
--code-bg: #f8fafc;
|
|
67
71
|
color-scheme: light;
|
|
68
72
|
}
|
|
@@ -331,7 +335,7 @@ td .err { color: var(--bad); white-space: normal; }
|
|
|
331
335
|
/* Progress bar and real-time telemetry */
|
|
332
336
|
.progress-wrap { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
|
333
337
|
.progress-bar-track { width: 100%; height: 8px; background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; overflow: hidden; }
|
|
334
|
-
.progress-bar-fill { height: 100%; background: var(--accent); transition: width .2s ease; }
|
|
338
|
+
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--ok)); transition: width .2s ease; }
|
|
335
339
|
.progress-meta { display: flex; justify-content: space-between; align-items: center; font: 500 .8rem var(--mono); color: var(--muted); }
|
|
336
340
|
.progress-meta strong { color: var(--ink); }
|
|
337
341
|
|
|
@@ -342,5 +346,27 @@ td .err { color: var(--bad); white-space: normal; }
|
|
|
342
346
|
.telemetry-gpus { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
|
343
347
|
.meter-bar { width: 100%; height: 8px; background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; overflow: hidden; }
|
|
344
348
|
.meter-fill { height: 100%; background: var(--gpu); transition: width .2s ease; }
|
|
345
|
-
#telemetry-cpu-fill { background: var(--
|
|
349
|
+
#telemetry-cpu-fill { background: var(--purple); }
|
|
350
|
+
|
|
351
|
+
/* Update notifications */
|
|
352
|
+
.update-dot {
|
|
353
|
+
width: 8px;
|
|
354
|
+
height: 8px;
|
|
355
|
+
border-radius: 50%;
|
|
356
|
+
background: var(--warn, #f59e0b);
|
|
357
|
+
display: inline-block;
|
|
358
|
+
margin-left: auto;
|
|
359
|
+
}
|
|
360
|
+
.version-info {
|
|
361
|
+
display: flex;
|
|
362
|
+
align-items: center;
|
|
363
|
+
justify-content: space-between;
|
|
364
|
+
padding-top: 1rem;
|
|
365
|
+
border-top: 1px solid var(--line);
|
|
366
|
+
font-size: .85rem;
|
|
367
|
+
}
|
|
368
|
+
.version-info #update-link {
|
|
369
|
+
text-decoration: none;
|
|
370
|
+
font-size: .75rem;
|
|
371
|
+
}
|
|
346
372
|
|
package/src/web/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
|
-
import { detectHardware, detectEngines, Tuner, ENGINES, installPlans, runInstall, slim, listPresets, readSettings, writeSettings, TunerError, PROFILES } from '../core/index.js';
|
|
6
|
+
import { detectHardware, detectEngines, Tuner, ENGINES, installPlans, runInstall, slim, listPresets, readSettings, writeSettings, TunerError, PROFILES, checkUpdate } from '../core/index.js';
|
|
7
7
|
import { THEMES } from '../core/settings.js';
|
|
8
8
|
import { LOCALES, messagesFor } from '../i18n/index.js';
|
|
9
9
|
import { openUrl } from '../core/installer.js';
|
|
@@ -52,6 +52,8 @@ function send(res, status, obj) {
|
|
|
52
52
|
const routes = {
|
|
53
53
|
'GET /api/settings': async () => ({ ...(await readSettings()), locales: LOCALES, themes: THEMES, profiles: PROFILES }),
|
|
54
54
|
|
|
55
|
+
'GET /api/update': async () => checkUpdate(),
|
|
56
|
+
|
|
55
57
|
'POST /api/settings': async (req) => {
|
|
56
58
|
const payload = await body(req);
|
|
57
59
|
if (payload?.lang === undefined && payload?.theme === undefined && payload?.profile === undefined) throw new TunerError('errors.unknownLocale', { lang: '', list: LOCALES.join(', ') });
|