@telepat/snoopy 0.1.8 → 0.1.10

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/README.md CHANGED
@@ -102,6 +102,7 @@ Note:
102
102
  - Snoopy uses Reddit public JSON endpoints by default.
103
103
  - Optional Reddit OAuth fallback credentials can be configured in `snoopy settings` for environments where unauthenticated access is blocked.
104
104
  - `snoopy settings` shows a full settings menu so you can jump directly to any setting and save once.
105
+ - If keychain storage is unavailable, configure secrets with environment variables (`SNOOPY_OPENROUTER_API_KEY`, `SNOOPY_REDDIT_CLIENT_SECRET`).
105
106
 
106
107
  1. Start interactive setup and create your first job:
107
108
 
@@ -3,7 +3,7 @@ import { getDb } from '../../services/db/sqlite.js';
3
3
  import { JobsRepository } from '../../services/db/repositories/jobsRepo.js';
4
4
  import { RunsRepository } from '../../services/db/repositories/runsRepo.js';
5
5
  import { extractErrorEntries, readRunLog } from '../../services/logging/logReader.js';
6
- import { getOpenRouterApiKey } from '../../services/security/secretStore.js';
6
+ import { getOpenRouterApiKey, isKeytarAvailable } from '../../services/security/secretStore.js';
7
7
  import { getStartupStatus } from '../../services/startup/index.js';
8
8
  import { ensureAppDirs } from '../../utils/paths.js';
9
9
  import { printCommandScreen, printError, printInfo, printKeyValue, printMuted, printSection, printSuccess, printWarning } from '../ui/consoleUi.js';
