@financedistrict/fdx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,480 @@
1
+ const axios = require('axios');
2
+
3
+ const defaultCredentialStore = require('./credential-store');
4
+ const logger = require('./utils/logger');
5
+ const { readStore, writeStore } = require('./storage');
6
+ const { generateCodeChallenge, generateCodeVerifier, generateState } = require('./utils/pkce');
7
+
8
+ class MCPAuthClient {
9
+ constructor({ mcpServerUrl, redirectUri, storePath, httpClient, credentialStore }) {
10
+ if (!mcpServerUrl) throw new Error('mcpServerUrl is required');
11
+ if (!redirectUri) throw new Error('redirectUri is required');
12
+ if (!storePath) throw new Error('storePath is required');
13
+
14
+ const parsed = new URL(mcpServerUrl);
15
+ if (
16
+ parsed.protocol !== 'https:' &&
17
+ parsed.hostname !== 'localhost' &&
18
+ parsed.hostname !== '127.0.0.1'
19
+ ) {
20
+ throw new Error('mcpServerUrl must use HTTPS (HTTP is only allowed for localhost)');
21
+ }
22
+
23
+ this.mcpServerUrl = mcpServerUrl.replace(/\/$/, '');
24
+ this.redirectUri = redirectUri;
25
+ this.storePath = storePath;
26
+ this.httpClient = httpClient || axios.create();
27
+ this._credentialStore = credentialStore || defaultCredentialStore;
28
+ this._initialized = false;
29
+ this._initPromise = null;
30
+ this._deviceInitialized = false;
31
+ this._deviceInitPromise = null;
32
+ this._discovered = false;
33
+ this._discoveryPromise = null;
34
+ }
35
+
36
+ async initialize() {
37
+ if (this._initialized) return;
38
+ if (this._initPromise) return this._initPromise;
39
+ this._initPromise = this.#doInitialize();
40
+ try {
41
+ await this._initPromise;
42
+ } finally {
43
+ this._initPromise = null;
44
+ }
45
+ }
46
+
47
+ async #doInitialize() {
48
+ await this.#ensureDiscovered();
49
+
50
+ const store = await this.#readStore();
51
+ const cached = store.mcpAuth;
52
+
53
+ if (cached?.clientId) {
54
+ this.clientId = cached.clientId;
55
+ this._initialized = true;
56
+ logger.debug('mcp-auth: using cached interactive client', { clientId: this.clientId });
57
+ return;
58
+ }
59
+
60
+ // Device-only setup — reuse device client_id for token refresh
61
+ if (cached?.deviceClientId) {
62
+ this.clientId = cached.deviceClientId;
63
+ this._initialized = true;
64
+ logger.debug('mcp-auth: using cached device client as interactive', { clientId: this.clientId });
65
+ return;
66
+ }
67
+
68
+ if (this.registrationEndpoint) {
69
+ const { data: registration } = await this.httpClient.post(
70
+ this.registrationEndpoint,
71
+ {
72
+ redirect_uris: [this.redirectUri],
73
+ client_name: 'FDX Wallet Client',
74
+ token_endpoint_auth_method: 'none',
75
+ grant_types: ['authorization_code', 'refresh_token'],
76
+ response_types: ['code'],
77
+ scope: ['openid', 'offline_access', 'api://fd-agent-wallet-mcp/mcp:tools'],
78
+ },
79
+ {
80
+ headers: { 'Content-Type': 'application/json' },
81
+ },
82
+ );
83
+
84
+ this.clientId = registration.client_id;
85
+ await this.#persistMCPAuth({ clientId: this.clientId });
86
+ logger.info('mcp-auth: interactive client registered', { clientId: this.clientId });
87
+ }
88
+
89
+ this._initialized = true;
90
+ }
91
+
92
+ async initializeForDevice() {
93
+ if (this._deviceInitialized) return;
94
+ if (this._deviceInitPromise) return this._deviceInitPromise;
95
+ this._deviceInitPromise = this.#doInitializeDevice();
96
+ try {
97
+ await this._deviceInitPromise;
98
+ } finally {
99
+ this._deviceInitPromise = null;
100
+ }
101
+ }
102
+
103
+ async #doInitializeDevice() {
104
+ await this.#ensureDiscovered();
105
+
106
+ if (!this.deviceAuthorizationEndpoint) {
107
+ throw new Error('Device authorization flow is not supported by this OAuth server');
108
+ }
109
+
110
+ const store = await this.#readStore();
111
+ const cached = store.mcpAuth;
112
+
113
+ if (cached?.deviceClientId) {
114
+ this.deviceClientId = cached.deviceClientId;
115
+ this._deviceInitialized = true;
116
+ logger.debug('mcp-auth: using cached device client', { deviceClientId: this.deviceClientId });
117
+ return;
118
+ }
119
+
120
+ if (this.registrationEndpoint) {
121
+ const { data: registration } = await this.httpClient.post(
122
+ this.registrationEndpoint,
123
+ {
124
+ client_name: 'FDX Wallet Client',
125
+ token_endpoint_auth_method: 'none',
126
+ grant_types: ['urn:ietf:params:oauth:grant-type:device_code'],
127
+ response_types: [],
128
+ },
129
+ {
130
+ headers: { 'Content-Type': 'application/json' },
131
+ },
132
+ );
133
+
134
+ this.deviceClientId = registration.client_id;
135
+ await this.#persistMCPAuth({ deviceClientId: this.deviceClientId });
136
+ logger.info('mcp-auth: device client registered', { deviceClientId: this.deviceClientId });
137
+ }
138
+
139
+ this._deviceInitialized = true;
140
+ }
141
+
142
+ async getAuthorizationUrl() {
143
+ await this.initialize();
144
+
145
+ const verifier = generateCodeVerifier();
146
+ const state = generateState();
147
+ const codeChallenge = await generateCodeChallenge(verifier);
148
+
149
+ const url = new URL(this.authorizationEndpoint);
150
+ url.searchParams.set('response_type', 'code');
151
+ url.searchParams.set('client_id', this.clientId);
152
+ url.searchParams.set('redirect_uri', this.redirectUri);
153
+ url.searchParams.set('code_challenge', codeChallenge);
154
+ url.searchParams.set('code_challenge_method', 'S256');
155
+ url.searchParams.set('state', state);
156
+ url.searchParams.set('scope', 'openid offline_access api://fd-agent-wallet-mcp/mcp:tools');
157
+ url.searchParams.set('prompt', 'consent');
158
+ url.searchParams.set('resource', this.mcpServerUrl);
159
+
160
+ return {
161
+ url: url.toString(),
162
+ state,
163
+ codeVerifier: verifier,
164
+ codeChallenge,
165
+ };
166
+ }
167
+
168
+ async startDeviceFlow() {
169
+ await this.initializeForDevice();
170
+
171
+ if (!this.deviceAuthorizationEndpoint) {
172
+ throw new Error('Device authorization flow is not supported by this OAuth server');
173
+ }
174
+
175
+ const payload = new URLSearchParams({
176
+ client_id: this.deviceClientId,
177
+ scope: 'openid offline_access api://fd-agent-wallet-mcp/mcp:tools',
178
+ });
179
+
180
+ const { data } = await this.httpClient.post(
181
+ this.deviceAuthorizationEndpoint,
182
+ payload.toString(),
183
+ { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
184
+ );
185
+
186
+ return {
187
+ deviceCode: data.device_code,
188
+ userCode: data.user_code,
189
+ verificationUri: data.verification_uri,
190
+ verificationUriComplete: data.verification_uri_complete,
191
+ expiresIn: data.expires_in,
192
+ interval: data.interval || 5,
193
+ };
194
+ }
195
+
196
+ async pollDeviceToken({ deviceCode, interval = 5 }) {
197
+ let pollIntervalMs = interval * 1000;
198
+
199
+ // eslint-disable-next-line no-constant-condition
200
+ while (true) {
201
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
202
+
203
+ const payload = new URLSearchParams({
204
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
205
+ client_id: this.deviceClientId,
206
+ device_code: deviceCode,
207
+ });
208
+
209
+ try {
210
+ const { data } = await this.httpClient.post(this.tokenEndpoint, payload.toString(), {
211
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
212
+ });
213
+
214
+ await this.#persistTokens(data);
215
+ logger.info('mcp-auth: token obtained via device flow', { server: this.mcpServerUrl });
216
+ return data;
217
+ } catch (err) {
218
+ const error = err.response?.data?.error;
219
+
220
+ if (error === 'authorization_pending') {
221
+ continue;
222
+ } else if (error === 'slow_down') {
223
+ pollIntervalMs += 5000;
224
+ continue;
225
+ } else if (error === 'access_denied') {
226
+ throw new Error('Device flow access denied by user');
227
+ } else if (error === 'expired_token') {
228
+ throw new Error('Device flow code expired — please run setup again');
229
+ } else {
230
+ throw err;
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ async exchangeCodeForToken({ code, state, codeVerifier }) {
237
+ await this.initialize();
238
+
239
+ if (!code) {
240
+ throw new Error('code is required');
241
+ }
242
+ if (!codeVerifier) {
243
+ throw new Error('codeVerifier is required');
244
+ }
245
+ if (!state) {
246
+ throw new Error('state is required');
247
+ }
248
+
249
+ const payload = new URLSearchParams({
250
+ grant_type: 'authorization_code',
251
+ client_id: this.clientId,
252
+ redirect_uri: this.redirectUri,
253
+ code,
254
+ code_verifier: codeVerifier,
255
+ });
256
+
257
+ const { data } = await this.httpClient.post(this.tokenEndpoint, payload.toString(), {
258
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
259
+ });
260
+
261
+ await this.#persistTokens(data);
262
+ return data;
263
+ }
264
+
265
+ async getAccessToken() {
266
+ const tokens = await this.#getTokens();
267
+ if (!tokens?.accessToken) {
268
+ throw new Error('No access token available');
269
+ }
270
+
271
+ if (!tokens.expiresAt || Date.now() < tokens.expiresAt - 10000) {
272
+ return tokens.accessToken;
273
+ }
274
+
275
+ return this.refreshToken();
276
+ }
277
+
278
+ async refreshToken() {
279
+ // Ensure endpoint discovery is done — read from cache or discover live.
280
+ // Do NOT call initialize() here: that would trigger interactive DCR for
281
+ // device-only setups that have never run interactive setup.
282
+ await this.#ensureDiscovered();
283
+
284
+ // Load client ID from store if not already in memory
285
+ if (!this.clientId && !this.deviceClientId) {
286
+ const store = await this.#readStore();
287
+ this.clientId = store.mcpAuth?.clientId ?? null;
288
+ this.deviceClientId = store.mcpAuth?.deviceClientId ?? null;
289
+ }
290
+
291
+ const tokens = await this.#getTokens();
292
+ if (!tokens?.refreshToken) {
293
+ throw new Error('No refresh token available');
294
+ }
295
+
296
+ logger.debug('mcp-auth: refreshing access token', { server: this.mcpServerUrl });
297
+
298
+ const payload = new URLSearchParams({
299
+ grant_type: 'refresh_token',
300
+ refresh_token: tokens.refreshToken,
301
+ client_id: this.clientId ?? this.deviceClientId,
302
+ });
303
+
304
+ const { data } = await this.httpClient.post(this.tokenEndpoint, payload.toString(), {
305
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
306
+ });
307
+
308
+ await this.#persistTokens({ ...tokens, ...data });
309
+ logger.info('mcp-auth: access token refreshed', { server: this.mcpServerUrl });
310
+ return data.access_token;
311
+ }
312
+
313
+ async getTokenState() {
314
+ const store = await this.#readStore();
315
+ const tokens = await this.#getTokens();
316
+
317
+ return {
318
+ authenticated: !!tokens?.accessToken,
319
+ expired: tokens?.expiresAt ? Date.now() >= tokens.expiresAt : false,
320
+ hasRefresh: !!tokens?.refreshToken,
321
+ expiresAt: tokens?.expiresAt,
322
+ clientId: store.mcpAuth?.clientId || store.mcpAuth?.deviceClientId,
323
+ usingCredentialStore: !!store.tokens?.credentialStore,
324
+ };
325
+ }
326
+
327
+ async logout() {
328
+ // Remove tokens from OS credential store
329
+ this._credentialStore.deleteSecret(this.#credentialAccount());
330
+
331
+ // Clear tokens from the store file but keep mcpAuth (client registrations)
332
+ // so the next `fdx setup` skips DCR and goes straight to auth
333
+ const store = await this.#readStore();
334
+ delete store.tokens;
335
+ await writeStore(store, this.storePath);
336
+
337
+ // Reset in-memory token state
338
+ this._initialized = false;
339
+ this._deviceInitialized = false;
340
+ this._discovered = false;
341
+
342
+ logger.info('mcp-auth: logged out', { server: this.mcpServerUrl });
343
+ }
344
+
345
+ #credentialAccount() {
346
+ return new URL(this.mcpServerUrl).host;
347
+ }
348
+
349
+ async #ensureDiscovered() {
350
+ if (this._discovered) return;
351
+ if (this._discoveryPromise) return this._discoveryPromise;
352
+ this._discoveryPromise = this.#doDiscover();
353
+ try {
354
+ await this._discoveryPromise;
355
+ } finally {
356
+ this._discoveryPromise = null;
357
+ }
358
+ }
359
+
360
+ async #doDiscover() {
361
+ const store = await this.#readStore();
362
+ const cached = store.mcpAuth;
363
+
364
+ if (cached?.oauthServerUrl && cached?.tokenEndpoint) {
365
+ this.oauthServerUrl = cached.oauthServerUrl;
366
+ this.authorizationEndpoint = cached.authorizationEndpoint;
367
+ this.tokenEndpoint = cached.tokenEndpoint;
368
+ this.registrationEndpoint = cached.registrationEndpoint;
369
+ this.deviceAuthorizationEndpoint = cached.deviceAuthorizationEndpoint;
370
+ this._discovered = true;
371
+ logger.debug('mcp-auth: using cached OAuth discovery', { server: this.oauthServerUrl });
372
+ return;
373
+ }
374
+
375
+ const protectedResourceUrl = `${this.mcpServerUrl}/.well-known/oauth-protected-resource`;
376
+ const { data: protectedResource } = await this.httpClient.get(protectedResourceUrl);
377
+
378
+ this.oauthServerUrl = protectedResource.authorization_servers[0];
379
+ logger.info('mcp-auth: OAuth server discovered', { server: this.oauthServerUrl });
380
+ // RFC 8414 preferred; fall back to OIDC discovery (e.g. Entra External ID only exposes the latter)
381
+ const metadata = await this.#discoverMetadata(this.oauthServerUrl);
382
+
383
+ this.authorizationEndpoint = metadata.authorization_endpoint;
384
+ this.tokenEndpoint = metadata.token_endpoint;
385
+ this.registrationEndpoint = metadata.registration_endpoint;
386
+ this.deviceAuthorizationEndpoint = metadata.device_authorization_endpoint;
387
+
388
+ await this.#persistMCPAuth({
389
+ oauthServerUrl: this.oauthServerUrl,
390
+ authorizationEndpoint: this.authorizationEndpoint,
391
+ tokenEndpoint: this.tokenEndpoint,
392
+ registrationEndpoint: this.registrationEndpoint,
393
+ deviceAuthorizationEndpoint: this.deviceAuthorizationEndpoint,
394
+ });
395
+
396
+ this._discovered = true;
397
+ }
398
+
399
+ async #discoverMetadata(oauthServerUrl) {
400
+ // Try RFC 8414 first; fall back to OIDC discovery (/.well-known/openid-configuration).
401
+ // Entra External ID only exposes the OIDC document.
402
+ const rfc8414Url = `${oauthServerUrl}/.well-known/oauth-authorization-server`;
403
+ try {
404
+ const { data } = await this.httpClient.get(rfc8414Url);
405
+ if (data?.token_endpoint) return data;
406
+ } catch {
407
+ // not found — try OIDC
408
+ }
409
+
410
+ const oidcUrl = `${oauthServerUrl}/.well-known/openid-configuration`;
411
+ const { data } = await this.httpClient.get(oidcUrl);
412
+ return data;
413
+ }
414
+
415
+ async #getTokens() {
416
+ const store = await this.#readStore();
417
+ const tokens = store.tokens;
418
+ if (!tokens) return null;
419
+
420
+ if (tokens.credentialStore) {
421
+ try {
422
+ const raw = this._credentialStore.getSecret(this.#credentialAccount());
423
+ if (raw) {
424
+ const secrets = JSON.parse(raw);
425
+ return { ...tokens, ...secrets };
426
+ }
427
+ } catch {
428
+ // Credential store read failed — return metadata only
429
+ }
430
+ return tokens;
431
+ }
432
+
433
+ // Legacy: tokens are stored inline in the file
434
+ return tokens;
435
+ }
436
+
437
+ async #persistTokens(tokenResponse) {
438
+ const store = await this.#readStore();
439
+ const expiresInMs = (tokenResponse.expires_in || 0) * 1000;
440
+ const accessToken = tokenResponse.access_token;
441
+ const refreshToken =
442
+ tokenResponse.refresh_token || tokenResponse.refreshToken || store.tokens?.refreshToken;
443
+
444
+ const stored = this._credentialStore.setSecret(
445
+ this.#credentialAccount(),
446
+ JSON.stringify({ accessToken, refreshToken }),
447
+ );
448
+
449
+ store.tokens = {
450
+ scope: tokenResponse.scope,
451
+ tokenType: tokenResponse.token_type || 'Bearer',
452
+ expiresAt: expiresInMs ? Date.now() + expiresInMs : undefined,
453
+ };
454
+
455
+ if (stored) {
456
+ store.tokens.credentialStore = true;
457
+ } else {
458
+ store.tokens.accessToken = accessToken;
459
+ store.tokens.refreshToken = refreshToken;
460
+ process.emitWarning(
461
+ 'OS credential store not available. Tokens stored in plaintext at ' + this.storePath,
462
+ 'SecurityWarning',
463
+ );
464
+ }
465
+
466
+ await writeStore(store, this.storePath);
467
+ }
468
+
469
+ async #persistMCPAuth(mcpAuth) {
470
+ const store = await this.#readStore();
471
+ store.mcpAuth = { ...store.mcpAuth, ...mcpAuth };
472
+ await writeStore(store, this.storePath);
473
+ }
474
+
475
+ async #readStore() {
476
+ return readStore(this.storePath);
477
+ }
478
+ }
479
+
480
+ module.exports = { MCPAuthClient };
@@ -0,0 +1,151 @@
1
+ const { Client } = require('@modelcontextprotocol/sdk/client');
2
+ const {
3
+ StreamableHTTPClientTransport,
4
+ } = require('@modelcontextprotocol/sdk/client/streamableHttp.js');
5
+
6
+ const logger = require('./utils/logger');
7
+
8
+ const CLIENT_NAME = 'fdx';
9
+ const CLIENT_VERSION = require('../package.json').version;
10
+
11
+ class MCPClient {
12
+ constructor({ mcpServerUrl, authClient }) {
13
+ if (!mcpServerUrl) throw new Error('mcpServerUrl is required');
14
+ if (!authClient) throw new Error('authClient is required');
15
+
16
+ this.mcpServerUrl = mcpServerUrl.replace(/\/$/, '');
17
+ this.authClient = authClient;
18
+ this._client = null;
19
+ this._transport = null;
20
+ }
21
+
22
+ async connect() {
23
+ await this.close();
24
+
25
+ const accessToken = await this.authClient.getAccessToken();
26
+
27
+ this._transport = new StreamableHTTPClientTransport(new URL(this.mcpServerUrl), {
28
+ requestInit: {
29
+ headers: {
30
+ Authorization: `Bearer ${accessToken}`,
31
+ },
32
+ },
33
+ });
34
+
35
+ this._client = new Client({ name: CLIENT_NAME, version: CLIENT_VERSION }, { capabilities: {} });
36
+
37
+ await this._client.connect(this._transport);
38
+ }
39
+
40
+ async callTool(toolName, args, retried = false) {
41
+ if (!toolName) throw new Error('toolName is required');
42
+
43
+ logger.debug('mcp: calling tool', { tool: toolName, args });
44
+
45
+ try {
46
+ if (!this._client) await this.connect();
47
+
48
+ const result = await this._client.callTool({
49
+ name: toolName,
50
+ arguments: args || {},
51
+ });
52
+
53
+ if (result.isError) {
54
+ const message = result.content?.[0]?.text || 'Tool returned an error';
55
+ logger.warn('mcp: tool returned error', { tool: toolName, message });
56
+ return { error: { code: 'TOOL_ERROR', message } };
57
+ }
58
+
59
+ for (const item of result.content || []) {
60
+ if (item.type === 'text' && item.text) {
61
+ try {
62
+ logger.info('mcp: tool call succeeded', { tool: toolName });
63
+ return { data: JSON.parse(item.text) };
64
+ } catch {
65
+ logger.info('mcp: tool call succeeded', { tool: toolName });
66
+ return { data: item.text };
67
+ }
68
+ }
69
+ }
70
+
71
+ logger.info('mcp: tool call succeeded', { tool: toolName });
72
+ return { data: result.content };
73
+ } catch (error) {
74
+ // Handle auth failures — reconnect with refreshed token once
75
+ if (!retried && isAuthError(error)) {
76
+ logger.warn('mcp: 401 on tool call, retrying after token refresh', { tool: toolName });
77
+ try {
78
+ await this.close();
79
+ await this.authClient.refreshToken();
80
+ return this.callTool(toolName, args, true);
81
+ } catch (refreshError) {
82
+ logger.error('mcp: token refresh failed during tool call', { tool: toolName, error: refreshError.message });
83
+ return {
84
+ error: {
85
+ code: 'AUTH_REFRESH_FAILED',
86
+ message: `Token refresh failed: ${refreshError.message}`,
87
+ },
88
+ };
89
+ }
90
+ }
91
+
92
+ // Auth error that we already retried, or getAccessToken failed initially
93
+ if (isAuthError(error)) {
94
+ logger.error('mcp: authentication failed', { tool: toolName, error: error.message });
95
+ return {
96
+ error: {
97
+ code: 'AUTH_ERROR',
98
+ message: `Authentication failed: ${error.message}`,
99
+ },
100
+ };
101
+ }
102
+
103
+ const statusSuffix = error?.code > 0 ? ` (HTTP ${error.code})` : '';
104
+ logger.error('mcp: tool call failed', { tool: toolName, code: error?.code, error: error.message });
105
+ return {
106
+ error: {
107
+ code: 'REQUEST_ERROR',
108
+ message: `${error.message}${statusSuffix}`,
109
+ },
110
+ };
111
+ }
112
+ }
113
+
114
+ async listTools(retried = false) {
115
+ try {
116
+ if (!this._client) await this.connect();
117
+ const result = await this._client.listTools();
118
+ return result.tools;
119
+ } catch (error) {
120
+ if (!retried && isAuthError(error)) {
121
+ await this.close();
122
+ await this.authClient.refreshToken();
123
+ return this.listTools(true);
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ async close() {
130
+ try {
131
+ await this._transport?.close();
132
+ } catch {
133
+ // Ignore close errors
134
+ }
135
+ this._client = null;
136
+ this._transport = null;
137
+ }
138
+ }
139
+
140
+ function isAuthError(error) {
141
+ const msg = error?.message?.toLowerCase() || '';
142
+ return (
143
+ error?.code === 401 || // StreamableHTTPError stores status in .code
144
+ error?.httpStatusCode === 401 ||
145
+ msg.includes('unauthorized') ||
146
+ msg.includes('401') ||
147
+ msg.includes('no access token')
148
+ );
149
+ }
150
+
151
+ module.exports = { MCPClient };
package/src/storage.js ADDED
@@ -0,0 +1,38 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs/promises');
3
+ const path = require('path');
4
+
5
+ async function ensureDirectoryExists(filePath) {
6
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
7
+ }
8
+
9
+ async function readStore(storePath) {
10
+ if (!storePath) throw new Error('storePath is required');
11
+
12
+ try {
13
+ const raw = await fs.readFile(storePath, 'utf8');
14
+ return JSON.parse(raw);
15
+ } catch (error) {
16
+ if (error.code === 'ENOENT') {
17
+ return {};
18
+ }
19
+ throw error;
20
+ }
21
+ }
22
+
23
+ async function writeStore(payload, storePath) {
24
+ if (!storePath) throw new Error('storePath is required');
25
+
26
+ await ensureDirectoryExists(storePath);
27
+ const json = JSON.stringify(payload, null, 2);
28
+ const tmpPath = `${storePath}.${crypto.randomBytes(4).toString('hex')}.tmp`;
29
+ // Note: mode flags are POSIX-only and have no effect on Windows.
30
+ // On Windows, file access is governed by the user's NTFS permissions.
31
+ await fs.writeFile(tmpPath, `${json}\n`, { mode: 0o600 });
32
+ await fs.rename(tmpPath, storePath);
33
+ }
34
+
35
+ module.exports = {
36
+ readStore,
37
+ writeStore,
38
+ };
@@ -0,0 +1,44 @@
1
+ function parseArgs(argv) {
2
+ const args = {};
3
+
4
+ for (let i = 0; i < argv.length; i++) {
5
+ const arg = argv[i];
6
+
7
+ if (arg.startsWith('--')) {
8
+ const rest = arg.substring(2);
9
+
10
+ // Support --key=value syntax
11
+ const eqIdx = rest.indexOf('=');
12
+ if (eqIdx !== -1) {
13
+ const key = rest.substring(0, eqIdx);
14
+ if (key) args[key] = parseValue(rest.substring(eqIdx + 1));
15
+ continue;
16
+ }
17
+
18
+ const key = rest;
19
+ if (!key) continue;
20
+
21
+ const value = argv[i + 1];
22
+
23
+ if (value === undefined || value.startsWith('--')) {
24
+ args[key] = true;
25
+ } else {
26
+ args[key] = parseValue(value);
27
+ i++;
28
+ }
29
+ }
30
+ }
31
+
32
+ return args;
33
+ }
34
+
35
+ function parseValue(value) {
36
+ if (value === 'true') return true;
37
+ if (value === 'false') return false;
38
+
39
+ // All other values stay as strings — no numeric coercion.
40
+ // Financial amounts, addresses, and IDs must not lose precision.
41
+ return value;
42
+ }
43
+
44
+ module.exports = { parseArgs, parseValue };