@telepat/snoopy 0.1.8 → 0.1.9
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 +1 -0
- package/dist/src/cli/commands/doctor.js +8 -2
- package/dist/src/cli/commands/job.js +8 -5
- package/dist/src/cli/commands/settings.js +17 -5
- package/dist/src/cli/flows/jobAddFlow.d.ts +2 -1
- package/dist/src/cli/flows/jobAddFlow.js +21 -5
- package/dist/src/cli/flows/settingsFlow.d.ts +2 -1
- package/dist/src/cli/flows/settingsFlow.js +9 -4
- package/dist/src/cli/flows/settingsFlowModel.d.ts +2 -1
- package/dist/src/cli/flows/settingsFlowModel.js +4 -3
- package/dist/src/services/db/repositories/settingsRepo.d.ts +0 -1
- package/dist/src/services/db/repositories/settingsRepo.js +0 -13
- package/dist/src/services/security/secretStore.d.ts +4 -0
- package/dist/src/services/security/secretStore.js +30 -121
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
if (keytarAvailable) {
|
|
31
|
+
await setOpenRouterApiKey(finalResult.apiKey);
|
|
32
|
+
}
|
|
24
33
|
}
|
|
25
34
|
if (finalResult.redditCredentials) {
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
55
|
+
? `Configured via ${sourceLabel} (hidden)`
|
|
55
56
|
: 'Missing';
|
|
56
57
|
return [
|
|
57
58
|
{ key: 'apiKey', label: 'OpenRouter API key', summary: apiKeySummary, editable: true },
|
|
@@ -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
|
|
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
|
|
34
|
-
const
|
|
35
|
-
return
|
|
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
|
|
62
|
-
const
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
68
|
+
// Fall through to env fallback if keytar read fails.
|
|
149
69
|
}
|
|
150
70
|
}
|
|
151
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
90
|
+
// Fall through to env fallback if keytar read fails.
|
|
177
91
|
}
|
|
178
92
|
}
|
|
179
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
100
|
+
await keytarClient.deletePassword(SERVICE_NAME, REDDIT_CLIENT_SECRET_ACCOUNT_NAME);
|
|
192
101
|
}
|
|
193
102
|
//# sourceMappingURL=secretStore.js.map
|