llm-tuner 0.4.4 → 0.5.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.
@@ -9,6 +9,11 @@ import { PROFILES } from './profiles.js';
9
9
  export const THEMES = Object.freeze(['system', 'light', 'dark']);
10
10
  export const DEFAULT_THEME = 'system';
11
11
 
12
+ export const DEFAULT_SERVERS = Object.freeze([
13
+ Object.freeze({ id: 'local', name: 'Local Machine', url: 'http://127.0.0.1:7860', token: null }),
14
+ ]);
15
+ export const DEFAULT_ACTIVE_SERVER_ID = 'local';
16
+
12
17
  /** Returns the value if it is a valid profile, otherwise null. */
13
18
  function storedProfile(value) {
14
19
  return PROFILES.includes(value) ? value : null;
@@ -18,15 +23,47 @@ export function normalizeTheme(theme) {
18
23
  return THEMES.includes(theme) ? theme : DEFAULT_THEME;
19
24
  }
20
25
 
26
+ export function normalizeServers(servers) {
27
+ if (!Array.isArray(servers)) return [...DEFAULT_SERVERS];
28
+ const list = servers
29
+ .filter((s) => s && typeof s === 'object' && s.id)
30
+ .map((s) => ({
31
+ id: String(s.id),
32
+ name: String(s.name || s.id),
33
+ url: String(s.url || '').replace(/\/+$/, ''),
34
+ token: s.token ? String(s.token) : null,
35
+ }));
36
+ if (list.length === 0) return [...DEFAULT_SERVERS];
37
+ if (!list.some((s) => s.id === 'local')) {
38
+ list.unshift(DEFAULT_SERVERS[0]);
39
+ }
40
+ return list;
41
+ }
42
+
43
+ export function normalizeActiveServerId(activeId, servers) {
44
+ const list = normalizeServers(servers);
45
+ return list.some((s) => s.id === activeId) ? activeId : DEFAULT_ACTIVE_SERVER_ID;
46
+ }
47
+
48
+ export function getActiveServer(settings) {
49
+ const servers = normalizeServers(settings?.servers);
50
+ const activeId = normalizeActiveServerId(settings?.activeServerId, servers);
51
+ return servers.find((s) => s.id === activeId) || servers[0];
52
+ }
53
+
21
54
  export const settingsPath = () => path.join(configDir(), 'settings.json');
22
55
 
23
56
  /** Current settings. A missing, corrupt or hand-edited file yields the defaults; never throws. */
24
57
  export async function readSettings() {
25
58
  const s = await readJson(settingsPath(), null);
59
+ const servers = normalizeServers(s?.servers);
60
+ const activeServerId = normalizeActiveServerId(s?.activeServerId, servers);
26
61
  return {
27
62
  lang: normalizeLocale(s?.lang),
28
63
  theme: normalizeTheme(s?.theme),
29
64
  profile: storedProfile(s?.profile),
65
+ servers,
66
+ activeServerId,
30
67
  };
31
68
  }
32
69
 
@@ -37,8 +74,19 @@ export async function writeSettings(patch) {
37
74
  const lang = normalizeLocale(patch?.lang !== undefined ? patch.lang : base.lang);
38
75
  const theme = normalizeTheme(patch?.theme !== undefined ? patch.theme : base.theme);
39
76
  const profile = storedProfile(patch?.profile) ?? base.profile;
40
- const next = { ...base, ...patch, lang, theme, profile };
77
+ const servers = patch?.servers !== undefined ? normalizeServers(patch.servers) : normalizeServers(base.servers);
78
+ const activeServerId = patch?.activeServerId !== undefined
79
+ ? normalizeActiveServerId(patch.activeServerId, servers)
80
+ : normalizeActiveServerId(base.activeServerId, servers);
81
+
82
+ const next = { ...base, ...patch, lang, theme, profile, servers, activeServerId };
41
83
  await fs.mkdir(configDir(), { recursive: true });
42
84
  await fs.writeFile(settingsPath(), JSON.stringify(next, null, 2));
43
- return { lang: next.lang, theme: next.theme, profile: storedProfile(next.profile) };
85
+ return {
86
+ lang: next.lang,
87
+ theme: next.theme,
88
+ profile: storedProfile(next.profile),
89
+ servers: next.servers,
90
+ activeServerId: next.activeServerId,
91
+ };
44
92
  }
package/src/core/util.js CHANGED
@@ -146,3 +146,29 @@ export function cpuBusyPercent(a, b) {
146
146
  }
147
147
  return total > 0 ? 100 * (1 - idle / total) : 0;
148
148
  }
