omnikey-cli 1.0.16 → 1.0.18

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/dist/daemon.js CHANGED
@@ -4,18 +4,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.startDaemon = startDaemon;
7
- const child_process_1 = require("child_process");
8
7
  const path_1 = __importDefault(require("path"));
9
8
  const fs_1 = __importDefault(require("fs"));
10
- const child_process_2 = require("child_process");
9
+ const child_process_1 = require("child_process");
10
+ const inquirer_1 = __importDefault(require("inquirer"));
11
11
  const utils_1 = require("./utils");
12
12
  /**
13
13
  * Start the Omnikey API backend as a daemon on the specified port.
14
14
  * On macOS: creates and registers a launchd agent for persistence.
15
- * On Windows: creates a wrapper script and registers a Windows Task Scheduler task.
15
+ * On Windows: installs an NSSM Windows service for boot-time persistence.
16
16
  * @param port The port to run the backend on
17
17
  */
18
- function startDaemon(port = 7071) {
18
+ async function startDaemon(port = 7071) {
19
19
  const backendPath = path_1.default.resolve(__dirname, '../backend-dist/index.js');
20
20
  const configDir = (0, utils_1.getConfigDir)();
21
21
  const configPath = (0, utils_1.getConfigPath)();
@@ -33,7 +33,7 @@ function startDaemon(port = 7071) {
33
33
  const logPath = path_1.default.join(configDir, 'daemon.log');
34
34
  const errorLogPath = path_1.default.join(configDir, 'daemon-error.log');
35
35
  if (utils_1.isWindows) {
36
- startDaemonWindows({
36
+ await startDaemonWindows({
37
37
  port,
38
38
  configDir,
39
39
  configVars,
@@ -47,53 +47,108 @@ function startDaemon(port = 7071) {
47
47
  startDaemonMacOS({ port, configDir, configVars, nodePath, backendPath, logPath, errorLogPath });
48
48
  }
49
49
  }
50
- function startDaemonWindows(opts) {
51
- const { port, configDir, configVars, nodePath, backendPath, logPath, errorLogPath } = opts;
52
- // Write a wrapper .cmd script that sets env vars and launches the backend
53
- const wrapperPath = path_1.default.join(configDir, 'start-daemon.cmd');
54
- const envSetLines = Object.entries({ ...configVars, OMNIKEY_PORT: String(port) })
55
- .map(([k, v]) => `set "${k}=${v}"`)
56
- .join('\r\n');
57
- const wrapperContent = [
58
- '@echo off',
59
- envSetLines,
60
- `"${nodePath}" "${backendPath}" >> "${logPath}" 2>> "${errorLogPath}"`,
61
- '',
62
- ].join('\r\n');
50
+ function resolveNssm() {
63
51
  try {
64
- fs_1.default.mkdirSync(configDir, { recursive: true });
65
- fs_1.default.writeFileSync(wrapperPath, wrapperContent, 'utf-8');
52
+ return (0, child_process_1.execSync)('where nssm', { stdio: 'pipe' }).toString().trim().split('\n')[0].trim();
66
53
  }
67
- catch (e) {
68
- console.error('Failed to write start-daemon.cmd:', e);
69
- return;
54
+ catch {
55
+ return null;
70
56
  }
71
- // Register with Windows Task Scheduler so the daemon persists across reboots
72
- const taskName = 'OmnikeyDaemon';
57
+ }
58
+ async function startDaemonWindows(opts) {
59
+ const { port, configDir, configVars, nodePath, backendPath, logPath, errorLogPath } = opts;
60
+ const serviceName = 'OmnikeyDaemon';
61
+ let nssmPath = resolveNssm();
62
+ if (!nssmPath) {
63
+ const { install } = await inquirer_1.default.prompt([
64
+ {
65
+ type: 'confirm',
66
+ name: 'install',
67
+ message: 'NSSM is required but not found. Install it now via winget?',
68
+ default: true,
69
+ },
70
+ ]);
71
+ if (!install) {
72
+ console.log('Aborted. Install NSSM manually and re-run in an elevated (Administrator) terminal.');
73
+ return;
74
+ }
75
+ console.log('Installing NSSM via winget...');
76
+ try {
77
+ (0, child_process_1.execSync)('winget install nssm --accept-package-agreements --accept-source-agreements', {
78
+ stdio: 'inherit',
79
+ });
80
+ }
81
+ catch (e) {
82
+ console.error('winget install failed:', e?.message ?? e);
83
+ console.log('Try manually: scoop install nssm or choco install nssm');
84
+ return;
85
+ }
86
+ // winget updates the machine PATH in the registry but the current process
87
+ // won't see it — spawn a fresh cmd to resolve the new location.
88
+ try {
89
+ nssmPath = (0, child_process_1.execSync)('cmd /c where nssm', { stdio: 'pipe' })
90
+ .toString().trim().split('\n')[0].trim();
91
+ }
92
+ catch {
93
+ nssmPath = null;
94
+ }
95
+ if (!nssmPath) {
96
+ console.log('NSSM installed successfully.');
97
+ console.log('Please open a new elevated (Administrator) terminal and re-run this command.');
98
+ return;
99
+ }
100
+ }
101
+ (0, utils_1.initLogFiles)(logPath, errorLogPath);
102
+ // Remove any existing service (stop first, then remove)
73
103
  try {
74
- // Delete existing task silently before creating a fresh one
75
- (0, child_process_2.execSync)(`schtasks /delete /tn "${taskName}" /f`, { stdio: 'pipe' });
104
+ (0, child_process_1.execFileSync)(nssmPath, ['stop', serviceName], { stdio: 'pipe' });
76
105
  }
77
- catch {
78
- // Task may not exist — that's fine
106
+ catch { /* not running */ }
107
+ try {
108
+ (0, child_process_1.execFileSync)(nssmPath, ['remove', serviceName, 'confirm'], { stdio: 'pipe' });
79
109
  }
110
+ catch { /* didn't exist */ }
111
+ // NSSM services run as LocalSystem; pass USERPROFILE so the backend's
112
+ // getHomeDir() resolves to the correct user config directory.
113
+ const env = {
114
+ ...configVars,
115
+ OMNIKEY_PORT: String(port),
116
+ USERPROFILE: process.env.USERPROFILE || configDir.replace(/[/\\]\.omnikey$/, ''),
117
+ HOME: process.env.USERPROFILE || configDir.replace(/[/\\]\.omnikey$/, ''),
118
+ };
80
119
  try {
81
- (0, child_process_2.execSync)(`schtasks /create /tn "${taskName}" /tr "cmd /c \\"${wrapperPath}\\"" /sc ONLOGON /f`, { stdio: 'pipe' });
82
- console.log(`Windows Task Scheduler task created: ${taskName}`);
83
- console.log('Omnikey daemon will auto-start on next logon.');
120
+ // Install: nssm install <name> <application> [args...]
121
+ (0, child_process_1.execFileSync)(nssmPath, ['install', serviceName, nodePath, backendPath], { stdio: 'pipe' });
122
+ (0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppDirectory', configDir], { stdio: 'pipe' });
123
+ // Pass all env vars in a single call (replaces the entire AppEnvironmentExtra key)
124
+ const envEntries = Object.entries(env).map(([k, v]) => `${k}=${v}`);
125
+ (0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppEnvironmentExtra', ...envEntries], { stdio: 'pipe' });
126
+ (0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppStdout', logPath], { stdio: 'pipe' });
127
+ (0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppStderr', errorLogPath], { stdio: 'pipe' });
128
+ (0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppRotateFiles', '1'], { stdio: 'pipe' });
129
+ // Restart automatically after a 3-second delay on any exit
130
+ (0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppExit', 'Default', 'Restart'], { stdio: 'pipe' });
131
+ (0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'AppRestartDelay', '3000'], { stdio: 'pipe' });
132
+ // Start automatically at boot (no login required)
133
+ (0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'Start', 'SERVICE_AUTO_START'], { stdio: 'pipe' });
134
+ (0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'DisplayName', 'Omnikey API Backend'], { stdio: 'pipe' });
135
+ (0, child_process_1.execFileSync)(nssmPath, ['set', serviceName, 'Description', 'Omnikey API Backend Daemon'], { stdio: 'pipe' });
136
+ (0, child_process_1.execFileSync)(nssmPath, ['start', serviceName], { stdio: 'pipe' });
137
+ console.log(`NSSM service installed and started: ${serviceName}`);
138
+ console.log('Omnikey daemon runs on boot, without login, and auto-restarts on crash.');
139
+ console.log(`Logs: ${logPath}`);
140
+ console.log(` ${errorLogPath}`);
84
141
  }
85
142
  catch (e) {
86
- console.error('Failed to create Windows Task Scheduler task:', e);
143
+ const msg = e?.stderr?.toString() || e?.message || String(e);
144
+ if (msg.toLowerCase().includes('access') || msg.toLowerCase().includes('privilege')) {
145
+ console.error('Failed to install NSSM service: administrator privileges are required.');
146
+ console.error('Re-run this command in an elevated (Administrator) terminal.');
147
+ }
148
+ else {
149
+ console.error('Failed to install NSSM service:', msg);
150
+ }
87
151
  }
88
- // Also start the backend immediately for the current session
89
- const { out, err } = (0, utils_1.initLogFiles)(logPath, errorLogPath);
90
- const child = (0, child_process_1.spawn)(nodePath, [backendPath], {
91
- env: { ...configVars, OMNIKEY_PORT: String(port) },
92
- detached: true,
93
- stdio: ['ignore', out, err],
94
- });
95
- child.unref();
96
- console.log(`Omnikey API backend started as a daemon on port ${port}. PID: ${child.pid}`);
97
152
  }
98
153
  function startDaemonMacOS(opts) {
99
154
  const { port, configDir, configVars, nodePath, backendPath, logPath, errorLogPath } = opts;
@@ -136,8 +191,8 @@ function startDaemonMacOS(opts) {
136
191
  fs_1.default.mkdirSync(launchAgentsDir, { recursive: true });
137
192
  fs_1.default.writeFileSync(plistPath, plistContent, 'utf-8');
138
193
  (0, utils_1.initLogFiles)(logPath, errorLogPath);
139
- (0, child_process_2.execSync)(`launchctl unload "${plistPath}" || true`);
140
- (0, child_process_2.execSync)(`launchctl load "${plistPath}"`);
194
+ (0, child_process_1.execSync)(`launchctl unload "${plistPath}" || true`);
195
+ (0, child_process_1.execSync)(`launchctl load "${plistPath}"`);
141
196
  console.log(`Launch agent created and loaded: ${plistPath}`);
142
197
  console.log('Omnikey daemon will auto-restart and persist across reboots.');
143
198
  // launchd starts the process via RunAtLoad — no manual spawn needed here.
package/dist/index.js CHANGED
@@ -8,6 +8,8 @@ const killDaemon_1 = require("./killDaemon");
8
8
  const removeConfig_1 = require("./removeConfig");
9
9
  const status_1 = require("./status");
10
10
  const showLogs_1 = require("./showLogs");
11
+ const showConfig_1 = require("./showConfig");
12
+ const setConfig_1 = require("./setConfig");
11
13
  const program = new commander_1.Command();
12
14
  program
13
15
  .name('omnikey')
@@ -23,9 +25,9 @@ program
23
25
  .command('daemon')
24
26
  .description('Start the Omnikey API backend as a daemon on a specified port')
25
27
  .option('--port <port>', 'Port to run the backend on', '7071')
26
- .action((options) => {
28
+ .action(async (options) => {
27
29
  const port = Number(options.port) || 7071;
28
- (0, daemon_1.startDaemon)(port);
30
+ await (0, daemon_1.startDaemon)(port);
29
31
  });
30
32
  program
31
33
  .command('kill-daemon')
@@ -56,4 +58,25 @@ program
56
58
  const errorsOnly = !!options.errors;
57
59
  (0, showLogs_1.showLogs)(lines, errorsOnly);
58
60
  });
61
+ program
62
+ .command('config')
63
+ .description('Show the current Omnikey configuration (API keys are masked)')
64
+ .action(() => {
65
+ (0, showConfig_1.showConfig)();
66
+ });
67
+ program
68
+ .command('set <key> <value>')
69
+ .description('Set a single configuration key (e.g. omnikey set OMNIKEY_PORT 8080)')
70
+ .action((key, value) => {
71
+ (0, setConfig_1.setConfig)(key, value);
72
+ });
73
+ program
74
+ .command('restart-daemon')
75
+ .description('Restart the Omnikey API backend daemon')
76
+ .option('--port <port>', 'Port to run the backend on', '7071')
77
+ .action(async (options) => {
78
+ (0, killDaemon_1.killDaemon)();
79
+ const port = Number(options.port) || 7071;
80
+ await (0, daemon_1.startDaemon)(port);
81
+ });
59
82
  program.parseAsync(process.argv);
@@ -29,29 +29,47 @@ function killLaunchdAgent() {
29
29
  }
30
30
  }
31
31
  function killWindowsTask() {
32
- const taskName = 'OmnikeyDaemon';
32
+ const serviceName = 'OmnikeyDaemon';
33
+ // Try NSSM first (current implementation)
34
+ let nssmPath = null;
33
35
  try {
34
- (0, child_process_1.execSync)(`schtasks /end /tn "${taskName}"`, { stdio: 'pipe' });
36
+ nssmPath = (0, child_process_1.execSync)('where nssm', { stdio: 'pipe' }).toString().trim().split('\n')[0].trim();
35
37
  }
36
- catch {
37
- // Task may not be running — that's fine
38
- }
39
- try {
40
- (0, child_process_1.execSync)(`schtasks /delete /tn "${taskName}" /f`, { stdio: 'pipe' });
41
- console.log(`Removed Windows Task Scheduler task: ${taskName}`);
38
+ catch { /* NSSM not installed */ }
39
+ if (nssmPath) {
40
+ try {
41
+ (0, child_process_1.execFileSync)(nssmPath, ['stop', serviceName], { stdio: 'pipe' });
42
+ }
43
+ catch { /* not running */ }
44
+ try {
45
+ (0, child_process_1.execFileSync)(nssmPath, ['remove', serviceName, 'confirm'], { stdio: 'pipe' });
46
+ console.log(`Removed NSSM service: ${serviceName}`);
47
+ }
48
+ catch {
49
+ console.log(`NSSM service does not exist: ${serviceName}`);
50
+ }
42
51
  }
43
- catch {
44
- console.log(`Windows Task Scheduler task does not exist: ${taskName}`);
52
+ else {
53
+ // Fallback: remove legacy Task Scheduler task from previous installs
54
+ try {
55
+ (0, child_process_1.execSync)(`schtasks /end /tn "${serviceName}"`, { stdio: 'pipe' });
56
+ }
57
+ catch { /* not running */ }
58
+ try {
59
+ (0, child_process_1.execSync)(`schtasks /delete /tn "${serviceName}" /f`, { stdio: 'pipe' });
60
+ console.log(`Removed Windows Task Scheduler task: ${serviceName}`);
61
+ }
62
+ catch {
63
+ console.log(`Windows Task Scheduler task does not exist: ${serviceName}`);
64
+ }
45
65
  }
46
- // Also remove the wrapper script
66
+ // Remove legacy wrapper script if present
47
67
  const wrapperPath = path_1.default.join((0, utils_1.getConfigDir)(), 'start-daemon.cmd');
48
68
  if (fs_1.default.existsSync(wrapperPath)) {
49
69
  try {
50
70
  fs_1.default.rmSync(wrapperPath);
51
71
  }
52
- catch {
53
- // Ignore
54
- }
72
+ catch { /* ignore */ }
55
73
  }
56
74
  }
57
75
  /**
@@ -117,14 +135,22 @@ function removeConfigAndDb(includeDb = false) {
117
135
  else {
118
136
  console.log('Skipping SQLite database removal (use --db to remove it).');
119
137
  }
120
- // Remove .omnikey directory
138
+ // Remove all files/folders inside .omnikey except the SQLite database
121
139
  if (fs_1.default.existsSync(configDir)) {
122
140
  try {
123
- fs_1.default.rmSync(configDir, { recursive: true, force: true });
124
- console.log(`Removed config directory: ${configDir}`);
141
+ const entries = fs_1.default.readdirSync(configDir);
142
+ for (const entry of entries) {
143
+ if (entry.endsWith('.sqlite')) {
144
+ continue;
145
+ }
146
+ const entryPath = path_1.default.join(configDir, entry);
147
+ fs_1.default.rmSync(entryPath, { recursive: true, force: true });
148
+ console.log(`Removed: ${entryPath}`);
149
+ }
150
+ console.log(`Cleared config directory (SQLite preserved): ${configDir}`);
125
151
  }
126
152
  catch (e) {
127
- console.error(`Failed to remove config directory: ${e}`);
153
+ console.error(`Failed to clear config directory: ${e}`);
128
154
  }
129
155
  }
130
156
  else {
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.setConfig = setConfig;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const utils_1 = require("./utils");
9
+ function setConfig(key, value) {
10
+ const configDir = (0, utils_1.getConfigDir)();
11
+ const configPath = (0, utils_1.getConfigPath)();
12
+ if (!fs_1.default.existsSync(configDir)) {
13
+ fs_1.default.mkdirSync(configDir, { recursive: true });
14
+ }
15
+ const config = (0, utils_1.readConfig)();
16
+ config[key] = value;
17
+ fs_1.default.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
18
+ console.log(`Set ${key} in ${configPath}`);
19
+ }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.showConfig = showConfig;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const utils_1 = require("./utils");
9
+ const API_KEY_FIELDS = [
10
+ 'OPENAI_API_KEY',
11
+ 'ANTHROPIC_API_KEY',
12
+ 'GEMINI_API_KEY',
13
+ 'SERPER_API_KEY',
14
+ 'BRAVE_SEARCH_API_KEY',
15
+ 'TAVILY_API_KEY',
16
+ ];
17
+ function maskSecret(value) {
18
+ if (value.length <= 8)
19
+ return '****';
20
+ return value.slice(0, 4) + '****' + value.slice(-4);
21
+ }
22
+ function showConfig() {
23
+ const configPath = (0, utils_1.getConfigPath)();
24
+ const configDir = (0, utils_1.getConfigDir)();
25
+ if (!fs_1.default.existsSync(configPath)) {
26
+ console.log('No configuration found. Run `omnikey onboard` to get started.');
27
+ return;
28
+ }
29
+ const config = (0, utils_1.readConfig)();
30
+ const keys = Object.keys(config);
31
+ if (keys.length === 0) {
32
+ console.log('Configuration file exists but is empty.');
33
+ return;
34
+ }
35
+ console.log(`Config file: ${configPath}\n`);
36
+ console.log('Current configuration:');
37
+ console.log('─'.repeat(50));
38
+ for (const key of keys) {
39
+ const raw = String(config[key]);
40
+ const display = API_KEY_FIELDS.includes(key) ? maskSecret(raw) : raw;
41
+ console.log(` ${key}: ${display}`);
42
+ }
43
+ console.log('─'.repeat(50));
44
+ console.log(`\nConfig directory: ${configDir}`);
45
+ }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
7
- "version": "1.0.16",
7
+ "version": "1.0.18",
8
8
  "description": "CLI for onboarding users to Omnikey AI and configuring OPENAI_API_KEY. Use Yarn for install/build.",
9
9
  "engines": {
10
10
  "node": ">=14.0.0",
package/src/daemon.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { spawn } from 'child_process';
2
1
  import path from 'path';
3
2
  import fs from 'fs';
4
- import { execSync } from 'child_process';
3
+ import { execSync, execFileSync } from 'child_process';
4
+ import inquirer from 'inquirer';
5
5
  import {
6
6
  isWindows,
7
7
  getHomeDir,
@@ -14,10 +14,10 @@ import {
14
14
  /**
15
15
  * Start the Omnikey API backend as a daemon on the specified port.
16
16
  * On macOS: creates and registers a launchd agent for persistence.
17
- * On Windows: creates a wrapper script and registers a Windows Task Scheduler task.
17
+ * On Windows: installs an NSSM Windows service for boot-time persistence.
18
18
  * @param port The port to run the backend on
19
19
  */
20
- export function startDaemon(port: number = 7071) {
20
+ export async function startDaemon(port: number = 7071) {
21
21
  const backendPath = path.resolve(__dirname, '../backend-dist/index.js');
22
22
 
23
23
  const configDir = getConfigDir();
@@ -38,7 +38,7 @@ export function startDaemon(port: number = 7071) {
38
38
  const errorLogPath = path.join(configDir, 'daemon-error.log');
39
39
 
40
40
  if (isWindows) {
41
- startDaemonWindows({
41
+ await startDaemonWindows({
42
42
  port,
43
43
  configDir,
44
44
  configVars,
@@ -62,57 +62,115 @@ interface DaemonOptions {
62
62
  errorLogPath: string;
63
63
  }
64
64
 
65
- function startDaemonWindows(opts: DaemonOptions) {
65
+ function resolveNssm(): string | null {
66
+ try {
67
+ return execSync('where nssm', { stdio: 'pipe' }).toString().trim().split('\n')[0].trim();
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ async function startDaemonWindows(opts: DaemonOptions) {
66
74
  const { port, configDir, configVars, nodePath, backendPath, logPath, errorLogPath } = opts;
75
+ const serviceName = 'OmnikeyDaemon';
67
76
 
68
- // Write a wrapper .cmd script that sets env vars and launches the backend
69
- const wrapperPath = path.join(configDir, 'start-daemon.cmd');
70
- const envSetLines = Object.entries({ ...configVars, OMNIKEY_PORT: String(port) })
71
- .map(([k, v]) => `set "${k}=${v}"`)
72
- .join('\r\n');
73
- const wrapperContent = [
74
- '@echo off',
75
- envSetLines,
76
- `"${nodePath}" "${backendPath}" >> "${logPath}" 2>> "${errorLogPath}"`,
77
- '',
78
- ].join('\r\n');
77
+ let nssmPath = resolveNssm();
78
+ if (!nssmPath) {
79
+ const { install } = await inquirer.prompt<{ install: boolean }>([
80
+ {
81
+ type: 'confirm',
82
+ name: 'install',
83
+ message: 'NSSM is required but not found. Install it now via winget?',
84
+ default: true,
85
+ },
86
+ ]);
79
87
 
80
- try {
81
- fs.mkdirSync(configDir, { recursive: true });
82
- fs.writeFileSync(wrapperPath, wrapperContent, 'utf-8');
83
- } catch (e) {
84
- console.error('Failed to write start-daemon.cmd:', e);
85
- return;
86
- }
88
+ if (!install) {
89
+ console.log('Aborted. Install NSSM manually and re-run in an elevated (Administrator) terminal.');
90
+ return;
91
+ }
87
92
 
88
- // Register with Windows Task Scheduler so the daemon persists across reboots
89
- const taskName = 'OmnikeyDaemon';
90
- try {
91
- // Delete existing task silently before creating a fresh one
92
- execSync(`schtasks /delete /tn "${taskName}" /f`, { stdio: 'pipe' });
93
- } catch {
94
- // Task may not exist that's fine
93
+ console.log('Installing NSSM via winget...');
94
+ try {
95
+ execSync('winget install nssm --accept-package-agreements --accept-source-agreements', {
96
+ stdio: 'inherit',
97
+ });
98
+ } catch (e) {
99
+ console.error('winget install failed:', (e as any)?.message ?? e);
100
+ console.log('Try manually: scoop install nssm or choco install nssm');
101
+ return;
102
+ }
103
+
104
+ // winget updates the machine PATH in the registry but the current process
105
+ // won't see it — spawn a fresh cmd to resolve the new location.
106
+ try {
107
+ nssmPath = execSync('cmd /c where nssm', { stdio: 'pipe' })
108
+ .toString().trim().split('\n')[0].trim();
109
+ } catch {
110
+ nssmPath = null;
111
+ }
112
+
113
+ if (!nssmPath) {
114
+ console.log('NSSM installed successfully.');
115
+ console.log('Please open a new elevated (Administrator) terminal and re-run this command.');
116
+ return;
117
+ }
95
118
  }
119
+
120
+ initLogFiles(logPath, errorLogPath);
121
+
122
+ // Remove any existing service (stop first, then remove)
123
+ try { execFileSync(nssmPath, ['stop', serviceName], { stdio: 'pipe' }); } catch { /* not running */ }
124
+ try { execFileSync(nssmPath, ['remove', serviceName, 'confirm'], { stdio: 'pipe' }); } catch { /* didn't exist */ }
125
+
126
+ // NSSM services run as LocalSystem; pass USERPROFILE so the backend's
127
+ // getHomeDir() resolves to the correct user config directory.
128
+ const env: Record<string, string> = {
129
+ ...configVars,
130
+ OMNIKEY_PORT: String(port),
131
+ USERPROFILE: process.env.USERPROFILE || configDir.replace(/[/\\]\.omnikey$/, ''),
132
+ HOME: process.env.USERPROFILE || configDir.replace(/[/\\]\.omnikey$/, ''),
133
+ };
134
+
96
135
  try {
97
- execSync(
98
- `schtasks /create /tn "${taskName}" /tr "cmd /c \\"${wrapperPath}\\"" /sc ONLOGON /f`,
99
- { stdio: 'pipe' },
100
- );
101
- console.log(`Windows Task Scheduler task created: ${taskName}`);
102
- console.log('Omnikey daemon will auto-start on next logon.');
103
- } catch (e) {
104
- console.error('Failed to create Windows Task Scheduler task:', e);
105
- }
136
+ // Install: nssm install <name> <application> [args...]
137
+ execFileSync(nssmPath, ['install', serviceName, nodePath, backendPath], { stdio: 'pipe' });
138
+
139
+ execFileSync(nssmPath, ['set', serviceName, 'AppDirectory', configDir], { stdio: 'pipe' });
140
+
141
+ // Pass all env vars in a single call (replaces the entire AppEnvironmentExtra key)
142
+ const envEntries = Object.entries(env).map(([k, v]) => `${k}=${v}`);
143
+ execFileSync(nssmPath, ['set', serviceName, 'AppEnvironmentExtra', ...envEntries], { stdio: 'pipe' });
106
144
 
107
- // Also start the backend immediately for the current session
108
- const { out, err } = initLogFiles(logPath, errorLogPath);
109
- const child = spawn(nodePath, [backendPath], {
110
- env: { ...configVars, OMNIKEY_PORT: String(port) },
111
- detached: true,
112
- stdio: ['ignore', out, err],
113
- });
114
- child.unref();
115
- console.log(`Omnikey API backend started as a daemon on port ${port}. PID: ${child.pid}`);
145
+ execFileSync(nssmPath, ['set', serviceName, 'AppStdout', logPath], { stdio: 'pipe' });
146
+ execFileSync(nssmPath, ['set', serviceName, 'AppStderr', errorLogPath], { stdio: 'pipe' });
147
+ execFileSync(nssmPath, ['set', serviceName, 'AppRotateFiles', '1'], { stdio: 'pipe' });
148
+
149
+ // Restart automatically after a 3-second delay on any exit
150
+ execFileSync(nssmPath, ['set', serviceName, 'AppExit', 'Default', 'Restart'], { stdio: 'pipe' });
151
+ execFileSync(nssmPath, ['set', serviceName, 'AppRestartDelay', '3000'], { stdio: 'pipe' });
152
+
153
+ // Start automatically at boot (no login required)
154
+ execFileSync(nssmPath, ['set', serviceName, 'Start', 'SERVICE_AUTO_START'], { stdio: 'pipe' });
155
+
156
+ execFileSync(nssmPath, ['set', serviceName, 'DisplayName', 'Omnikey API Backend'], { stdio: 'pipe' });
157
+ execFileSync(nssmPath, ['set', serviceName, 'Description', 'Omnikey API Backend Daemon'], { stdio: 'pipe' });
158
+
159
+ execFileSync(nssmPath, ['start', serviceName], { stdio: 'pipe' });
160
+
161
+ console.log(`NSSM service installed and started: ${serviceName}`);
162
+ console.log('Omnikey daemon runs on boot, without login, and auto-restarts on crash.');
163
+ console.log(`Logs: ${logPath}`);
164
+ console.log(` ${errorLogPath}`);
165
+ } catch (e: any) {
166
+ const msg: string = e?.stderr?.toString() || e?.message || String(e);
167
+ if (msg.toLowerCase().includes('access') || msg.toLowerCase().includes('privilege')) {
168
+ console.error('Failed to install NSSM service: administrator privileges are required.');
169
+ console.error('Re-run this command in an elevated (Administrator) terminal.');
170
+ } else {
171
+ console.error('Failed to install NSSM service:', msg);
172
+ }
173
+ }
116
174
  }
117
175
 
118
176
  function startDaemonMacOS(opts: DaemonOptions) {
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ import { killDaemon } from './killDaemon';
7
7
  import { removeConfigAndDb } from './removeConfig';
8
8
  import { statusCmd } from './status';
9
9
  import { showLogs } from './showLogs';
10
+ import { showConfig } from './showConfig';
11
+ import { setConfig } from './setConfig';
10
12
 
11
13
  const program = new Command();
12
14
 
@@ -26,9 +28,9 @@ program
26
28
  .command('daemon')
27
29
  .description('Start the Omnikey API backend as a daemon on a specified port')
28
30
  .option('--port <port>', 'Port to run the backend on', '7071')
29
- .action((options) => {
31
+ .action(async (options) => {
30
32
  const port = Number(options.port) || 7071;
31
- startDaemon(port);
33
+ await startDaemon(port);
32
34
  });
33
35
 
34
36
  program
@@ -64,4 +66,28 @@ program
64
66
  showLogs(lines, errorsOnly);
65
67
  });
66
68
 
69
+ program
70
+ .command('config')
71
+ .description('Show the current Omnikey configuration (API keys are masked)')
72
+ .action(() => {
73
+ showConfig();
74
+ });
75
+
76
+ program
77
+ .command('set <key> <value>')
78
+ .description('Set a single configuration key (e.g. omnikey set OMNIKEY_PORT 8080)')
79
+ .action((key: string, value: string) => {
80
+ setConfig(key, value);
81
+ });
82
+
83
+ program
84
+ .command('restart-daemon')
85
+ .description('Restart the Omnikey API backend daemon')
86
+ .option('--port <port>', 'Port to run the backend on', '7071')
87
+ .action(async (options) => {
88
+ killDaemon();
89
+ const port = Number(options.port) || 7071;
90
+ await startDaemon(port);
91
+ });
92
+
67
93
  program.parseAsync(process.argv);