omnikey-cli 1.6.0 → 1.6.2

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.
@@ -0,0 +1,282 @@
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.aiProviderRouter = aiProviderRouter;
7
+ const express_1 = __importDefault(require("express"));
8
+ const zod_1 = __importDefault(require("zod"));
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const os_1 = __importDefault(require("os"));
12
+ const authMiddleware_1 = require("./authMiddleware");
13
+ const config_1 = require("./config");
14
+ const logger_1 = require("./logger");
15
+ const scheduledJobExecutor_1 = require("./scheduledJobExecutor");
16
+ const providerEnum = zod_1.default.enum(['openai', 'anthropic', 'gemini', 'nemotron']);
17
+ const putSchema = zod_1.default.object({
18
+ apiKey: zod_1.default.string().min(1).max(4096),
19
+ baseUrl: zod_1.default
20
+ .string()
21
+ .max(1000)
22
+ .url({ message: 'baseUrl must be a valid URL.' })
23
+ .nullable()
24
+ .optional(),
25
+ });
26
+ /** Mapping from provider → env var that holds its API key. */
27
+ const PROVIDER_ENV_KEY = {
28
+ openai: 'OPENAI_API_KEY',
29
+ anthropic: 'ANTHROPIC_API_KEY',
30
+ gemini: 'GEMINI_API_KEY',
31
+ nemotron: 'NEMOTRON_API_KEY',
32
+ };
33
+ /** Legacy aliases that should be cleared whenever the canonical env is rewritten. */
34
+ const PROVIDER_LEGACY_ALIASES = {
35
+ nemotron: ['NVIDIA_API_KEY'],
36
+ };
37
+ function getConfigPath() {
38
+ const home = process.env.HOME || process.env.USERPROFILE || os_1.default.homedir();
39
+ return path_1.default.join(home, '.omnikey', 'config.json');
40
+ }
41
+ function readConfigFile() {
42
+ const configPath = getConfigPath();
43
+ try {
44
+ if (fs_1.default.existsSync(configPath)) {
45
+ const raw = fs_1.default.readFileSync(configPath, 'utf-8');
46
+ const parsed = JSON.parse(raw);
47
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
48
+ return parsed;
49
+ }
50
+ }
51
+ }
52
+ catch (err) {
53
+ logger_1.logger.warn('Could not read ~/.omnikey/config.json — treating as empty.', { error: err });
54
+ }
55
+ return {};
56
+ }
57
+ function writeConfigFile(data) {
58
+ const configPath = getConfigPath();
59
+ fs_1.default.mkdirSync(path_1.default.dirname(configPath), { recursive: true });
60
+ fs_1.default.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8');
61
+ }
62
+ /** Mask an API key so a small prefix/suffix is visible (e.g. `sk-…AB12`). */
63
+ function maskApiKey(key) {
64
+ if (!key)
65
+ return '';
66
+ if (key.length <= 8)
67
+ return '••••••••';
68
+ return `${key.slice(0, 3)}••••••••${key.slice(-4)}`;
69
+ }
70
+ /** Reads any provider key (canonical or legacy alias) out of the config object. */
71
+ function readProviderKey(cfg, provider) {
72
+ const canonical = cfg[PROVIDER_ENV_KEY[provider]];
73
+ if (canonical && typeof canonical === 'string' && canonical.length > 0)
74
+ return canonical;
75
+ for (const alias of PROVIDER_LEGACY_ALIASES[provider] ?? []) {
76
+ const v = cfg[alias];
77
+ if (v && typeof v === 'string' && v.length > 0)
78
+ return v;
79
+ }
80
+ return undefined;
81
+ }
82
+ function describeProvider(provider, cfg) {
83
+ const key = readProviderKey(cfg, provider);
84
+ return {
85
+ provider,
86
+ isConfigured: Boolean(key),
87
+ apiKeyMasked: maskApiKey(key),
88
+ baseUrl: provider === 'nemotron'
89
+ ? (typeof cfg.NEMOTRON_BASE_URL === 'string' ? cfg.NEMOTRON_BASE_URL : null)
90
+ : null,
91
+ };
92
+ }
93
+ function readActiveProvider(cfg) {
94
+ const raw = cfg.AI_PROVIDER;
95
+ if (raw === 'openai' || raw === 'anthropic' || raw === 'gemini' || raw === 'nemotron') {
96
+ return raw;
97
+ }
98
+ // Fall back to the provider currently bound to the running process (config.ts
99
+ // already auto-detects from the first configured key in env).
100
+ return config_1.config.aiProvider;
101
+ }
102
+ /**
103
+ * Triggers a daemon restart by handing off to the shared `runScript` helper
104
+ * (the same one used by the scheduled-job executor). The script invokes the
105
+ * `omnikey restart-daemon --port <port>` CLI, which tears down the
106
+ * launchd / NSSM persistence agent and brings a fresh daemon back up on the
107
+ * same port — picking up the rewritten config.json values from env on the
108
+ * way up.
109
+ *
110
+ * Important: `omnikey restart-daemon` calls `killDaemon()` first, which kills
111
+ * *this* Node process. To survive that, we wrap the call in `nohup ... &`
112
+ * with stdio redirected to a log file and `disown` so the actual
113
+ * `omnikey restart-daemon` invocation escapes the shell's process tree
114
+ * before this process is terminated. `runScript` is fire-and-forget: we do
115
+ * not await it because we expect to be killed before it returns.
116
+ */
117
+ function scheduleDaemonRestart(reason) {
118
+ setTimeout(() => {
119
+ const port = config_1.config.port;
120
+ const logFile = path_1.default.join(process.env.HOME || os_1.default.homedir(), '.omnikey', 'restart-daemon.log');
121
+ const script = [
122
+ '#!/usr/bin/env bash',
123
+ 'set -u',
124
+ `mkdir -p "$(dirname "${logFile}")"`,
125
+ `echo "[$(date -Iseconds)] restart-daemon triggered: ${reason}" >> "${logFile}"`,
126
+ // Detach the actual restart so it survives this daemon's imminent death.
127
+ `nohup omnikey restart-daemon --port ${port} >> "${logFile}" 2>&1 &`,
128
+ 'disown || true',
129
+ 'exit 0',
130
+ ].join('\n');
131
+ logger_1.logger.info(`Running \`omnikey restart-daemon --port ${port}\` via runScript (${reason})`);
132
+ // Fire-and-forget: we expect `omnikey restart-daemon` to kill this process
133
+ // long before runScript's exec promise resolves.
134
+ void (0, scheduledJobExecutor_1.runScript)(script)
135
+ .then((result) => {
136
+ if (result.isError) {
137
+ logger_1.logger.error('runScript reported a non-zero exit for restart-daemon.', {
138
+ output: result.output,
139
+ });
140
+ }
141
+ })
142
+ .catch((err) => {
143
+ logger_1.logger.error('Unexpected runScript failure while restarting daemon.', { error: err });
144
+ });
145
+ }, 500);
146
+ }
147
+ function aiProviderRouter() {
148
+ const router = express_1.default.Router();
149
+ /** GET /api/providers — list which providers have a key in config.json. */
150
+ router.get('/', authMiddleware_1.authMiddleware, async (_req, res) => {
151
+ const { logger: reqLogger } = res.locals;
152
+ try {
153
+ const cfg = readConfigFile();
154
+ const providers = ['openai', 'anthropic', 'gemini', 'nemotron'];
155
+ const active = readActiveProvider(cfg);
156
+ res.json({
157
+ providers: providers.map((p) => describeProvider(p, cfg)),
158
+ activeProvider: active,
159
+ // The in-process provider may differ from cfg.AI_PROVIDER if the user
160
+ // changed config.json out of band — surface both so the UI can warn.
161
+ runtimeProvider: config_1.config.aiProvider,
162
+ });
163
+ }
164
+ catch (err) {
165
+ reqLogger.error('Error reading provider config.', { error: err });
166
+ res.status(500).json({ error: 'Failed to read provider config.' });
167
+ }
168
+ });
169
+ /** PUT /api/providers/:provider — store/update API key for that provider. */
170
+ router.put('/:provider', authMiddleware_1.authMiddleware, async (req, res) => {
171
+ const { logger: reqLogger } = res.locals;
172
+ const providerParam = providerEnum.safeParse(req.params.provider);
173
+ if (!providerParam.success) {
174
+ return res.status(400).json({ error: 'Unknown provider.' });
175
+ }
176
+ const provider = providerParam.data;
177
+ try {
178
+ const parsed = putSchema.parse(req.body);
179
+ const cfg = readConfigFile();
180
+ // Always clear legacy aliases so the canonical env wins on next boot.
181
+ for (const alias of PROVIDER_LEGACY_ALIASES[provider] ?? []) {
182
+ delete cfg[alias];
183
+ }
184
+ cfg[PROVIDER_ENV_KEY[provider]] = parsed.apiKey;
185
+ if (provider === 'nemotron') {
186
+ if (parsed.baseUrl) {
187
+ cfg.NEMOTRON_BASE_URL = parsed.baseUrl;
188
+ }
189
+ else if (parsed.baseUrl === null) {
190
+ delete cfg.NEMOTRON_BASE_URL;
191
+ }
192
+ }
193
+ writeConfigFile(cfg);
194
+ // If the user updated the currently-active provider's key, the running
195
+ // process is now using a stale key — schedule a restart so it picks
196
+ // the new one up. We only restart when AI_PROVIDER is *explicitly*
197
+ // pinned in config.json, because the auto-detected fallback may differ
198
+ // from what the user is configuring and we don't want to bounce the
199
+ // server on first-time key save.
200
+ const explicitActive = typeof cfg.AI_PROVIDER === 'string' ? cfg.AI_PROVIDER : null;
201
+ let restartScheduled = false;
202
+ if (explicitActive === provider) {
203
+ scheduleDaemonRestart(`updated active provider key (${provider})`);
204
+ restartScheduled = true;
205
+ }
206
+ res.json({
207
+ ...describeProvider(provider, cfg),
208
+ restartScheduled,
209
+ });
210
+ }
211
+ catch (err) {
212
+ reqLogger.error('Error storing provider key.', { error: err });
213
+ if (err instanceof zod_1.default.ZodError) {
214
+ return res.status(400).json({ error: 'Invalid provider data.' });
215
+ }
216
+ res.status(500).json({ error: 'Failed to store provider key.' });
217
+ }
218
+ });
219
+ /** DELETE /api/providers/:provider — remove the saved key. */
220
+ router.delete('/:provider', authMiddleware_1.authMiddleware, async (req, res) => {
221
+ const { logger: reqLogger } = res.locals;
222
+ const providerParam = providerEnum.safeParse(req.params.provider);
223
+ if (!providerParam.success) {
224
+ return res.status(400).json({ error: 'Unknown provider.' });
225
+ }
226
+ const provider = providerParam.data;
227
+ try {
228
+ const cfg = readConfigFile();
229
+ const active = readActiveProvider(cfg);
230
+ if (active === provider) {
231
+ return res.status(409).json({
232
+ error: 'Cannot remove the active provider. Activate a different provider first, then remove this one.',
233
+ });
234
+ }
235
+ delete cfg[PROVIDER_ENV_KEY[provider]];
236
+ for (const alias of PROVIDER_LEGACY_ALIASES[provider] ?? []) {
237
+ delete cfg[alias];
238
+ }
239
+ if (provider === 'nemotron') {
240
+ delete cfg.NEMOTRON_BASE_URL;
241
+ }
242
+ writeConfigFile(cfg);
243
+ res.status(204).send();
244
+ }
245
+ catch (err) {
246
+ reqLogger.error('Error removing provider key.', { error: err });
247
+ res.status(500).json({ error: 'Failed to remove provider key.' });
248
+ }
249
+ });
250
+ /** POST /api/providers/:provider/activate — switch AI_PROVIDER + restart. */
251
+ router.post('/:provider/activate', authMiddleware_1.authMiddleware, async (req, res) => {
252
+ const { logger: reqLogger } = res.locals;
253
+ const providerParam = providerEnum.safeParse(req.params.provider);
254
+ if (!providerParam.success) {
255
+ return res.status(400).json({ error: 'Unknown provider.' });
256
+ }
257
+ const provider = providerParam.data;
258
+ try {
259
+ const cfg = readConfigFile();
260
+ const key = readProviderKey(cfg, provider);
261
+ if (!key) {
262
+ return res.status(400).json({
263
+ error: `No API key saved for provider "${provider}". Save a key first, then activate.`,
264
+ });
265
+ }
266
+ cfg.AI_PROVIDER = provider;
267
+ writeConfigFile(cfg);
268
+ res.json({
269
+ provider,
270
+ activeProvider: provider,
271
+ restartScheduled: true,
272
+ message: 'Provider activated. Server will restart shortly to apply the change.',
273
+ });
274
+ scheduleDaemonRestart(`activated provider ${provider}`);
275
+ }
276
+ catch (err) {
277
+ reqLogger.error('Error activating provider.', { error: err });
278
+ res.status(500).json({ error: 'Failed to activate provider.' });
279
+ }
280
+ });
281
+ return router;
282
+ }
@@ -15,6 +15,7 @@ const logger_1 = require("./logger");
15
15
  const taskInstructionRoutes_1 = require("./taskInstructionRoutes");
