@ainote/mcp 1.3.4 → 1.3.5

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.
@@ -1,326 +1,326 @@
1
- /**
2
- * Credential persistence for the CLI.
3
- *
4
- * Primary: OS keychain via `cross-keychain` (macOS Keychain, libsecret,
5
- * Windows Credential Manager). Loaded lazily via createRequire so
6
- * import never fails on platforms without it.
7
- * Fallback: ${XDG_CONFIG_HOME:-~/.config}/ainote/credentials.json with
8
- * directory mode 0o700 and file mode 0o600.
9
- *
10
- * Schema (file):
11
- * {
12
- * "<apiUrlHost>": {
13
- * "mcp_key": "...",
14
- * "access_token": "at_...",
15
- * "refresh_token": "rt_...",
16
- * "expires_at": 1700000000000 // ms since epoch
17
- * },
18
- * ...
19
- * }
20
- *
21
- * Each environment (host) is namespaced so multiple ainote deployments
22
- * (prod / staging / localhost) can coexist on one machine.
23
- */
24
- import fs from 'node:fs';
25
- import path from 'node:path';
26
- import os from 'node:os';
27
- import url from 'node:url';
28
- import { createRequire } from 'node:module';
29
-
30
- const require = createRequire(import.meta.url);
31
-
32
- const SERVICE = '@ainote/cli';
33
- const FIELDS = ['mcp_key', 'access_token', 'refresh_token', 'expires_at'];
34
-
35
- /**
36
- * cross-keychain validates service/account identifiers against
37
- * /^[a-zA-Z0-9._@-]+$/ in its shared ConfigurableBackend base class, so a '/'
38
- * is rejected on EVERY backend — macOS Keychain, libsecret, and Windows
39
- * Credential Manager alike (verified against cross-keychain 1.1.0). The literal
40
- * '@ainote/cli' therefore made every keychain read/write throw on all
41
- * platforms, silently forcing the file fallback and — because the device-flow
42
- * poll error masked it — blocking `ainote-mcp login` from persisting at all.
43
- *
44
- * We sanitize the '/' to '.' unconditionally. Since the unsanitized name never
45
- * stored anything in any keychain, there are no existing entries to orphan.
46
- */
47
- function keychainService() {
48
- return SERVICE.replace(/\//g, '.');
49
- }
50
-
51
- let keychainModule = null;
52
- let keychainProbed = false;
53
-
54
- function getKeychain() {
55
- if (keychainProbed) return keychainModule;
56
- keychainProbed = true;
57
- try {
58
- keychainModule = require('cross-keychain');
59
- } catch (err) {
60
- process.stderr.write(`[ainote-mcp] cross-keychain unavailable, using file fallback: ${err.message}\n`);
61
- keychainModule = null;
62
- }
63
- return keychainModule;
64
- }
65
-
66
- function getCredentialsDir() {
67
- const xdg = process.env.XDG_CONFIG_HOME;
68
- const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.config');
69
- return path.join(base, 'ainote');
70
- }
71
-
72
- function getCredentialsFile() {
73
- return path.join(getCredentialsDir(), 'credentials.json');
74
- }
75
-
76
- function apiHost(apiUrl) {
77
- try {
78
- const parsed = new url.URL(apiUrl);
79
- return parsed.host || apiUrl;
80
- } catch {
81
- return apiUrl;
82
- }
83
- }
84
-
85
- /**
86
- * Keychain account name for a (host, field) pair.
87
- *
88
- * Same cross-keychain constraint as keychainService(): account names must match
89
- * /^[a-zA-Z0-9._@-]+$/ on every backend. The previous `${host}:${field}` form
90
- * was rejected outright (the ':' separator, plus any ':port' in the host), so
91
- * every keychain read/write threw and credentials always fell back to the file.
92
- *
93
- * Format: `${sanitizedHost}@${field}`. The host is reduced to [a-zA-Z0-9.-]
94
- * (any other char, e.g. a port ':' or IPv6 brackets, becomes '_'), so it can
95
- * never contain '@'; the '@' separator therefore keeps host and field
96
- * unambiguously distinct. Deterministic for a given apiUrl.
97
- *
98
- * No migration: since the old ':'-based names were always rejected, nothing
99
- * was ever stored under them in any keychain.
100
- */
101
- function accountFor(apiUrl, field) {
102
- const host = String(apiHost(apiUrl)).replace(/[^a-zA-Z0-9.-]/g, '_');
103
- return `${host}@${field}`;
104
- }
105
-
106
- function readFileStore() {
107
- const file = getCredentialsFile();
108
- try {
109
- const raw = fs.readFileSync(file, 'utf8');
110
- const parsed = JSON.parse(raw);
111
- return parsed && typeof parsed === 'object' ? parsed : {};
112
- } catch (err) {
113
- if (err.code !== 'ENOENT') {
114
- process.stderr.write(`[ainote-mcp] credentials.json read failed: ${err.message}\n`);
115
- }
116
- return {};
117
- }
118
- }
119
-
120
- function writeFileStore(store) {
121
- const dir = getCredentialsDir();
122
- try {
123
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
124
- } catch (err) {
125
- if (err.code !== 'EEXIST') throw err;
126
- }
127
- // Try to tighten directory perms even if mkdir didn't get to chmod (older Node).
128
- try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
129
-
130
- const file = getCredentialsFile();
131
- const tmp = `${file}.tmp`;
132
- fs.writeFileSync(tmp, JSON.stringify(store, null, 2), { mode: 0o600 });
133
- try { fs.chmodSync(tmp, 0o600); } catch { /* best effort */ }
134
- fs.renameSync(tmp, file);
135
- try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
136
- }
137
-
138
- async function keychainGet(apiUrl, field) {
139
- const kc = getKeychain();
140
- if (!kc || typeof kc.getPassword !== 'function') return null;
141
- try {
142
- const value = await kc.getPassword(keychainService(), accountFor(apiUrl, field));
143
- return value || null;
144
- } catch (err) {
145
- process.stderr.write(`[ainote-mcp] keychain read failed (${field}): ${err.message}\n`);
146
- return null;
147
- }
148
- }
149
-
150
- async function keychainSet(apiUrl, field, value) {
151
- const kc = getKeychain();
152
- if (!kc || typeof kc.setPassword !== 'function') return false;
153
- try {
154
- if (value === null || value === undefined || value === '') {
155
- if (typeof kc.deletePassword === 'function') {
156
- await kc.deletePassword(keychainService(), accountFor(apiUrl, field));
157
- }
158
- return true;
159
- }
160
- await kc.setPassword(keychainService(), accountFor(apiUrl, field), String(value));
161
- return true;
162
- } catch (err) {
163
- process.stderr.write(`[ainote-mcp] keychain write failed (${field}): ${err.message}\n`);
164
- return false;
165
- }
166
- }
167
-
168
- async function keychainDelete(apiUrl, field) {
169
- const kc = getKeychain();
170
- if (!kc || typeof kc.deletePassword !== 'function') return false;
171
- try {
172
- await kc.deletePassword(keychainService(), accountFor(apiUrl, field));
173
- return true;
174
- } catch (err) {
175
- if (err && /not found/i.test(err.message)) return true;
176
- return false;
177
- }
178
- }
179
-
180
- /**
181
- * Read credentials for the given API URL. Returns `null` if nothing is stored.
182
- *
183
- * Tries keychain first for each field; falls back to file. If both yield
184
- * nothing, returns null. Mixed sources (keychain mcp_key + file refresh) are
185
- * permitted and merged.
186
- *
187
- * @returns {Promise<null | {mcpKey?: string, accessToken?: string,
188
- * refreshToken?: string, expiresAt?: number, source: 'keychain'|'file'|'mixed'}>}
189
- */
190
- export async function getCredentials(apiUrl) {
191
- const host = apiHost(apiUrl);
192
-
193
- const fileStore = readFileStore();
194
- const fileEntry = fileStore[host] || {};
195
-
196
- const kc = getKeychain();
197
- const result = {};
198
- let usedKeychain = false;
199
- let usedFile = false;
200
-
201
- for (const field of FIELDS) {
202
- let value = null;
203
- if (kc) {
204
- value = await keychainGet(apiUrl, field);
205
- if (value !== null && value !== undefined) usedKeychain = true;
206
- }
207
- if (value === null || value === undefined) {
208
- if (fileEntry[field] !== undefined && fileEntry[field] !== null) {
209
- value = fileEntry[field];
210
- usedFile = true;
211
- }
212
- }
213
- if (value !== null && value !== undefined && value !== '') {
214
- result[field] = value;
215
- }
216
- }
217
-
218
- if (Object.keys(result).length === 0) return null;
219
-
220
- const out = {
221
- mcpKey: result.mcp_key,
222
- accessToken: result.access_token,
223
- refreshToken: result.refresh_token,
224
- expiresAt: result.expires_at ? Number(result.expires_at) : undefined,
225
- source: usedKeychain && usedFile ? 'mixed' : usedKeychain ? 'keychain' : 'file'
226
- };
227
- return out;
228
- }
229
-
230
- /**
231
- * Persist credentials. Writes to keychain when available; ALWAYS also writes
232
- * a minimal file shadow when keychain write fails for any field, so the user
233
- * can still authenticate after a partial-failure scenario.
234
- *
235
- * @param {string} apiUrl
236
- * @param {{mcpKey?: string, accessToken?: string, refreshToken?: string, expiresAt?: number|string}} creds
237
- * @returns {Promise<{storage: 'keychain'|'file'|'mixed', file?: string}>}
238
- */
239
- export async function setCredentials(apiUrl, creds) {
240
- const host = apiHost(apiUrl);
241
- const mapping = {
242
- mcp_key: creds.mcpKey,
243
- access_token: creds.accessToken,
244
- refresh_token: creds.refreshToken,
245
- expires_at: creds.expiresAt === undefined ? undefined : String(creds.expiresAt)
246
- };
247
-
248
- let keychainOk = true;
249
- let anyKeychain = false;
250
-
251
- for (const [field, value] of Object.entries(mapping)) {
252
- if (value === undefined) continue;
253
- const ok = await keychainSet(apiUrl, field, value);
254
- if (ok) anyKeychain = true;
255
- else keychainOk = false;
256
- }
257
-
258
- let fileWritten = false;
259
- if (!anyKeychain || !keychainOk) {
260
- // Write or merge file store so credentials survive even if some keychain
261
- // writes failed.
262
- const store = readFileStore();
263
- const entry = { ...(store[host] || {}) };
264
- for (const [field, value] of Object.entries(mapping)) {
265
- if (value === undefined) continue;
266
- if (value === null || value === '') delete entry[field];
267
- else entry[field] = field === 'expires_at' ? Number(value) : value;
268
- }
269
- store[host] = entry;
270
- writeFileStore(store);
271
- fileWritten = true;
272
- }
273
-
274
- let storage;
275
- if (anyKeychain && !fileWritten) storage = 'keychain';
276
- else if (!anyKeychain && fileWritten) storage = 'file';
277
- else storage = 'mixed';
278
-
279
- return {
280
- storage,
281
- file: fileWritten ? getCredentialsFile() : undefined
282
- };
283
- }
284
-
285
- /**
286
- * Remove credentials for the given API URL from both keychain and file.
287
- * Best-effort: errors are swallowed and the caller can assume cleared state.
288
- */
289
- export async function clearCredentials(apiUrl) {
290
- const host = apiHost(apiUrl);
291
-
292
- for (const field of FIELDS) {
293
- await keychainDelete(apiUrl, field);
294
- }
295
-
296
- try {
297
- const store = readFileStore();
298
- if (store[host]) {
299
- delete store[host];
300
- if (Object.keys(store).length === 0) {
301
- try { fs.unlinkSync(getCredentialsFile()); } catch { /* ignore */ }
302
- } else {
303
- writeFileStore(store);
304
- }
305
- }
306
- } catch (err) {
307
- process.stderr.write(`[ainote-mcp] file credentials clear failed: ${err.message}\n`);
308
- }
309
- }
310
-
311
- /**
312
- * Internal helper for diagnostics. Not part of public API.
313
- */
314
- export function _credentialsLocations() {
315
- return {
316
- keychainService: keychainService(),
317
- file: getCredentialsFile()
318
- };
319
- }
320
-
321
- /**
322
- * Internal helper exposing keychain account names for tests. Not public API.
323
- */
324
- export function _keychainAccount(apiUrl, field) {
325
- return accountFor(apiUrl, field);
326
- }
1
+ /**
2
+ * Credential persistence for the CLI.
3
+ *
4
+ * Primary: OS keychain via `cross-keychain` (macOS Keychain, libsecret,
5
+ * Windows Credential Manager). Loaded lazily via createRequire so
6
+ * import never fails on platforms without it.
7
+ * Fallback: ${XDG_CONFIG_HOME:-~/.config}/ainote/credentials.json with
8
+ * directory mode 0o700 and file mode 0o600.
9
+ *
10
+ * Schema (file):
11
+ * {
12
+ * "<apiUrlHost>": {
13
+ * "mcp_key": "...",
14
+ * "access_token": "at_...",
15
+ * "refresh_token": "rt_...",
16
+ * "expires_at": 1700000000000 // ms since epoch
17
+ * },
18
+ * ...
19
+ * }
20
+ *
21
+ * Each environment (host) is namespaced so multiple ainote deployments
22
+ * (prod / staging / localhost) can coexist on one machine.
23
+ */
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import os from 'node:os';
27
+ import url from 'node:url';
28
+ import { createRequire } from 'node:module';
29
+
30
+ const require = createRequire(import.meta.url);
31
+
32
+ const SERVICE = '@ainote/cli';
33
+ const FIELDS = ['mcp_key', 'access_token', 'refresh_token', 'expires_at'];
34
+
35
+ /**
36
+ * cross-keychain validates service/account identifiers against
37
+ * /^[a-zA-Z0-9._@-]+$/ in its shared ConfigurableBackend base class, so a '/'
38
+ * is rejected on EVERY backend — macOS Keychain, libsecret, and Windows
39
+ * Credential Manager alike (verified against cross-keychain 1.1.0). The literal
40
+ * '@ainote/cli' therefore made every keychain read/write throw on all
41
+ * platforms, silently forcing the file fallback and — because the device-flow
42
+ * poll error masked it — blocking `ainote-mcp login` from persisting at all.
43
+ *
44
+ * We sanitize the '/' to '.' unconditionally. Since the unsanitized name never
45
+ * stored anything in any keychain, there are no existing entries to orphan.
46
+ */
47
+ function keychainService() {
48
+ return SERVICE.replace(/\//g, '.');
49
+ }
50
+
51
+ let keychainModule = null;
52
+ let keychainProbed = false;
53
+
54
+ function getKeychain() {
55
+ if (keychainProbed) return keychainModule;
56
+ keychainProbed = true;
57
+ try {
58
+ keychainModule = require('cross-keychain');
59
+ } catch (err) {
60
+ process.stderr.write(`[ainote-mcp] cross-keychain unavailable, using file fallback: ${err.message}\n`);
61
+ keychainModule = null;
62
+ }
63
+ return keychainModule;
64
+ }
65
+
66
+ function getCredentialsDir() {
67
+ const xdg = process.env.XDG_CONFIG_HOME;
68
+ const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.config');
69
+ return path.join(base, 'ainote');
70
+ }
71
+
72
+ function getCredentialsFile() {
73
+ return path.join(getCredentialsDir(), 'credentials.json');
74
+ }
75
+
76
+ function apiHost(apiUrl) {
77
+ try {
78
+ const parsed = new url.URL(apiUrl);
79
+ return parsed.host || apiUrl;
80
+ } catch {
81
+ return apiUrl;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Keychain account name for a (host, field) pair.
87
+ *
88
+ * Same cross-keychain constraint as keychainService(): account names must match
89
+ * /^[a-zA-Z0-9._@-]+$/ on every backend. The previous `${host}:${field}` form
90
+ * was rejected outright (the ':' separator, plus any ':port' in the host), so
91
+ * every keychain read/write threw and credentials always fell back to the file.
92
+ *
93
+ * Format: `${sanitizedHost}@${field}`. The host is reduced to [a-zA-Z0-9.-]
94
+ * (any other char, e.g. a port ':' or IPv6 brackets, becomes '_'), so it can
95
+ * never contain '@'; the '@' separator therefore keeps host and field
96
+ * unambiguously distinct. Deterministic for a given apiUrl.
97
+ *
98
+ * No migration: since the old ':'-based names were always rejected, nothing
99
+ * was ever stored under them in any keychain.
100
+ */
101
+ function accountFor(apiUrl, field) {
102
+ const host = String(apiHost(apiUrl)).replace(/[^a-zA-Z0-9.-]/g, '_');
103
+ return `${host}@${field}`;
104
+ }
105
+
106
+ function readFileStore() {
107
+ const file = getCredentialsFile();
108
+ try {
109
+ const raw = fs.readFileSync(file, 'utf8');
110
+ const parsed = JSON.parse(raw);
111
+ return parsed && typeof parsed === 'object' ? parsed : {};
112
+ } catch (err) {
113
+ if (err.code !== 'ENOENT') {
114
+ process.stderr.write(`[ainote-mcp] credentials.json read failed: ${err.message}\n`);
115
+ }
116
+ return {};
117
+ }
118
+ }
119
+
120
+ function writeFileStore(store) {
121
+ const dir = getCredentialsDir();
122
+ try {
123
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
124
+ } catch (err) {
125
+ if (err.code !== 'EEXIST') throw err;
126
+ }
127
+ // Try to tighten directory perms even if mkdir didn't get to chmod (older Node).
128
+ try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
129
+
130
+ const file = getCredentialsFile();
131
+ const tmp = `${file}.tmp`;
132
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2), { mode: 0o600 });
133
+ try { fs.chmodSync(tmp, 0o600); } catch { /* best effort */ }
134
+ fs.renameSync(tmp, file);
135
+ try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
136
+ }
137
+
138
+ async function keychainGet(apiUrl, field) {
139
+ const kc = getKeychain();
140
+ if (!kc || typeof kc.getPassword !== 'function') return null;
141
+ try {
142
+ const value = await kc.getPassword(keychainService(), accountFor(apiUrl, field));
143
+ return value || null;
144
+ } catch (err) {
145
+ process.stderr.write(`[ainote-mcp] keychain read failed (${field}): ${err.message}\n`);
146
+ return null;
147
+ }
148
+ }
149
+
150
+ async function keychainSet(apiUrl, field, value) {
151
+ const kc = getKeychain();
152
+ if (!kc || typeof kc.setPassword !== 'function') return false;
153
+ try {
154
+ if (value === null || value === undefined || value === '') {
155
+ if (typeof kc.deletePassword === 'function') {
156
+ await kc.deletePassword(keychainService(), accountFor(apiUrl, field));
157
+ }
158
+ return true;
159
+ }
160
+ await kc.setPassword(keychainService(), accountFor(apiUrl, field), String(value));
161
+ return true;
162
+ } catch (err) {
163
+ process.stderr.write(`[ainote-mcp] keychain write failed (${field}): ${err.message}\n`);
164
+ return false;
165
+ }
166
+ }
167
+
168
+ async function keychainDelete(apiUrl, field) {
169
+ const kc = getKeychain();
170
+ if (!kc || typeof kc.deletePassword !== 'function') return false;
171
+ try {
172
+ await kc.deletePassword(keychainService(), accountFor(apiUrl, field));
173
+ return true;
174
+ } catch (err) {
175
+ if (err && /not found/i.test(err.message)) return true;
176
+ return false;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Read credentials for the given API URL. Returns `null` if nothing is stored.
182
+ *
183
+ * Tries keychain first for each field; falls back to file. If both yield
184
+ * nothing, returns null. Mixed sources (keychain mcp_key + file refresh) are
185
+ * permitted and merged.
186
+ *
187
+ * @returns {Promise<null | {mcpKey?: string, accessToken?: string,
188
+ * refreshToken?: string, expiresAt?: number, source: 'keychain'|'file'|'mixed'}>}
189
+ */
190
+ export async function getCredentials(apiUrl) {
191
+ const host = apiHost(apiUrl);
192
+
193
+ const fileStore = readFileStore();
194
+ const fileEntry = fileStore[host] || {};
195
+
196
+ const kc = getKeychain();
197
+ const result = {};
198
+ let usedKeychain = false;
199
+ let usedFile = false;
200
+
201
+ for (const field of FIELDS) {
202
+ let value = null;
203
+ if (kc) {
204
+ value = await keychainGet(apiUrl, field);
205
+ if (value !== null && value !== undefined) usedKeychain = true;
206
+ }
207
+ if (value === null || value === undefined) {
208
+ if (fileEntry[field] !== undefined && fileEntry[field] !== null) {
209
+ value = fileEntry[field];
210
+ usedFile = true;
211
+ }
212
+ }
213
+ if (value !== null && value !== undefined && value !== '') {
214
+ result[field] = value;
215
+ }
216
+ }
217
+
218
+ if (Object.keys(result).length === 0) return null;
219
+
220
+ const out = {
221
+ mcpKey: result.mcp_key,
222
+ accessToken: result.access_token,
223
+ refreshToken: result.refresh_token,
224
+ expiresAt: result.expires_at ? Number(result.expires_at) : undefined,
225
+ source: usedKeychain && usedFile ? 'mixed' : usedKeychain ? 'keychain' : 'file'
226
+ };
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * Persist credentials. Writes to keychain when available; ALWAYS also writes
232
+ * a minimal file shadow when keychain write fails for any field, so the user
233
+ * can still authenticate after a partial-failure scenario.
234
+ *
235
+ * @param {string} apiUrl
236
+ * @param {{mcpKey?: string, accessToken?: string, refreshToken?: string, expiresAt?: number|string}} creds
237
+ * @returns {Promise<{storage: 'keychain'|'file'|'mixed', file?: string}>}
238
+ */
239
+ export async function setCredentials(apiUrl, creds) {
240
+ const host = apiHost(apiUrl);
241
+ const mapping = {
242
+ mcp_key: creds.mcpKey,
243
+ access_token: creds.accessToken,
244
+ refresh_token: creds.refreshToken,
245
+ expires_at: creds.expiresAt === undefined ? undefined : String(creds.expiresAt)
246
+ };
247
+
248
+ let keychainOk = true;
249
+ let anyKeychain = false;
250
+
251
+ for (const [field, value] of Object.entries(mapping)) {
252
+ if (value === undefined) continue;
253
+ const ok = await keychainSet(apiUrl, field, value);
254
+ if (ok) anyKeychain = true;
255
+ else keychainOk = false;
256
+ }
257
+
258
+ let fileWritten = false;
259
+ if (!anyKeychain || !keychainOk) {
260
+ // Write or merge file store so credentials survive even if some keychain
261
+ // writes failed.
262
+ const store = readFileStore();
263
+ const entry = { ...(store[host] || {}) };
264
+ for (const [field, value] of Object.entries(mapping)) {
265
+ if (value === undefined) continue;
266
+ if (value === null || value === '') delete entry[field];
267
+ else entry[field] = field === 'expires_at' ? Number(value) : value;
268
+ }
269
+ store[host] = entry;
270
+ writeFileStore(store);
271
+ fileWritten = true;
272
+ }
273
+
274
+ let storage;
275
+ if (anyKeychain && !fileWritten) storage = 'keychain';
276
+ else if (!anyKeychain && fileWritten) storage = 'file';
277
+ else storage = 'mixed';
278
+
279
+ return {
280
+ storage,
281
+ file: fileWritten ? getCredentialsFile() : undefined
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Remove credentials for the given API URL from both keychain and file.
287
+ * Best-effort: errors are swallowed and the caller can assume cleared state.
288
+ */
289
+ export async function clearCredentials(apiUrl) {
290
+ const host = apiHost(apiUrl);
291
+
292
+ for (const field of FIELDS) {
293
+ await keychainDelete(apiUrl, field);
294
+ }
295
+
296
+ try {
297
+ const store = readFileStore();
298
+ if (store[host]) {
299
+ delete store[host];
300
+ if (Object.keys(store).length === 0) {
301
+ try { fs.unlinkSync(getCredentialsFile()); } catch { /* ignore */ }
302
+ } else {
303
+ writeFileStore(store);
304
+ }
305
+ }
306
+ } catch (err) {
307
+ process.stderr.write(`[ainote-mcp] file credentials clear failed: ${err.message}\n`);
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Internal helper for diagnostics. Not part of public API.
313
+ */
314
+ export function _credentialsLocations() {
315
+ return {
316
+ keychainService: keychainService(),
317
+ file: getCredentialsFile()
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Internal helper exposing keychain account names for tests. Not public API.
323
+ */
324
+ export function _keychainAccount(apiUrl, field) {
325
+ return accountFor(apiUrl, field);
326
+ }