149
+
150
+ export function resolveConnectableHost(host = '127.0.0.1', networkInterfaces = os.networkInterfaces) {
151
+ if (host !== '0.0.0.0' && host !== '::') {
152
+ return host;
153
+ }
154
+
155
+ let interfaces;
156
+ if (typeof networkInterfaces === 'function') {
157
+ interfaces = networkInterfaces();
158
+ } else if (networkInterfaces && typeof networkInterfaces.networkInterfaces === 'function') {
159
+ interfaces = networkInterfaces.networkInterfaces();
160
+ } else {
161
+ interfaces = os.networkInterfaces();
162
+ }
163
+
164
+ for (const name of Object.keys(interfaces || {})) {
165
+ const list = interfaces[name] || [];
166
+ const addr = list.find((info) => (info.family === 'IPv4' || info.family === 4) && !info.internal);
167
+ if (addr) {
168
+ return addr.address;
169
+ }
170
+ }
171
+
172
+ return '127.0.0.1';
173
+ }
174
+
package/src/i18n/en.js CHANGED
@@ -1,5 +1,25 @@
1
1
  // English catalog: the base language. Every key here must also exist in es.js.
2
2
  export default {
3
+ "web.localServerSettings": "Local Server Settings",
4
+ "web.serverAutostartLabel": "Start server after login",
5
+ "web.serverStartupEnabled": "Automatic server startup is enabled",
6
+ "web.serverStartupDisabled": "Automatic server startup is disabled",
7
+ "web.serverStartupUnknown": "Automatic server startup state is unknown",
8
+ "web.serverStartupSaved": "Startup setting saved for next login",
9
+ "web.serverTokenLabel": "Server authentication token",
10
+ "web.serverTokenReveal": "Reveal token",
11
+ "web.serverTokenHide": "Hide token",
12
+ "web.serverTokenCopy": "Copy token",
13
+ "web.serverTokenCopied": "Token copied",
14
+ "web.serverNoConfig": "No server configuration yet. Run /serve or enable automatic startup first.",
15
+ "web.serverConnectionInfo": "Server listens on {host}:{port}. On another computer use this computer LAN IP with port {port}, not 0.0.0.0 or localhost.",
16
+ "web.serverStartupUnconfirmed": "Could not confirm startup state.",
17
+ "web.serverTokenCopyFailed": "Could not confirm token copy.",
18
+ "cli.serverConnection": "View server connection details and token",
19
+ "cli.serverConnectionDetails": "Listen address: {host}:{port}; Authentication Token: {token}; From another computer use the server LAN IP with port {port}, not 0.0.0.0 or localhost.",
20
+ "cli.serverNoConfig": "No server configuration yet. Run /serve or enable automatic startup first.",
21
+ "cli.serverConnectionInvalid": "Saved server configuration is invalid.",
22
+ "cli.serverConnectionHelp": "In /settings, select View server connection details and token to retrieve the saved token. Desktop Settings also provides explicit reveal and copy actions.",
3
23
  // Engine choice
4
24
  'engine.reason.onlyInstalled': '{name} is the only one installed.',
5
25
  'engine.reason.multiGpu': 'You have several GPUs: LM Studio lets you set the GPU order and the KV type per model.',
@@ -85,6 +105,8 @@ export default {
85
105
  'errors.unknownLocale': 'Unknown language "{lang}". Valid values: {list}',
86
106
  'errors.unknownTheme': 'Unknown theme "{theme}". Valid values: {list}',
87
107
  'errors.unknownProfile': 'Unknown profile "{profile}". Valid values: {list}',
108
+ 'errors.unauthorized': 'Unauthorized: a valid Bearer token is required.',
109
+ 'errors.forbiddenRemote': 'Forbidden: this operation cannot be performed remotely.',
88
110
  'errors.installNotFinished': 'The installation did not finish. Run llm-tuner again when it is done.',
89
111
  'errors.modelDownloadFailed': 'Could not download the model',
90
112
  'errors.noInstaller': 'There is no automatic installer for this system.',
@@ -114,7 +136,7 @@ Commands:
114
136
  /load Tune and load a model with optimal hardware settings
115
137
  /mcp Connect local engine to AI coding agents as MCP server
116
138
  /presets List saved presets
117
- /settings Interactive settings menu (language and theme)
139
+ /settings Interactive settings menu (language, theme, and server startup)
118
140
  /help Show this help message
119
141
  /exit Exit the interactive menu
120
142
 
@@ -132,6 +154,8 @@ Options:
132
154
  --lang <en|es> Interface language (saved for next time)
133
155
  --theme <system|light|dark> Interface theme (saved for next time)
134
156
 
157
+ Server startup can be enabled or disabled after login in /settings. It starts on your next login, not immediately.
158
+
135
159
  Ollama service changes may ask for administrator authorization in the terminal.
136
160
  Unchanged settings do not restart the service. JSON/noninteractive mode never asks for a password.
137
161
 
@@ -141,6 +165,16 @@ Presets are saved in {presetsDir}`,
141
165
  'cli.settingsMenu': 'Select an option to configure:',
142
166
  'cli.settingsLanguage': 'Language',
143
167
  'cli.settingsTheme': 'Theme',
168
+ 'cli.settingsAutostart': 'Automatic server startup after login',
169
+ 'cli.settingsAutostartEnable': 'Enable',
170
+ 'cli.settingsAutostartDisable': 'Disable',
171
+ 'cli.enabled': 'Enabled',
172
+ 'cli.disabled': 'Disabled',
173
+ 'cli.unknown': 'Unknown',
174
+ 'cli.autostartEnabled': 'Server startup is enabled for your next login.',
175
+ 'cli.autostartDisabled': 'Server startup is disabled.',
176
+ 'cli.autostartFailed': 'Could not update automatic server startup: {error}',
177
+ 'cli.settingsServers': 'Active Server',
144
178
  'cli.settingsExit': 'Save and return',
145
179
  'cli.settingsSaved': 'Settings saved.',
146
180
  'cli.langNotSaved': 'Could not save the language preference: {error}',
@@ -284,12 +318,25 @@ Presets are saved in {presetsDir}`,
284
318
  'web.mcpAlreadyConfigured': 'Configured',
285
319
  'web.mcpSuccess': 'Successfully configured MCP in: {agents}',
286
320
  'web.mcpSaveBtn': 'Save & Configure',
321
+ 'web.servers': 'Servers',
322
+ 'web.addServer': 'Add Server',
323
+ 'web.removeServer': 'Remove',
324
+ 'web.addServerModalTitle': 'Add Remote Server',
325
+ 'web.serverName': 'Server Name',
326
+ 'web.serverUrl': 'Server URL',
327
+ 'web.serverToken': 'Authentication Token',
328
+ 'web.testConnection': 'Test Connection',
329
+ 'web.saveServer': 'Save Server',
330
+ 'web.serverConnected': 'Connection successful!',
331
+ 'web.serverConnectionFailed': 'Could not connect to server: {error}',
332
+ 'web.serverActive': 'Active',
287
333
 
288
334
  // CLI Slash Commands
289
335
  'cli.menuTitle': 'Select a command:',
290
336
  'cli.cmdLoadDesc': 'Tune and load a model with optimal hardware settings',
291
337
  'cli.cmdMcpDesc': 'Connect local engine to AI coding agents as MCP server',
292
338
  'cli.cmdPresetsDesc': 'View saved tuning presets',
339
+ 'cli.cmdServeDesc': 'Start remote server mode with API authentication',
293
340
  'cli.cmdSettingsDesc': 'Configure interface language and visual theme',
294
341
  'cli.cmdHelpDesc': 'View usage help and CLI flags',
295
342
  'cli.cmdExitDesc': 'Exit',
package/src/i18n/es.js CHANGED
@@ -1,5 +1,25 @@
1
1
  // Spanish catalog. Same keys and {params} as en.js.
2
2
  export default {
3
+ "web.localServerSettings": "Configuración del servidor local",
4
+ "web.serverAutostartLabel": "Iniciar servidor después del inicio de sesión",
5
+ "web.serverStartupEnabled": "El inicio automático del servidor está habilitado",
6
+ "web.serverStartupDisabled": "El inicio automático del servidor está deshabilitado",
7
+ "web.serverStartupUnknown": "El estado del inicio automático del servidor es desconocido",
8
+ "web.serverStartupSaved": "Configuración de inicio guardada para el próximo inicio de sesión",
9
+ "web.serverTokenLabel": "Token de autenticación del servidor",
10
+ "web.serverTokenReveal": "Mostrar token",
11
+ "web.serverTokenHide": "Ocultar token",
12
+ "web.serverTokenCopy": "Copiar token",
13
+ "web.serverTokenCopied": "Token copiado",
14
+ "web.serverNoConfig": "Aún no hay configuración del servidor. Ejecute /serve o habilite el inicio automático primero.",
15
+ "web.serverConnectionInfo": "El servidor escucha en {host}:{port}. Desde otro ordenador, use la IP LAN de este equipo con el puerto {port}, no 0.0.0.0 ni localhost.",
16
+ "web.serverStartupUnconfirmed": "No se pudo confirmar el estado del inicio.",
17
+ "web.serverTokenCopyFailed": "No se pudo confirmar la copia del token.",
18
+ "cli.serverConnection": "Ver detalles de conexión del servidor y token",
19
+ "cli.serverConnectionDetails": "Dirección de escucha: {host}:{port}; Token de autenticación: {token}; Desde otro ordenador, use la IP LAN del servidor con el puerto {port}, no 0.0.0.0 ni localhost.",
20
+ "cli.serverNoConfig": "Aún no hay configuración del servidor. Ejecute /serve o habilite el inicio automático primero.",
21
+ "cli.serverConnectionInvalid": "La configuración del servidor guardada no es válida.",
22
+ "cli.serverConnectionHelp": "En /settings, seleccione 'Ver detalles de conexión del servidor y token' para recuperar el token guardado. Los Ajustes de escritorio también ofrecen acciones explícitas de mostrar y copiar.",
3
23
  // Engine choice
4
24
  'engine.reason.onlyInstalled': '{name} es el único instalado.',
5
25
  'engine.reason.multiGpu': 'Tienes varias GPUs: LM Studio permite fijar el orden de GPUs y el tipo de KV por modelo.',
@@ -85,6 +105,8 @@ export default {
85
105
  'errors.unknownLocale': 'Idioma desconocido "{lang}". Valores válidos: {list}',
86
106
  'errors.unknownTheme': 'Tema desconocido "{theme}". Valores válidos: {list}',
87
107
  'errors.unknownProfile': 'Perfil desconocido "{profile}". Valores válidos: {list}',
108
+ 'errors.unauthorized': 'No autorizado: se requiere un token Bearer válido.',
109
+ 'errors.forbiddenRemote': 'Prohibido: esta operación no se puede ejecutar de forma remota.',
88
110
  'errors.installNotFinished': 'La instalación no terminó. Vuelve a ejecutar llm-tuner cuando acabe.',
89
111
  'errors.modelDownloadFailed': 'No se pudo descargar el modelo',
90
112
  'errors.noInstaller': 'No hay un instalador automático para este sistema.',
@@ -114,7 +136,7 @@ Comandos:
114
136
  /load Ajustar y cargar un modelo con la configuración óptima
115
137
  /mcp Conectar motor local con agentes de IA como servidor MCP
116
138
  /presets Listar presets guardados
117
- /settings Menú interactivo de configuración (idioma y tema)
139
+ /settings Menú interactivo de configuración (idioma, tema e inicio del servidor)
118
140
  /help Mostrar esta ayuda
119
141
  /exit Salir del menú interactivo
120
142
 
@@ -132,6 +154,8 @@ Opciones:
132
154
  --lang <en|es> Idioma de la interfaz (se guarda para la próxima vez)
133
155
  --theme <system|light|dark> Tema de la interfaz (se guarda para la próxima)
134
156
 
157
+ El inicio del servidor se habilita o deshabilita después de iniciar sesión desde /settings. Se ejecuta en el próximo inicio de sesión, no de inmediato.
158
+
135
159
  Los cambios del servicio de Ollama pueden solicitar autorización de administrador en la terminal.
136
160
  Si los ajustes no cambian, el servicio no se reinicia. El modo JSON/no interactivo nunca pide contraseña.
137
161
 
@@ -141,6 +165,16 @@ Los presets se guardan en {presetsDir}`,
141
165
  'cli.settingsMenu': 'Selecciona una opción para configurar:',
142
166
  'cli.settingsLanguage': 'Idioma',
143
167
  'cli.settingsTheme': 'Tema',
168
+ 'cli.settingsAutostart': 'Inicio automático del servidor después de iniciar sesión',
169
+ 'cli.settingsAutostartEnable': 'Habilitar',
170
+ 'cli.settingsAutostartDisable': 'Deshabilitar',
171
+ 'cli.enabled': 'Habilitado',
172
+ 'cli.disabled': 'Deshabilitado',
173
+ 'cli.unknown': 'Desconocido',
174
+ 'cli.autostartEnabled': 'El inicio del servidor quedó habilitado para tu próximo inicio de sesión.',
175
+ 'cli.autostartDisabled': 'El inicio del servidor quedó deshabilitado.',
176
+ 'cli.autostartFailed': 'No se pudo actualizar el inicio automático del servidor: {error}',
177
+ 'cli.settingsServers': 'Servidor activo',
144
178
  'cli.settingsExit': 'Guardar y volver',
145
179
  'cli.settingsSaved': 'Configuración guardada.',
146
180
  'cli.langNotSaved': 'No se pudo guardar la preferencia de idioma: {error}',
@@ -284,12 +318,25 @@ Los presets se guardan en {presetsDir}`,
284
318
  'web.mcpAlreadyConfigured': 'Configurado',
285
319
  'web.mcpSuccess': 'MCP configurado con éxito en: {agents}',
286
320
  'web.mcpSaveBtn': 'Guardar y configurar',
321
+ 'web.servers': 'Servidores',
322
+ 'web.addServer': 'Agregar servidor',
323
+ 'web.removeServer': 'Eliminar',
324
+ 'web.addServerModalTitle': 'Agregar servidor remoto',
325
+ 'web.serverName': 'Nombre del servidor',
326
+ 'web.serverUrl': 'URL del servidor',
327
+ 'web.serverToken': 'Token de autenticación',
328
+ 'web.testConnection': 'Probar conexión',
329
+ 'web.saveServer': 'Guardar servidor',
330
+ 'web.serverConnected': '¡Conexión exitosa!',
331
+ 'web.serverConnectionFailed': 'No se pudo conectar al servidor: {error}',
332
+ 'web.serverActive': 'Activo',
287
333
 
288
334
  // CLI Slash Commands
289
335
  'cli.menuTitle': 'Selecciona un comando:',
290
336
  'cli.cmdLoadDesc': 'Ajustar y cargar un modelo con la configuración óptima',
291
337
  'cli.cmdMcpDesc': 'Conectar motor local con agentes de IA como servidor MCP',
292
338
  'cli.cmdPresetsDesc': 'Ver presets de configuración guardados',
339
+ 'cli.cmdServeDesc': 'Iniciar en modo servidor remoto con autenticación de API',
293
340
  'cli.cmdSettingsDesc': 'Configurar idioma y tema de la interfaz',
294
341
  'cli.cmdHelpDesc': 'Ver ayuda de uso y opciones',
295
342
  'cli.cmdExitDesc': 'Salir',
@@ -0,0 +1,187 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { run as runCommand } from '../core/util.js';
7
+
8
+ const ENTRY_NAME = 'dev.llm-tuner.server';
9
+
10
+ function missing(error) {
11
+ return error?.code === 'ENOENT';
12
+ }
13
+
14
+ function validConfig(config) {
15
+ return config && typeof config === 'object' && !Array.isArray(config)
16
+ && typeof config.host === 'string' && config.host.length > 0
17
+ && Number.isInteger(config.port) && config.port > 0 && config.port <= 65535
18
+ && typeof config.token === 'string' && config.token.length > 0;
19
+ }
20
+
21
+ function desktopArgument(value) {
22
+ const execEscaped = String(value).replace(/([\\"$`])/g, '\\$1').replace(/%/g, '%%');
23
+ return `"${execEscaped.replace(/\\/g, '\\\\')}"`;
24
+ }
25
+
26
+ function assertNoControlCharacters(values) {
27
+ if (values.some((value) => /[\u0000-\u001f\u007f-\u009f]/u.test(String(value)))) {
28
+ throw new Error('Startup command paths cannot contain control characters.');
29
+ }
30
+ }
31
+
32
+ function xml(value) {
33
+ return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
34
+ }
35
+
36
+ function powershellLiteral(value) {
37
+ return `'${String(value).replace(/'/g, "''")}'`;
38
+ }
39
+
40
+ function windowsArgument(value) {
41
+ const text = String(value);
42
+ if (!/[\s"]/.test(text)) return text;
43
+ let quoted = '"';
44
+ let slashes = 0;
45
+ for (const character of text) {
46
+ if (character === '\\') {
47
+ slashes += 1;
48
+ } else if (character === '"') {
49
+ quoted += `${'\\'.repeat(slashes * 2 + 1)}"`;
50
+ slashes = 0;
51
+ } else {
52
+ quoted += `${'\\'.repeat(slashes)}${character}`;
53
+ slashes = 0;
54
+ }
55
+ }
56
+ return `${quoted}${'\\'.repeat(slashes * 2)}"`;
57
+ }
58
+
59
+ function plist(argumentsList) {
60
+ const values = argumentsList.map((value) => ` <string>${xml(value)}</string>`).join('\n');
61
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key>\n <string>${ENTRY_NAME}</string>\n <key>ProgramArguments</key>\n <array>\n${values}\n </array>\n <key>RunAtLoad</key>\n <true/>\n</dict>\n</plist>\n`;
62
+ }
63
+
64
+ export function createServerAutostart({
65
+ platform = process.platform,
66
+ home = os.homedir(),
67
+ env = process.env,
68
+ execPath = process.execPath,
69
+ entryPath = fileURLToPath(new URL('../cli/server-login.js', import.meta.url)),
70
+ configPath,
71
+ fileSystem = fs,
72
+ run = runCommand,
73
+ } = {}) {
74
+ let artifactPath;
75
+ if (platform === 'linux') {
76
+ artifactPath = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'autostart', 'llm-tuner-server.desktop');
77
+ } else if (platform === 'darwin') {
78
+ artifactPath = path.join(home, 'Library', 'LaunchAgents', `${ENTRY_NAME}.plist`);
79
+ } else if (platform === 'win32') {
80
+ artifactPath = path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'LLM Tuner Server.lnk');
81
+ } else {
82
+ throw new Error(`Automatic server startup is not supported on ${platform}.`);
83
+ }
84
+ const defaultConfigRoot = platform === 'win32'
85
+ ? (env.APPDATA || path.join(home, 'AppData', 'Roaming'))
86
+ : platform === 'darwin'
87
+ ? path.join(home, 'Library', 'Application Support')
88
+ : (env.XDG_CONFIG_HOME || path.join(home, '.config'));
89
+ const resolvedConfigPath = configPath || path.join(env.LLM_TUNER_CONFIG_DIR || path.join(defaultConfigRoot, 'llm-tuner'), 'server.json');
90
+
91
+ async function ensureConfig() {
92
+ let contents;
93
+ try {
94
+ contents = await fileSystem.readFile(resolvedConfigPath, 'utf8');
95
+ } catch (error) {
96
+ if (!missing(error)) throw error;
97
+ const initial = { host: '0.0.0.0', port: 7860, token: randomBytes(32).toString('hex') };
98
+ await fileSystem.mkdir(path.dirname(resolvedConfigPath), { recursive: true });
99
+ try {
100
+ await fileSystem.writeFile(resolvedConfigPath, JSON.stringify(initial, null, 2), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
101
+ return initial;
102
+ } catch (writeError) {
103
+ if (writeError?.code !== 'EEXIST') throw writeError;
104
+ contents = await fileSystem.readFile(resolvedConfigPath, 'utf8');
105
+ }
106
+ }
107
+ let config;
108
+ try {
109
+ config = JSON.parse(contents);
110
+ } catch {
111
+ throw new Error(`Server configuration at ${resolvedConfigPath} is malformed; automatic startup was not registered.`);
112
+ }
113
+ if (!validConfig(config)) {
114
+ throw new Error(`Server configuration at ${resolvedConfigPath} is invalid; automatic startup was not registered.`);
115
+ }
116
+ return config;
117
+ }
118
+
119
+ async function register() {
120
+ const configDirectory = path.dirname(resolvedConfigPath);
121
+ assertNoControlCharacters([execPath, entryPath, configDirectory, artifactPath]);
122
+ await ensureConfig();
123
+ if (platform === 'linux') {
124
+ const entry = `[Desktop Entry]\nType=Application\nName=LLM Tuner Server\nComment=Start the LLM Tuner server after login\nExec=${desktopArgument(execPath)} ${desktopArgument(entryPath)} --config-dir ${desktopArgument(configDirectory)}\nTerminal=false\n`;
125
+ await fileSystem.mkdir(path.dirname(artifactPath), { recursive: true });
126
+ await fileSystem.writeFile(artifactPath, entry, { encoding: 'utf8', mode: 0o600 });
127
+ if (!(await status())) throw new Error('The Linux startup entry was not created.');
128
+ return;
129
+ }
130
+ if (platform === 'darwin') {
131
+ await fileSystem.mkdir(path.dirname(artifactPath), { recursive: true });
132
+ await fileSystem.writeFile(artifactPath, plist([execPath, entryPath, '--config-dir', configDirectory]), { encoding: 'utf8', mode: 0o600 });
133
+ if (!(await status())) throw new Error('The macOS startup entry was not created.');
134
+ return;
135
+ }
136
+
137
+ await fileSystem.mkdir(path.dirname(artifactPath), { recursive: true });
138
+ const args = `--config-dir ${windowsArgument(configDirectory)}`;
139
+ const script = [
140
+ `$ErrorActionPreference = 'Stop'`,
141
+ `$expectedTarget = ${powershellLiteral(execPath)}`,
142
+ `$expectedArguments = ${powershellLiteral(`${windowsArgument(entryPath)} ${args}`)}`,
143
+ `$expectedWorkingDirectory = ${powershellLiteral(path.dirname(entryPath))}`,
144
+ `try {`,
145
+ `$shell = New-Object -ComObject WScript.Shell`,
146
+ `$shortcut = $shell.CreateShortcut(${powershellLiteral(artifactPath)})`,
147
+ `$shortcut.TargetPath = $expectedTarget`,
148
+ `$shortcut.Arguments = $expectedArguments`,
149
+ `$shortcut.WorkingDirectory = $expectedWorkingDirectory`,
150
+ `$shortcut.Save()`,
151
+ `$saved = $shell.CreateShortcut(${powershellLiteral(artifactPath)})`,
152
+ `if ($saved.TargetPath -ne $expectedTarget -or $saved.Arguments -ne $expectedArguments -or $saved.WorkingDirectory -ne $expectedWorkingDirectory) { throw 'The saved startup shortcut did not match the requested settings.' }`,
153
+ `} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }`,
154
+ ].join('\n');
155
+ const result = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { windowsHide: true });
156
+ if (result.code !== 0) throw new Error(`Could not create the Windows startup shortcut: ${result.stderr || `PowerShell exited with code ${result.code}`}`);
157
+ try {
158
+ await fileSystem.access(artifactPath);
159
+ } catch (error) {
160
+ if (missing(error)) throw new Error('PowerShell reported success but did not create the Windows startup shortcut.');
161
+ throw error;
162
+ }
163
+ }
164
+
165
+ async function status() {
166
+ try {
167
+ await fileSystem.access(artifactPath);
168
+ return true;
169
+ } catch (error) {
170
+ if (missing(error)) return false;
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ return {
176
+ status,
177
+ async setEnabled(enabled) {
178
+ if (typeof enabled !== 'boolean') throw new TypeError('enabled must be a boolean.');
179
+ if (enabled) {
180
+ await register();
181
+ } else {
182
+ await fileSystem.rm(artifactPath, { force: true });
183
+ }
184
+ return status();
185
+ },
186
+ };
187
+ }