16
16
  const scheduledJobRoutes_1 = require("./scheduledJobRoutes");
17
17
  const mcpServerRoutes_1 = require("./mcpServerRoutes");
18
+ const aiProviderRoutes_1 = require("./aiProviderRoutes");
18
19
  const spawn_1 = require("./workers/spawn");
19
20
  const scheduledJobWorkerClient_1 = require("./workers/scheduledJobWorkerClient");
20
21
  const config_1 = require("./config");
@@ -36,6 +37,7 @@ app.use('/api/feature', (0, featureRoutes_1.createFeatureRouter)());
36
37
  app.use('/api/instructions', (0, taskInstructionRoutes_1.taskInstructionRouter)());
37
38
  app.use('/api/scheduled-jobs', (0, scheduledJobRoutes_1.scheduledJobRouter)());
38
39
  app.use('/api/mcp-servers', (0, mcpServerRoutes_1.mcpServerRouter)());
40
+ app.use('/api/providers', (0, aiProviderRoutes_1.aiProviderRouter)());
39
41
  app.use('/api/agent', (0, agentServer_1.createAgentRouter)());
40
42
  app.get('/macos/download', (_req, res) => {
41
43
  const dmgPath = path_1.default.join(process.cwd(), 'macOS', 'OmniKeyAI.dmg');
@@ -43,21 +45,25 @@ app.get('/macos/download', (_req, res) => {
43
45
  res.status(404).send('File not found.');
44
46
  return;
45
47
  }
48
+ let fileSize = 0;
49
+ try {
50
+ fileSize = fs_1.default.statSync(dmgPath).size;
51
+ }
52
+ catch (_) { }
46
53
  res.set({
47
54
  'Content-Type': 'application/octet-stream',
48
55
  'Content-Disposition': 'attachment; filename="OmniKeyAI.dmg"',
49
- 'Content-Encoding': 'gzip',
56
+ ...(fileSize ? { 'Content-Length': String(fileSize) } : {}),
50
57
  });
51
58
  (0, bucket_adapter_1.incrementDownloadCount)('macos').catch(() => { });
52
59
  const fileStream = fs_1.default.createReadStream(dmgPath);
53
- const gzip = zlib_1.default.createGzip();
54
60
  fileStream.on('error', (err) => {
55
61
  logger_1.logger.error('Failed to send OmniKeyAI.dmg for download.', { error: err });
56
62
  if (!res.headersSent) {
57
63
  res.status(500).send('Unable to download file.');
58
64
  }
59
65
  });
60
- fileStream.pipe(gzip).pipe(res);
66
+ fileStream.pipe(res);
61
67
  });
62
68
  // Sparkle appcast feed for macOS updates.
63
69
  // This feed uses the existing /macos/download endpoint as the
@@ -78,8 +84,8 @@ app.get('/macos/appcast', (req, res) => {
78
84
  const appcastUrl = `${baseUrl}/macos/appcast`;
79
85
  // These should match the values embedded into the macOS app
80
86
  // Info.plist in macOS/build_release_dmg.sh.
81
- const bundleVersion = '37';
82
- const shortVersion = '1.0.36';
87
+ const bundleVersion = '39';
88
+ const shortVersion = '1.0.38';
83
89
  const xml = `<?xml version="1.0" encoding="utf-8"?>
84
90
  <rss version="2.0"
85
91
  xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
@@ -181,6 +187,24 @@ app.get('/downloads/stats', async (_req, res) => {
181
187
  app.get('/health', (_req, res) => {
182
188
  res.json({ status: 'ok' });
183
189
  });
190
+ app.get('/install.sh', (_req, res) => {
191
+ const scriptPath = path_1.default.join(process.cwd(), 'install.sh');
192
+ if (!fs_1.default.existsSync(scriptPath)) {
193
+ res.status(404).send('Not found.');
194
+ return;
195
+ }
196
+ res.set('Content-Type', 'text/plain; charset=utf-8');
197
+ res.sendFile(scriptPath);
198
+ });
199
+ app.get('/install.ps1', (_req, res) => {
200
+ const scriptPath = path_1.default.join(process.cwd(), 'install.ps1');
201
+ if (!fs_1.default.existsSync(scriptPath)) {
202
+ res.status(404).send('Not found.');
203
+ return;
204
+ }
205
+ res.set('Content-Type', 'text/plain; charset=utf-8');
206
+ res.sendFile(scriptPath);
207
+ });
184
208
  app.get('*', (_req, res) => {
185
209
  res.sendFile(path_1.default.join(process.cwd(), 'public', 'index.html'));
186
210
  });
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.computeNextRunAt = computeNextRunAt;
7
7
  exports.startScheduledJobExecutor = startScheduledJobExecutor;
8
+ exports.runScript = runScript;
8
9
  exports.executeJob = executeJob;
9
10
  const child_process_1 = require("child_process");
10
11
  const promises_1 = require("fs/promises");
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
7
- "version": "1.6.0",
7
+ "version": "1.6.2",
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",