@@ -51,6 +51,7 @@ export async function runDoctor() {
51
51
  const jobs = jobsRepo.list();
52
52
  const enabledJobs = jobs.filter((job) => job.enabled).length;
53
53
  const apiKey = await getOpenRouterApiKey();
54
+ const keytarAvailable = await isKeytarAvailable();
54
55
  const startup = getStartupStatus();
55
56
  const daemon = getDaemonHealth();
56
57
  printKeyValue('Platform', process.platform);
@@ -67,7 +68,12 @@ export async function runDoctor() {
67
68
  }
68
69
  else {
69
70
  printWarning('OpenRouter API key: missing');
70
- printMuted(' → Run: snoopy settings to configure your OpenRouter API key');
71
+ if (keytarAvailable) {
72
+ printMuted(' → Run: snoopy settings to configure your OpenRouter API key');
73
+ }
74
+ else {
75
+ printMuted(' → Export SNOOPY_OPENROUTER_API_KEY to configure your OpenRouter API key');
76
+ }
71
77
  }
72
78
  printInfo(`Jobs: ${jobs.length} total, ${enabledJobs} enabled`);
73
79
  if (daemon.ok) {
@@ -4,7 +4,7 @@ import { JobAddFlow } from '../flows/jobAddFlow.js';
4
4
  import { JobsRepository } from '../../services/db/repositories/jobsRepo.js';
5
5
  import { SettingsRepository } from '../../services/db/repositories/settingsRepo.js';
6
6
  import { RunsRepository } from '../../services/db/repositories/runsRepo.js';
7
- import { deleteOpenRouterApiKey, getOpenRouterApiKey, setOpenRouterApiKey } from '../../services/security/secretStore.js';
7
+ import { deleteOpenRouterApiKey, getOpenRouterApiKey, isKeytarAvailable, setOpenRouterApiKey } from '../../services/security/secretStore.js';
8
8
  import { getStartupStatus, installStartup } from '../../services/startup/index.js';
9
9
  import { ensureDaemonRunning, requestDaemonReload } from '../../services/daemonControl.js';
10
10
  import { JobRunner } from '../../services/scheduler/jobRunner.js';
@@ -28,11 +28,11 @@ function formatRunDuration(startedAt, finishedAt) {
28
28
  }
29
29
  return `${Math.round((endMs - startMs) / 1000)}s`;
30
30
  }
31
- async function runAddFlow(hasApiKey, existingApiKey, startupAlreadyEnabled) {
31
+ async function runAddFlow(hasApiKey, existingApiKey, keytarAvailable, startupAlreadyEnabled) {
32
32
  const settingsRepo = new SettingsRepository();
33
33
  const initialSettings = settingsRepo.getAppSettings();
34
34
  let result = null;
35
- const app = render(_jsx(JobAddFlow, { hasApiKey: hasApiKey, existingApiKey: existingApiKey, startupAlreadyEnabled: startupAlreadyEnabled, initialSettings: initialSettings, onApiKeyCaptured: (apiKey) => {
35
+ const app = render(_jsx(JobAddFlow, { hasApiKey: hasApiKey, existingApiKey: existingApiKey, canPersistApiKey: keytarAvailable, startupAlreadyEnabled: startupAlreadyEnabled, initialSettings: initialSettings, onApiKeyCaptured: (apiKey) => {
36
36
  return setOpenRouterApiKey(apiKey);
37
37
  }, onAuthFailure: () => {
38
38
  return deleteOpenRouterApiKey();
@@ -99,16 +99,19 @@ export async function runInitialJobAndEnable(jobRef, options = {}) {
99
99
  export async function addJob() {
100
100
  const jobsRepo = new JobsRepository();
101
101
  const settingsRepo = new SettingsRepository();
102
+ const keytarAvailable = await isKeytarAvailable();
102
103
  const existingApiKey = await getOpenRouterApiKey();
103
104
  const hasApiKey = Boolean(existingApiKey);
104
105
  const startupStatus = getStartupStatus();
105
- const flowResult = await runAddFlow(hasApiKey, existingApiKey, startupStatus.enabled);
106
+ const flowResult = await runAddFlow(hasApiKey, existingApiKey, keytarAvailable, startupStatus.enabled);
106
107
  if (!flowResult) {
107
108
  printWarning('Job creation cancelled.');
108
109
  return;
109
110
  }
110
111
  if (flowResult.apiKey) {
111
- await setOpenRouterApiKey(flowResult.apiKey);
112
+ if (keytarAvailable) {
113
+ await setOpenRouterApiKey(flowResult.apiKey);
114
+ }
112
115
  }
113
116
  const currentSettings = settingsRepo.getAppSettings();
114
117
  settingsRepo.setAppSettings({
@@ -2,15 +2,16 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from 'ink';
3
3
  import { SettingsFlow } from '../flows/settingsFlow.js';
4
4
  import { SettingsRepository } from '../../services/db/repositories/settingsRepo.js';
5
- import { getOpenRouterApiKey, setOpenRouterApiKey } from '../../services/security/secretStore.js';
6
- import { printSuccess, printWarning } from '../ui/consoleUi.js';
5
+ import { getOpenRouterApiKey, isKeytarAvailable, setOpenRouterApiKey } from '../../services/security/secretStore.js';
6
+ import { printInfo, printSuccess, printWarning } from '../ui/consoleUi.js';
7
7
  export async function openSettings() {
8
8
  const settingsRepo = new SettingsRepository();
9
9
  const current = settingsRepo.getAppSettings();
10
+ const keytarAvailable = await isKeytarAvailable();
10
11
  const currentRedditCredentials = await settingsRepo.getRedditCredentialState();
11
12
  const currentApiKey = await getOpenRouterApiKey();
12
13
  let result = null;
13
- const app = render(_jsx(SettingsFlow, { current: current, currentRedditCredentials: currentRedditCredentials, currentApiKey: currentApiKey, onDone: (value) => {
14
+ const app = render(_jsx(SettingsFlow, { current: current, keytarAvailable: keytarAvailable, currentRedditCredentials: currentRedditCredentials, currentApiKey: currentApiKey, onDone: (value) => {
14
15
  result = value;
15
16
  } }));
16
17
  await app.waitUntilExit();
@@ -19,11 +20,22 @@ export async function openSettings() {
19
20
  printWarning('Settings unchanged.');
20
21
  return;
21
22
  }
23
+ if (!keytarAvailable && (finalResult.apiKey || finalResult.redditCredentials?.clientSecret)) {
24
+ printWarning('Keychain storage is unavailable. Secret values entered in settings were not saved.');
25
+ printInfo('Use environment variables instead:');
26
+ printInfo(' SNOOPY_OPENROUTER_API_KEY');
27
+ printInfo(' SNOOPY_REDDIT_CLIENT_SECRET');
28
+ }
22
29
  if (finalResult.apiKey) {
23
- await setOpenRouterApiKey(finalResult.apiKey);
30
+ if (keytarAvailable) {
31
+ await setOpenRouterApiKey(finalResult.apiKey);
32
+ }
24
33
  }
25
34
  if (finalResult.redditCredentials) {
26
- await settingsRepo.setRedditCredentials(finalResult.redditCredentials);
35
+ const redditCredentials = keytarAvailable
36
+ ? finalResult.redditCredentials
37
+ : { ...finalResult.redditCredentials, clientSecret: undefined };
38
+ await settingsRepo.setRedditCredentials(redditCredentials);
27
39
  }
28
40
  settingsRepo.setAppSettings(finalResult.settings);
29
41
  printSuccess('Settings saved.');
@@ -16,11 +16,12 @@ interface JobAddResult {
16
16
  interface JobAddFlowProps {
17
17
  hasApiKey: boolean;
18
18
  existingApiKey: string | null;
19
+ canPersistApiKey: boolean;
19
20
  startupAlreadyEnabled: boolean;
20
21
  initialSettings: AppSettings;
21
22
  onApiKeyCaptured?: (apiKey: string) => Promise<void> | void;
22
23
  onAuthFailure?: () => Promise<void> | void;
23
24
  onDone: (result: JobAddResult) => void;
24
25
  }
25
- export declare function JobAddFlow({ hasApiKey, existingApiKey, startupAlreadyEnabled, initialSettings, onApiKeyCaptured, onAuthFailure, onDone }: JobAddFlowProps): React.JSX.Element;
26
+ export declare function JobAddFlow({ hasApiKey, existingApiKey, canPersistApiKey, startupAlreadyEnabled, initialSettings, onApiKeyCaptured, onAuthFailure, onDone }: JobAddFlowProps): React.JSX.Element;
26
27
  export {};
@@ -12,7 +12,7 @@ import { uiTheme } from '../../ui/theme.js';
12
12
  function FlowFrame({ transcript, children, statusText, statusTone = 'info' }) {
13
13
  return (_jsxs(AppFrame, { subtitle: "Add job wizard", statusText: statusText, statusTone: statusTone, hints: ['Enter: confirm', 'Esc: cancel current step'], children: [transcript.length > 0 ? (_jsx(Panel, { title: "Session Transcript", children: transcript.map((entry, index) => (_jsxs(Text, { color: entry.muted ? uiTheme.ink.textMuted : uiTheme.ink.textPrimary, children: [_jsxs(Text, { color: uiTheme.ink.accent, children: [entry.label, ":"] }), " ", entry.value] }, `${entry.label}-${index}`))) })) : null, children] }));
14
14
  }
15
- export function JobAddFlow({ hasApiKey, existingApiKey, startupAlreadyEnabled, initialSettings, onApiKeyCaptured, onAuthFailure, onDone }) {
15
+ export function JobAddFlow({ hasApiKey, existingApiKey, canPersistApiKey, startupAlreadyEnabled, initialSettings, onApiKeyCaptured, onAuthFailure, onDone }) {
16
16
  const defaultModel = initialSettings.model.trim() || DEFAULT_MODEL;
17
17
  const { exit } = useApp();
18
18
  const [stage, setStage] = useState('criteria');
@@ -63,7 +63,7 @@ export function JobAddFlow({ hasApiKey, existingApiKey, startupAlreadyEnabled, i
63
63
  void run();
64
64
  }, [answers, apiKey, criteria, defaultModel, existingApiKey, hasApiKey, model, onAuthFailure, stage]);
65
65
  useEffect(() => {
66
- if (stage !== 'error') {
66
+ if (stage !== 'error' && stage !== 'apiKeyEnvHelp') {
67
67
  return;
68
68
  }
69
69
  const timeoutId = setTimeout(() => {
@@ -100,14 +100,30 @@ export function JobAddFlow({ hasApiKey, existingApiKey, startupAlreadyEnabled, i
100
100
  return (_jsx(FlowFrame, { transcript: transcript, statusText: "Define monitoring criteria", statusTone: "info", children: _jsx(Panel, { title: "Step 1: Criteria", children: _jsx(TextPrompt, { label: "Describe the conversations you want to monitor", onSubmit: (value) => {
101
101
  setCriteria(value);
102
102
  appendTranscript('Criteria', value);
103
- setStage(hasApiKey ? 'model' : 'apiKey');
103
+ if (hasApiKey) {
104
+ setStage('model');
105
+ return;
106
+ }
107
+ setStage(canPersistApiKey ? 'apiKey' : 'apiKeyEnvHelp');
104
108
  } }) }) }));
105
109
  }
110
+ if (stage === 'apiKeyEnvHelp') {
111
+ return (_jsx(FlowFrame, { transcript: transcript, statusText: "OpenRouter API key required", statusTone: "warning", children: _jsxs(Panel, { title: "Step 2: Authentication", children: [_jsx(Text, { color: uiTheme.ink.warning, children: "Keychain storage is unavailable on this system." }), _jsx(Text, { children: "Set your API key with environment variable:" }), _jsx(Text, { color: uiTheme.ink.accent, children: "SNOOPY_OPENROUTER_API_KEY=<your-key>" }), _jsx(Text, { color: uiTheme.ink.textMuted, children: "After setting it, run snoopy job add again." }), _jsx(Text, { color: uiTheme.ink.textMuted, children: "Exiting..." })] }) }));
112
+ }
106
113
  if (stage === 'apiKey') {
107
- return (_jsx(FlowFrame, { transcript: transcript, statusText: "First-time setup required", statusTone: "warning", children: _jsxs(Panel, { title: "Step 2: Authentication", children: [_jsx(Text, { color: uiTheme.ink.warning, children: "First-time setup: OpenRouter API key required." }), _jsx(TextPrompt, { label: "Paste OpenRouter API key", secret: true, onSubmit: (value) => {
114
+ return (_jsx(FlowFrame, { transcript: transcript, statusText: "First-time setup required", statusTone: "warning", children: _jsxs(Panel, { title: "Step 2: Authentication", children: [_jsx(Text, { color: uiTheme.ink.warning, children: "First-time setup: OpenRouter API key required." }), _jsx(TextPrompt, { label: "Paste OpenRouter API key", secret: true, onSubmit: async (value) => {
108
115
  setApiKey(value);
109
116
  appendTranscript('OpenRouter API key', '********');
110
- void onApiKeyCaptured?.(value);
117
+ try {
118
+ await onApiKeyCaptured?.(value);
119
+ }
120
+ catch (error) {
121
+ const message = error instanceof Error ? error.message : 'Could not save OpenRouter API key.';
122
+ setErrorMessage(message);
123
+ appendTranscript('OpenRouter', message, true);
124
+ setStage('error');
125
+ return;
126
+ }
111
127
  setStage('model');
112
128
  } })] }) }));
113
129
  }
@@ -7,9 +7,10 @@ interface SettingsResult {
7
7
  }
8
8
  interface SettingsFlowProps {
9
9
  current: AppSettings;
10
+ keytarAvailable: boolean;
10
11
  currentRedditCredentials: RedditCredentialState;
11
12
  currentApiKey: string | null;
12
13
  onDone: (result: SettingsResult) => void;
13
14
  }
14
- export declare function SettingsFlow({ current, currentRedditCredentials, currentApiKey, onDone }: SettingsFlowProps): React.JSX.Element;
15
+ export declare function SettingsFlow({ current, keytarAvailable, currentRedditCredentials, currentApiKey, onDone }: SettingsFlowProps): React.JSX.Element;
15
16
  export {};
@@ -57,7 +57,7 @@ function keyToDraftField(key) {
57
57
  return 'redditClientId';
58
58
  }
59
59
  }
60
- export function SettingsFlow({ current, currentRedditCredentials, currentApiKey, onDone }) {
60
+ export function SettingsFlow({ current, keytarAvailable, currentRedditCredentials, currentApiKey, onDone }) {
61
61
  const { exit } = useApp();
62
62
  const [mode, setMode] = useState('menu');
63
63
  const [cursor, setCursor] = useState(0);
@@ -69,10 +69,11 @@ export function SettingsFlow({ current, currentRedditCredentials, currentApiKey,
69
69
  const menuItems = useMemo(() => buildSettingsMenuItems({
70
70
  draft,
71
71
  draftSecrets,
72
+ keytarAvailable,
72
73
  currentApiKey,
73
74
  hasCurrentRedditClientSecret: currentRedditCredentials.hasClientSecret,
74
75
  clearedFields
75
- }), [currentApiKey, currentRedditCredentials.hasClientSecret, draft, draftSecrets, clearedFields]);
76
+ }), [keytarAvailable, currentApiKey, currentRedditCredentials.hasClientSecret, draft, draftSecrets, clearedFields]);
76
77
  useInput((_, key) => {
77
78
  if (mode === 'edit' && editingKey === 'notificationsEnabled') {
78
79
  if (key.return) {
@@ -127,7 +128,10 @@ export function SettingsFlow({ current, currentRedditCredentials, currentApiKey,
127
128
  setMode('edit');
128
129
  });
129
130
  if (mode === 'menu') {
130
- return (_jsx(SettingsFrame, { statusText: menuError ?? 'Ready', statusTone: menuError ? 'danger' : 'info', children: _jsxs(Panel, { title: "Settings Menu", children: [_jsx(Text, { color: uiTheme.ink.accent, children: "Choose a setting to edit. Press Enter to select." }), _jsx(Text, { color: uiTheme.ink.textMuted, children: "Use Up/Down arrows to navigate. Esc or Cancel exits without saving." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: menuItems.map((item, index) => {
131
+ const storageStatus = keytarAvailable
132
+ ? 'Keychain storage available for secret fields.'
133
+ : 'Keychain storage unavailable. Use SNOOPY_OPENROUTER_API_KEY and SNOOPY_REDDIT_CLIENT_SECRET.';
134
+ return (_jsx(SettingsFrame, { statusText: menuError ?? storageStatus, statusTone: menuError ? 'danger' : 'info', children: _jsxs(Panel, { title: "Settings Menu", children: [_jsx(Text, { color: uiTheme.ink.accent, children: "Choose a setting to edit. Press Enter to select." }), _jsx(Text, { color: uiTheme.ink.textMuted, children: "Use Up/Down arrows to navigate. Esc or Cancel exits without saving." }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: menuItems.map((item, index) => {
131
135
  const isCursor = index === cursor;
132
136
  const value = item.summary ? `: ${item.summary}` : '';
133
137
  return (_jsxs(Text, { color: isCursor ? uiTheme.ink.focus : uiTheme.ink.textPrimary, inverse: isCursor, children: [isCursor ? '>' : ' ', " ", item.label, value] }, item.key));
@@ -154,7 +158,8 @@ export function SettingsFlow({ current, currentRedditCredentials, currentApiKey,
154
158
  : draft[keyToDraftField(editingKey)];
155
159
  const fieldHasCurrentValue = Boolean(initialValue);
156
160
  const helpText = fieldHasCurrentValue ? 'Press Enter with empty value to clear' : 'Press Enter to continue';
157
- return (_jsx(SettingsFrame, { statusText: "Editing setting", statusTone: "info", children: _jsxs(Panel, { title: "Edit Setting", children: [_jsx(TextPrompt, { label: labelForSettingKey(editingKey), initialValue: initialValue, secret: isSecret, onSubmit: (value) => {
161
+ const showSecretEnvHint = isSecret && !keytarAvailable;
162
+ return (_jsx(SettingsFrame, { statusText: "Editing setting", statusTone: "info", children: _jsxs(Panel, { title: "Edit Setting", children: [showSecretEnvHint ? (_jsx(Text, { color: uiTheme.ink.warning, children: "Keychain storage is unavailable. Save is disabled for this secret; configure env vars instead." })) : null, _jsx(TextPrompt, { label: labelForSettingKey(editingKey), initialValue: initialValue, secret: isSecret, onSubmit: (value) => {
158
163
  if (value === '' && fieldHasCurrentValue && editingKey !== 'model') {
159
164
  setMode('confirmClear');
160
165
  return;
@@ -18,6 +18,7 @@ export interface SettingsDraftSecrets {
18
18
  interface BuildMenuItemsInput {
19
19
  draft: SettingsDraft;
20
20
  draftSecrets: SettingsDraftSecrets;
21
+ keytarAvailable: boolean;
21
22
  currentApiKey: string | null;
22
23
  hasCurrentRedditClientSecret: boolean;
23
24
  clearedFields?: Set<EditableSettingKey>;
@@ -42,6 +43,6 @@ type SaveResult = {
42
43
  };
43
44
  export declare function maskSecret(value: string, visibleChars?: number): string;
44
45
  export declare function createSettingsDraft(current: AppSettings, currentRedditCredentials: RedditCredentialState): SettingsDraft;
45
- export declare function buildSettingsMenuItems({ draft, draftSecrets, currentApiKey, hasCurrentRedditClientSecret, clearedFields }: BuildMenuItemsInput): SettingsMenuItem[];
46
+ export declare function buildSettingsMenuItems({ draft, draftSecrets, keytarAvailable, currentApiKey, hasCurrentRedditClientSecret, clearedFields }: BuildMenuItemsInput): SettingsMenuItem[];
46
47
  export declare function buildSettingsSaveResult(draft: SettingsDraft, draftSecrets: SettingsDraftSecrets, currentRedditCredentials: RedditCredentialState, clearedFields?: Set<EditableSettingKey>): SaveResult;
47
48
  export {};
@@ -36,11 +36,12 @@ export function createSettingsDraft(current, currentRedditCredentials) {
36
36
  redditClientId: currentRedditCredentials.clientId
37
37
  };
38
38
  }
39
- export function buildSettingsMenuItems({ draft, draftSecrets, currentApiKey, hasCurrentRedditClientSecret, clearedFields = new Set() }) {
39
+ export function buildSettingsMenuItems({ draft, draftSecrets, keytarAvailable, currentApiKey, hasCurrentRedditClientSecret, clearedFields = new Set() }) {
40
+ const sourceLabel = keytarAvailable ? 'keychain' : 'env';
40
41
  const apiKeySummary = draftSecrets.apiKey
41
42
  ? `Will update (${maskSecret(draftSecrets.apiKey)})`
42
43
  : currentApiKey
43
- ? `Configured (${maskSecret(currentApiKey)})`
44
+ ? `Configured via ${sourceLabel} (${maskSecret(currentApiKey)})`
44
45
  : 'Missing';
45
46
  const redditClientIdSummary = clearedFields.has('redditClientId')
46
47
  ? 'Cleared'
@@ -51,7 +52,7 @@ export function buildSettingsMenuItems({ draft, draftSecrets, currentApiKey, has
51
52
  const redditClientSecretSummary = draftSecrets.redditClientSecret
52
53
  ? 'Will update (hidden)'
53
54
  : hasCurrentRedditClientSecret
54
- ? 'Configured (hidden)'
55
+ ? `Configured via ${sourceLabel} (hidden)`
55
56
  : 'Missing';
56
57
  return [
57
58
  { key: 'apiKey', label: 'OpenRouter API key', summary: apiKeySummary, editable: true },
@@ -48,6 +48,12 @@ export class JobsRepository {
48
48
  // Ignore filesystem cleanup failures and continue DB cleanup.
49
49
  }
50
50
  }
51
+ this.db
52
+ .prepare(`DELETE FROM comment_thread_nodes
53
+ WHERE scan_item_id IN (
54
+ SELECT id FROM scan_items WHERE job_id = ?
55
+ )`)
56
+ .run(jobId);
51
57
  this.db.prepare('DELETE FROM scan_items WHERE job_id = ?').run(jobId);
52
58
  this.db.prepare('DELETE FROM job_runs WHERE job_id = ?').run(jobId);
53
59
  this.db.prepare('DELETE FROM jobs WHERE id = ?').run(jobId);
@@ -7,7 +7,6 @@ export declare class SettingsRepository {
7
7
  getAppSettings(): AppSettings;
8
8
  setAppSettings(settings: AppSettings): void;
9
9
  private getOrCreateRedditAppName;
10
- private migrateLegacyRedditClientSecret;
11
10
  getRedditCredentialState(): Promise<RedditCredentialState>;
12
11
  getRedditCredentials(): Promise<RedditCredentials | null>;
13
12
  setRedditCredentials(update: RedditCredentialsUpdate | RedditCredentials): Promise<void>;
@@ -84,19 +84,7 @@ export class SettingsRepository {
84
84
  this.set('reddit_app_name', generated);
85
85
  return generated;
86
86
  }
87
- async migrateLegacyRedditClientSecret() {
88
- const legacySecret = this.get('reddit_client_secret')?.trim() ?? '';
89
- if (!legacySecret) {
90
- return;
91
- }
92
- const existingSecret = await getRedditClientSecret();
93
- if (!existingSecret) {
94
- await setRedditClientSecret(legacySecret);
95
- }
96
- this.delete('reddit_client_secret');
97
- }
98
87
  async getRedditCredentialState() {
99
- await this.migrateLegacyRedditClientSecret();
100
88
  const appName = this.getOrCreateRedditAppName();
101
89
  const clientId = this.get('reddit_client_id')?.trim() ?? '';
102
90
  const clientSecret = await getRedditClientSecret();
@@ -107,7 +95,6 @@ export class SettingsRepository {
107
95
  };
108
96
  }
109
97
  async getRedditCredentials() {
110
- await this.migrateLegacyRedditClientSecret();
111
98
  const appName = this.getOrCreateRedditAppName();
112
99
  const clientId = this.get('reddit_client_id')?.trim() ?? '';
113
100
  const clientSecret = (await getRedditClientSecret())?.trim() ?? '';
@@ -1,3 +1,7 @@
1
+ export declare class KeytarUnavailableError extends Error {
2
+ constructor(message?: string);
3
+ }
4
+ export declare function isKeytarAvailable(): Promise<boolean>;
1
5
  export declare function setOpenRouterApiKey(apiKey: string): Promise<void>;
2
6
  export declare function deleteOpenRouterApiKey(): Promise<void>;
3
7
  export declare function getOpenRouterApiKey(): Promise<string | null>;
@@ -1,12 +1,15 @@
1
- import crypto from 'node:crypto';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import { ensureAppDirs } from '../../utils/paths.js';
5
1
  const SERVICE_NAME = 'snoopy';
6
2
  const OPENROUTER_ACCOUNT_NAME = 'openrouter_api_key';
7
3
  const REDDIT_CLIENT_SECRET_ACCOUNT_NAME = 'reddit_client_secret';
8
- const FILE_NAME = 'secrets.enc';
4
+ const OPENROUTER_ENV_NAME = 'SNOOPY_OPENROUTER_API_KEY';
5
+ const REDDIT_CLIENT_SECRET_ENV_NAME = 'SNOOPY_REDDIT_CLIENT_SECRET';
9
6
  let keytarClientPromise;
7
+ export class KeytarUnavailableError extends Error {
8
+ constructor(message = 'Keychain storage is unavailable on this system.') {
9
+ super(message);
10
+ this.name = 'KeytarUnavailableError';
11
+ }
12
+ }
10
13
  async function getKeytarClient() {
11
14
  if (keytarClientPromise) {
12
15
  return keytarClientPromise;
@@ -30,110 +33,27 @@ async function getKeytarClient() {
30
33
  })();
31
34
  return keytarClientPromise;
32
35
  }
33
- function getFallbackPath() {
34
- const paths = ensureAppDirs();
35
- return path.join(paths.rootDir, FILE_NAME);
36
- }
37
- function getMachineKey() {
38
- return crypto
39
- .createHash('sha256')
40
- .update(`${process.platform}:${process.arch}:${process.env.USER ?? process.env.USERNAME ?? 'user'}`)
41
- .digest();
42
- }
43
- function encrypt(value) {
44
- const iv = crypto.randomBytes(16);
45
- const key = getMachineKey();
46
- const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
47
- const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
48
- return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
49
- }
50
- function decrypt(value) {
51
- const [ivHex, contentHex] = value.split(':');
52
- if (!ivHex || !contentHex) {
53
- throw new Error('Invalid encrypted payload');
54
- }
55
- const iv = Buffer.from(ivHex, 'hex');
56
- const content = Buffer.from(contentHex, 'hex');
57
- const decipher = crypto.createDecipheriv('aes-256-cbc', getMachineKey(), iv);
58
- const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
59
- return decrypted.toString('utf8');
36
+ function readEnvSecret(name) {
37
+ const value = process.env[name]?.trim();
38
+ return value ? value : null;
60
39
  }
61
- function readFallbackSecrets() {
62
- const filePath = getFallbackPath();
63
- if (!fs.existsSync(filePath)) {
64
- return {};
65
- }
66
- try {
67
- const decrypted = decrypt(fs.readFileSync(filePath, 'utf8'));
68
- const parsed = JSON.parse(decrypted);
69
- if (parsed && typeof parsed === 'object') {
70
- return parsed;
71
- }
72
- }
73
- catch {
74
- // Legacy fallback stored only OpenRouter API key as a plaintext payload before encryption.
75
- }
76
- try {
77
- const legacyValue = decrypt(fs.readFileSync(filePath, 'utf8'));
78
- return legacyValue ? { openrouter_api_key: legacyValue } : {};
79
- }
80
- catch {
81
- return {};
82
- }
83
- }
84
- function writeFallbackSecrets(secrets) {
85
- const sanitized = {
86
- openrouter_api_key: secrets.openrouter_api_key?.trim() ? secrets.openrouter_api_key : undefined,
87
- reddit_client_secret: secrets.reddit_client_secret?.trim() ? secrets.reddit_client_secret : undefined
88
- };
89
- if (!sanitized.openrouter_api_key && !sanitized.reddit_client_secret) {
90
- const filePath = getFallbackPath();
91
- if (fs.existsSync(filePath)) {
92
- fs.unlinkSync(filePath);
93
- }
94
- return;
95
- }
96
- const payload = encrypt(JSON.stringify(sanitized));
97
- fs.writeFileSync(getFallbackPath(), payload, { mode: 0o600 });
98
- }
99
- function setFallbackSecret(key, value) {
100
- const current = readFallbackSecrets();
101
- current[key] = value;
102
- writeFallbackSecrets(current);
103
- }
104
- function getFallbackSecret(key) {
105
- const current = readFallbackSecrets();
106
- return current[key] ?? null;
107
- }
108
- function deleteFallbackSecret(key) {
109
- const current = readFallbackSecrets();
110
- delete current[key];
111
- writeFallbackSecrets(current);
40
+ export async function isKeytarAvailable() {
41
+ const keytarClient = await getKeytarClient();
42
+ return Boolean(keytarClient);
112
43
  }
113
44
  export async function setOpenRouterApiKey(apiKey) {
114
45
  const keytarClient = await getKeytarClient();
115
- if (keytarClient) {
116
- try {
117
- await keytarClient.setPassword(SERVICE_NAME, OPENROUTER_ACCOUNT_NAME, apiKey);
118
- return;
119
- }
120
- catch {
121
- // Fall through to encrypted file fallback.
122
- }
46
+ if (!keytarClient) {
47
+ throw new KeytarUnavailableError(`Unable to save OpenRouter API key because keychain storage is unavailable. Set ${OPENROUTER_ENV_NAME} instead.`);
123
48
  }
124
- setFallbackSecret('openrouter_api_key', apiKey);
49
+ await keytarClient.setPassword(SERVICE_NAME, OPENROUTER_ACCOUNT_NAME, apiKey);
125
50
  }
126
51
  export async function deleteOpenRouterApiKey() {
127
52
  const keytarClient = await getKeytarClient();
128
- if (keytarClient) {
129
- try {
130
- await keytarClient.deletePassword(SERVICE_NAME, OPENROUTER_ACCOUNT_NAME);
131
- }
132
- catch {
133
- // Ignore keychain deletion failures and continue with file cleanup.
134
- }
53
+ if (!keytarClient) {
54
+ return;
135
55
  }
136
- deleteFallbackSecret('openrouter_api_key');
56
+ await keytarClient.deletePassword(SERVICE_NAME, OPENROUTER_ACCOUNT_NAME);
137
57
  }
138
58
  export async function getOpenRouterApiKey() {
139
59
  const keytarClient = await getKeytarClient();
@@ -145,23 +65,17 @@ export async function getOpenRouterApiKey() {
145
65
  }
146
66
  }
147
67
  catch {
148
- // Fallback to encrypted file when keytar is unavailable.
68
+ // Fall through to env fallback if keytar read fails.
149
69
  }
150
70
  }
151
- return getFallbackSecret('openrouter_api_key');
71
+ return readEnvSecret(OPENROUTER_ENV_NAME);
152
72
  }
153
73
  export async function setRedditClientSecret(secret) {
154
74
  const keytarClient = await getKeytarClient();
155
- if (keytarClient) {
156
- try {
157
- await keytarClient.setPassword(SERVICE_NAME, REDDIT_CLIENT_SECRET_ACCOUNT_NAME, secret);
158
- return;
159
- }
160
- catch {
161
- // Fall through to encrypted file fallback.
162
- }
75
+ if (!keytarClient) {
76
+ throw new KeytarUnavailableError(`Unable to save Reddit client secret because keychain storage is unavailable. Set ${REDDIT_CLIENT_SECRET_ENV_NAME} instead.`);
163
77
  }
164
- setFallbackSecret('reddit_client_secret', secret);
78
+ await keytarClient.setPassword(SERVICE_NAME, REDDIT_CLIENT_SECRET_ACCOUNT_NAME, secret);
165
79
  }
166
80
  export async function getRedditClientSecret() {
167
81
  const keytarClient = await getKeytarClient();
@@ -173,21 +87,16 @@ export async function getRedditClientSecret() {
173
87
  }
174
88
  }
175
89
  catch {
176
- // Fallback to encrypted file when keytar is unavailable.
90
+ // Fall through to env fallback if keytar read fails.
177
91
  }
178
92
  }
179
- return getFallbackSecret('reddit_client_secret');
93
+ return readEnvSecret(REDDIT_CLIENT_SECRET_ENV_NAME);
180
94
  }
181
95
  export async function deleteRedditClientSecret() {
182
96
  const keytarClient = await getKeytarClient();
183
- if (keytarClient) {
184
- try {
185
- await keytarClient.deletePassword(SERVICE_NAME, REDDIT_CLIENT_SECRET_ACCOUNT_NAME);
186
- }
187
- catch {
188
- // Ignore keychain deletion failures and continue with file cleanup.
189
- }
97
+ if (!keytarClient) {
98
+ return;
190
99
  }
191
- deleteFallbackSecret('reddit_client_secret');
100
+ await keytarClient.deletePassword(SERVICE_NAME, REDDIT_CLIENT_SECRET_ACCOUNT_NAME);
192
101
  }
193
102
  //# sourceMappingURL=secretStore.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telepat/snoopy",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Snoopy CLI for Reddit conversation monitoring jobs.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",