@ainote/mcp 1.3.2 → 1.3.3

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,286 +1,302 @@
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
- let keychainModule = null;
36
- let keychainProbed = false;
37
-
38
- function getKeychain() {
39
- if (keychainProbed) return keychainModule;
40
- keychainProbed = true;
41
- try {
42
- keychainModule = require('cross-keychain');
43
- } catch (err) {
44
- process.stderr.write(`[ainote-mcp] cross-keychain unavailable, using file fallback: ${err.message}\n`);
45
- keychainModule = null;
46
- }
47
- return keychainModule;
48
- }
49
-
50
- function getCredentialsDir() {
51
- const xdg = process.env.XDG_CONFIG_HOME;
52
- const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.config');
53
- return path.join(base, 'ainote');
54
- }
55
-
56
- function getCredentialsFile() {
57
- return path.join(getCredentialsDir(), 'credentials.json');
58
- }
59
-
60
- function apiHost(apiUrl) {
61
- try {
62
- const parsed = new url.URL(apiUrl);
63
- return parsed.host || apiUrl;
64
- } catch {
65
- return apiUrl;
66
- }
67
- }
68
-
69
- function accountFor(apiUrl, field) {
70
- return `${apiHost(apiUrl)}:${field}`;
71
- }
72
-
73
- function readFileStore() {
74
- const file = getCredentialsFile();
75
- try {
76
- const raw = fs.readFileSync(file, 'utf8');
77
- const parsed = JSON.parse(raw);
78
- return parsed && typeof parsed === 'object' ? parsed : {};
79
- } catch (err) {
80
- if (err.code !== 'ENOENT') {
81
- process.stderr.write(`[ainote-mcp] credentials.json read failed: ${err.message}\n`);
82
- }
83
- return {};
84
- }
85
- }
86
-
87
- function writeFileStore(store) {
88
- const dir = getCredentialsDir();
89
- try {
90
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
91
- } catch (err) {
92
- if (err.code !== 'EEXIST') throw err;
93
- }
94
- // Try to tighten directory perms even if mkdir didn't get to chmod (older Node).
95
- try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
96
-
97
- const file = getCredentialsFile();
98
- const tmp = `${file}.tmp`;
99
- fs.writeFileSync(tmp, JSON.stringify(store, null, 2), { mode: 0o600 });
100
- try { fs.chmodSync(tmp, 0o600); } catch { /* best effort */ }
101
- fs.renameSync(tmp, file);
102
- try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
103
- }
104
-
105
- async function keychainGet(apiUrl, field) {
106
- const kc = getKeychain();
107
- if (!kc || typeof kc.getPassword !== 'function') return null;
108
- try {
109
- const value = await kc.getPassword(SERVICE, accountFor(apiUrl, field));
110
- return value || null;
111
- } catch (err) {
112
- process.stderr.write(`[ainote-mcp] keychain read failed (${field}): ${err.message}\n`);
113
- return null;
114
- }
115
- }
116
-
117
- async function keychainSet(apiUrl, field, value) {
118
- const kc = getKeychain();
119
- if (!kc || typeof kc.setPassword !== 'function') return false;
120
- try {
121
- if (value === null || value === undefined || value === '') {
122
- if (typeof kc.deletePassword === 'function') {
123
- await kc.deletePassword(SERVICE, accountFor(apiUrl, field));
124
- }
125
- return true;
126
- }
127
- await kc.setPassword(SERVICE, accountFor(apiUrl, field), String(value));
128
- return true;
129
- } catch (err) {
130
- process.stderr.write(`[ainote-mcp] keychain write failed (${field}): ${err.message}\n`);
131
- return false;
132
- }
133
- }
134
-
135
- async function keychainDelete(apiUrl, field) {
136
- const kc = getKeychain();
137
- if (!kc || typeof kc.deletePassword !== 'function') return false;
138
- try {
139
- await kc.deletePassword(SERVICE, accountFor(apiUrl, field));
140
- return true;
141
- } catch (err) {
142
- if (err && /not found/i.test(err.message)) return true;
143
- return false;
144
- }
145
- }
146
-
147
- /**
148
- * Read credentials for the given API URL. Returns `null` if nothing is stored.
149
- *
150
- * Tries keychain first for each field; falls back to file. If both yield
151
- * nothing, returns null. Mixed sources (keychain mcp_key + file refresh) are
152
- * permitted and merged.
153
- *
154
- * @returns {Promise<null | {mcpKey?: string, accessToken?: string,
155
- * refreshToken?: string, expiresAt?: number, source: 'keychain'|'file'|'mixed'}>}
156
- */
157
- export async function getCredentials(apiUrl) {
158
- const host = apiHost(apiUrl);
159
-
160
- const fileStore = readFileStore();
161
- const fileEntry = fileStore[host] || {};
162
-
163
- const kc = getKeychain();
164
- const result = {};
165
- let usedKeychain = false;
166
- let usedFile = false;
167
-
168
- for (const field of FIELDS) {
169
- let value = null;
170
- if (kc) {
171
- value = await keychainGet(apiUrl, field);
172
- if (value !== null && value !== undefined) usedKeychain = true;
173
- }
174
- if (value === null || value === undefined) {
175
- if (fileEntry[field] !== undefined && fileEntry[field] !== null) {
176
- value = fileEntry[field];
177
- usedFile = true;
178
- }
179
- }
180
- if (value !== null && value !== undefined && value !== '') {
181
- result[field] = value;
182
- }
183
- }
184
-
185
- if (Object.keys(result).length === 0) return null;
186
-
187
- const out = {
188
- mcpKey: result.mcp_key,
189
- accessToken: result.access_token,
190
- refreshToken: result.refresh_token,
191
- expiresAt: result.expires_at ? Number(result.expires_at) : undefined,
192
- source: usedKeychain && usedFile ? 'mixed' : usedKeychain ? 'keychain' : 'file'
193
- };
194
- return out;
195
- }
196
-
197
- /**
198
- * Persist credentials. Writes to keychain when available; ALWAYS also writes
199
- * a minimal file shadow when keychain write fails for any field, so the user
200
- * can still authenticate after a partial-failure scenario.
201
- *
202
- * @param {string} apiUrl
203
- * @param {{mcpKey?: string, accessToken?: string, refreshToken?: string, expiresAt?: number|string}} creds
204
- * @returns {Promise<{storage: 'keychain'|'file'|'mixed', file?: string}>}
205
- */
206
- export async function setCredentials(apiUrl, creds) {
207
- const host = apiHost(apiUrl);
208
- const mapping = {
209
- mcp_key: creds.mcpKey,
210
- access_token: creds.accessToken,
211
- refresh_token: creds.refreshToken,
212
- expires_at: creds.expiresAt === undefined ? undefined : String(creds.expiresAt)
213
- };
214
-
215
- let keychainOk = true;
216
- let anyKeychain = false;
217
-
218
- for (const [field, value] of Object.entries(mapping)) {
219
- if (value === undefined) continue;
220
- const ok = await keychainSet(apiUrl, field, value);
221
- if (ok) anyKeychain = true;
222
- else keychainOk = false;
223
- }
224
-
225
- let fileWritten = false;
226
- if (!anyKeychain || !keychainOk) {
227
- // Write or merge file store so credentials survive even if some keychain
228
- // writes failed.
229
- const store = readFileStore();
230
- const entry = { ...(store[host] || {}) };
231
- for (const [field, value] of Object.entries(mapping)) {
232
- if (value === undefined) continue;
233
- if (value === null || value === '') delete entry[field];
234
- else entry[field] = field === 'expires_at' ? Number(value) : value;
235
- }
236
- store[host] = entry;
237
- writeFileStore(store);
238
- fileWritten = true;
239
- }
240
-
241
- let storage;
242
- if (anyKeychain && !fileWritten) storage = 'keychain';
243
- else if (!anyKeychain && fileWritten) storage = 'file';
244
- else storage = 'mixed';
245
-
246
- return {
247
- storage,
248
- file: fileWritten ? getCredentialsFile() : undefined
249
- };
250
- }
251
-
252
- /**
253
- * Remove credentials for the given API URL from both keychain and file.
254
- * Best-effort: errors are swallowed and the caller can assume cleared state.
255
- */
256
- export async function clearCredentials(apiUrl) {
257
- const host = apiHost(apiUrl);
258
-
259
- for (const field of FIELDS) {
260
- await keychainDelete(apiUrl, field);
261
- }
262
-
263
- try {
264
- const store = readFileStore();
265
- if (store[host]) {
266
- delete store[host];
267
- if (Object.keys(store).length === 0) {
268
- try { fs.unlinkSync(getCredentialsFile()); } catch { /* ignore */ }
269
- } else {
270
- writeFileStore(store);
271
- }
272
- }
273
- } catch (err) {
274
- process.stderr.write(`[ainote-mcp] file credentials clear failed: ${err.message}\n`);
275
- }
276
- }
277
-
278
- /**
279
- * Internal helper for diagnostics. Not part of public API.
280
- */
281
- export function _credentialsLocations() {
282
- return {
283
- keychainService: SERVICE,
284
- file: getCredentialsFile()
285
- };
286
- }
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
+ function accountFor(apiUrl, field) {
86
+ return `${apiHost(apiUrl)}:${field}`;
87
+ }
88
+
89
+ function readFileStore() {
90
+ const file = getCredentialsFile();
91
+ try {
92
+ const raw = fs.readFileSync(file, 'utf8');
93
+ const parsed = JSON.parse(raw);
94
+ return parsed && typeof parsed === 'object' ? parsed : {};
95
+ } catch (err) {
96
+ if (err.code !== 'ENOENT') {
97
+ process.stderr.write(`[ainote-mcp] credentials.json read failed: ${err.message}\n`);
98
+ }
99
+ return {};
100
+ }
101
+ }
102
+
103
+ function writeFileStore(store) {
104
+ const dir = getCredentialsDir();
105
+ try {
106
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
107
+ } catch (err) {
108
+ if (err.code !== 'EEXIST') throw err;
109
+ }
110
+ // Try to tighten directory perms even if mkdir didn't get to chmod (older Node).
111
+ try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
112
+
113
+ const file = getCredentialsFile();
114
+ const tmp = `${file}.tmp`;
115
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2), { mode: 0o600 });
116
+ try { fs.chmodSync(tmp, 0o600); } catch { /* best effort */ }
117
+ fs.renameSync(tmp, file);
118
+ try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
119
+ }
120
+
121
+ async function keychainGet(apiUrl, field) {
122
+ const kc = getKeychain();
123
+ if (!kc || typeof kc.getPassword !== 'function') return null;
124
+ try {
125
+ const value = await kc.getPassword(keychainService(), accountFor(apiUrl, field));
126
+ return value || null;
127
+ } catch (err) {
128
+ process.stderr.write(`[ainote-mcp] keychain read failed (${field}): ${err.message}\n`);
129
+ return null;
130
+ }
131
+ }
132
+
133
+ async function keychainSet(apiUrl, field, value) {
134
+ const kc = getKeychain();
135
+ if (!kc || typeof kc.setPassword !== 'function') return false;
136
+ try {
137
+ if (value === null || value === undefined || value === '') {
138
+ if (typeof kc.deletePassword === 'function') {
139
+ await kc.deletePassword(keychainService(), accountFor(apiUrl, field));
140
+ }
141
+ return true;
142
+ }
143
+ await kc.setPassword(keychainService(), accountFor(apiUrl, field), String(value));
144
+ return true;
145
+ } catch (err) {
146
+ process.stderr.write(`[ainote-mcp] keychain write failed (${field}): ${err.message}\n`);
147
+ return false;
148
+ }
149
+ }
150
+
151
+ async function keychainDelete(apiUrl, field) {
152
+ const kc = getKeychain();
153
+ if (!kc || typeof kc.deletePassword !== 'function') return false;
154
+ try {
155
+ await kc.deletePassword(keychainService(), accountFor(apiUrl, field));
156
+ return true;
157
+ } catch (err) {
158
+ if (err && /not found/i.test(err.message)) return true;
159
+ return false;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Read credentials for the given API URL. Returns `null` if nothing is stored.
165
+ *
166
+ * Tries keychain first for each field; falls back to file. If both yield
167
+ * nothing, returns null. Mixed sources (keychain mcp_key + file refresh) are
168
+ * permitted and merged.
169
+ *
170
+ * @returns {Promise<null | {mcpKey?: string, accessToken?: string,
171
+ * refreshToken?: string, expiresAt?: number, source: 'keychain'|'file'|'mixed'}>}
172
+ */
173
+ export async function getCredentials(apiUrl) {
174
+ const host = apiHost(apiUrl);
175
+
176
+ const fileStore = readFileStore();
177
+ const fileEntry = fileStore[host] || {};
178
+
179
+ const kc = getKeychain();
180
+ const result = {};
181
+ let usedKeychain = false;
182
+ let usedFile = false;
183
+
184
+ for (const field of FIELDS) {
185
+ let value = null;
186
+ if (kc) {
187
+ value = await keychainGet(apiUrl, field);
188
+ if (value !== null && value !== undefined) usedKeychain = true;
189
+ }
190
+ if (value === null || value === undefined) {
191
+ if (fileEntry[field] !== undefined && fileEntry[field] !== null) {
192
+ value = fileEntry[field];
193
+ usedFile = true;
194
+ }
195
+ }
196
+ if (value !== null && value !== undefined && value !== '') {
197
+ result[field] = value;
198
+ }
199
+ }
200
+
201
+ if (Object.keys(result).length === 0) return null;
202
+
203
+ const out = {
204
+ mcpKey: result.mcp_key,
205
+ accessToken: result.access_token,
206
+ refreshToken: result.refresh_token,
207
+ expiresAt: result.expires_at ? Number(result.expires_at) : undefined,
208
+ source: usedKeychain && usedFile ? 'mixed' : usedKeychain ? 'keychain' : 'file'
209
+ };
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * Persist credentials. Writes to keychain when available; ALWAYS also writes
215
+ * a minimal file shadow when keychain write fails for any field, so the user
216
+ * can still authenticate after a partial-failure scenario.
217
+ *
218
+ * @param {string} apiUrl
219
+ * @param {{mcpKey?: string, accessToken?: string, refreshToken?: string, expiresAt?: number|string}} creds
220
+ * @returns {Promise<{storage: 'keychain'|'file'|'mixed', file?: string}>}
221
+ */
222
+ export async function setCredentials(apiUrl, creds) {
223
+ const host = apiHost(apiUrl);
224
+ const mapping = {
225
+ mcp_key: creds.mcpKey,
226
+ access_token: creds.accessToken,
227
+ refresh_token: creds.refreshToken,
228
+ expires_at: creds.expiresAt === undefined ? undefined : String(creds.expiresAt)
229
+ };
230
+
231
+ let keychainOk = true;
232
+ let anyKeychain = false;
233
+
234
+ for (const [field, value] of Object.entries(mapping)) {
235
+ if (value === undefined) continue;
236
+ const ok = await keychainSet(apiUrl, field, value);
237
+ if (ok) anyKeychain = true;
238
+ else keychainOk = false;
239
+ }
240
+
241
+ let fileWritten = false;
242
+ if (!anyKeychain || !keychainOk) {
243
+ // Write or merge file store so credentials survive even if some keychain
244
+ // writes failed.
245
+ const store = readFileStore();
246
+ const entry = { ...(store[host] || {}) };
247
+ for (const [field, value] of Object.entries(mapping)) {
248
+ if (value === undefined) continue;
249
+ if (value === null || value === '') delete entry[field];
250
+ else entry[field] = field === 'expires_at' ? Number(value) : value;
251
+ }
252
+ store[host] = entry;
253
+ writeFileStore(store);
254
+ fileWritten = true;
255
+ }
256
+
257
+ let storage;
258
+ if (anyKeychain && !fileWritten) storage = 'keychain';
259
+ else if (!anyKeychain && fileWritten) storage = 'file';
260
+ else storage = 'mixed';
261
+
262
+ return {
263
+ storage,
264
+ file: fileWritten ? getCredentialsFile() : undefined
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Remove credentials for the given API URL from both keychain and file.
270
+ * Best-effort: errors are swallowed and the caller can assume cleared state.
271
+ */
272
+ export async function clearCredentials(apiUrl) {
273
+ const host = apiHost(apiUrl);
274
+
275
+ for (const field of FIELDS) {
276
+ await keychainDelete(apiUrl, field);
277
+ }
278
+
279
+ try {
280
+ const store = readFileStore();
281
+ if (store[host]) {
282
+ delete store[host];
283
+ if (Object.keys(store).length === 0) {
284
+ try { fs.unlinkSync(getCredentialsFile()); } catch { /* ignore */ }
285
+ } else {
286
+ writeFileStore(store);
287
+ }
288
+ }
289
+ } catch (err) {
290
+ process.stderr.write(`[ainote-mcp] file credentials clear failed: ${err.message}\n`);
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Internal helper for diagnostics. Not part of public API.
296
+ */
297
+ export function _credentialsLocations() {
298
+ return {
299
+ keychainService: keychainService(),
300
+ file: getCredentialsFile()
301
+ };
302
+ }