financy 0.1.2 → 0.1.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.
package/README.md
CHANGED
|
@@ -50,7 +50,25 @@ financy setup --no-input # read FINANCY_CLIENT_ID / _SECRET / _USER_ID from
|
|
|
50
50
|
|
|
51
51
|
`setup` validates the credentials against the live API before saving, so an
|
|
52
52
|
unregistered/free account is caught immediately (exit `3` for bad credentials,
|
|
53
|
-
`4` for an ineligible plan).
|
|
53
|
+
`4` for an ineligible plan). Validation happens **before** anything is written —
|
|
54
|
+
a failed `setup` saves nothing and says so.
|
|
55
|
+
|
|
56
|
+
### Windows
|
|
57
|
+
|
|
58
|
+
The secret prompt echoes one `*` per accepted character, so you can see the value
|
|
59
|
+
arriving. If you paste and no `*`s appear, your console did not paste: the legacy
|
|
60
|
+
Windows console does not accept **Ctrl+V** at a prompt — use **right-click** or
|
|
61
|
+
**Ctrl+Shift+V**. A silently dropped paste used to be submitted as a mangled
|
|
62
|
+
secret and come back as a `401`.
|
|
63
|
+
|
|
64
|
+
The config file lives at `%USERPROFILE%\.config\financy\config.json`. Windows does
|
|
65
|
+
not enforce Unix file modes, so it is not restricted to your user the way it is on
|
|
66
|
+
macOS/Linux — treat it as readable by anything running as you.
|
|
67
|
+
|
|
68
|
+
If you hand-edit that file, note that PowerShell 5.1's `>` and `Out-File` write
|
|
69
|
+
UTF-16 and `Set-Content` writes a UTF-8 BOM. The CLI now reads all three, but
|
|
70
|
+
`financy config` is the way to check: it reports the file as `ok`, `missing`,
|
|
71
|
+
`malformed`, or `unreadable` rather than just showing blank credentials.
|
|
54
72
|
|
|
55
73
|
## Commands
|
|
56
74
|
|
package/dist/bin.js
CHANGED
|
@@ -18,10 +18,12 @@ import {
|
|
|
18
18
|
getConnections,
|
|
19
19
|
loadConfig,
|
|
20
20
|
mintToken,
|
|
21
|
+
notConfigured,
|
|
21
22
|
readCredentialsFile,
|
|
22
23
|
resolveEndpoints,
|
|
24
|
+
sanitizeCredential,
|
|
23
25
|
writeCredentialsFile
|
|
24
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-YOW6MO6F.js";
|
|
25
27
|
|
|
26
28
|
// src/run.ts
|
|
27
29
|
import { Command, CommanderError } from "commander";
|
|
@@ -110,12 +112,17 @@ async function setupCommand(ctx) {
|
|
|
110
112
|
} catch (error) {
|
|
111
113
|
if (error instanceof CliError && error.code === "NOT_AVAILABLE_ON_PLAN") {
|
|
112
114
|
await writeCredentialsFile(endpoints.configDir, credentials);
|
|
115
|
+
} else {
|
|
116
|
+
ctx.err(
|
|
117
|
+
"\u2717 Nothing was saved. financy setup writes the config file only after the\n credentials validate against the API. The failure was:\n"
|
|
118
|
+
);
|
|
113
119
|
}
|
|
114
120
|
throw error;
|
|
115
121
|
}
|
|
116
122
|
await writeCredentialsFile(endpoints.configDir, credentials);
|
|
123
|
+
const perms = (ctx.platform ?? process.platform) === "win32" ? "" : " (permissions 600)";
|
|
117
124
|
ctx.out(
|
|
118
|
-
`\u2713 Saved to ${join(endpoints.configDir, "config.json")}
|
|
125
|
+
`\u2713 Saved to ${join(endpoints.configDir, "config.json")}${perms}
|
|
119
126
|
|
|
120
127
|
Try: financy status
|
|
121
128
|
`
|
|
@@ -123,9 +130,9 @@ Try: financy status
|
|
|
123
130
|
return EXIT.OK;
|
|
124
131
|
}
|
|
125
132
|
function credentialsFromEnv(env) {
|
|
126
|
-
const clientId = env.FINANCY_CLIENT_ID;
|
|
127
|
-
const clientSecret = env.FINANCY_CLIENT_SECRET;
|
|
128
|
-
const userId = env.FINANCY_USER_ID;
|
|
133
|
+
const clientId = sanitizeCredential(env.FINANCY_CLIENT_ID ?? "");
|
|
134
|
+
const clientSecret = sanitizeCredential(env.FINANCY_CLIENT_SECRET ?? "");
|
|
135
|
+
const userId = sanitizeCredential(env.FINANCY_USER_ID ?? "");
|
|
129
136
|
if (!clientId || !clientSecret || !userId) {
|
|
130
137
|
throw new CliError(
|
|
131
138
|
EXIT.USAGE,
|
|
@@ -149,11 +156,13 @@ function maskId(v) {
|
|
|
149
156
|
}
|
|
150
157
|
async function configCommand(ctx) {
|
|
151
158
|
const endpoints = resolveEndpoints(ctx.env);
|
|
152
|
-
const
|
|
159
|
+
const fileResult = await readCredentialsFile(endpoints.configDir);
|
|
160
|
+
const file = fileResult.status === "ok" ? fileResult.credentials : {};
|
|
161
|
+
const fileStatus = fileResult.status === "ok" ? "ok" : fileResult.status === "missing" ? "not created yet \u2014 run financy setup" : `${fileResult.status}: ${fileResult.detail}`;
|
|
153
162
|
const resolve = (envKey, fileVal) => {
|
|
154
163
|
const envVal = ctx.env[envKey];
|
|
155
|
-
if (envVal) return { value: envVal, source: "env" };
|
|
156
|
-
if (fileVal) return { value: fileVal, source: "file" };
|
|
164
|
+
if (envVal) return { value: sanitizeCredential(envVal), source: "env" };
|
|
165
|
+
if (fileVal) return { value: sanitizeCredential(fileVal), source: "file" };
|
|
157
166
|
return { source: "unset" };
|
|
158
167
|
};
|
|
159
168
|
const clientId = resolve("FINANCY_CLIENT_ID", file.clientId);
|
|
@@ -164,6 +173,7 @@ async function configCommand(ctx) {
|
|
|
164
173
|
JSON.stringify(
|
|
165
174
|
{
|
|
166
175
|
configDir: endpoints.configDir,
|
|
176
|
+
configFileStatus: fileStatus,
|
|
167
177
|
authTokenUrl: endpoints.authTokenUrl,
|
|
168
178
|
apiBaseUrl: endpoints.apiBaseUrl,
|
|
169
179
|
chatBaseUrl: endpoints.chatBaseUrl,
|
|
@@ -171,6 +181,9 @@ async function configCommand(ctx) {
|
|
|
171
181
|
clientIdSource: clientId.source,
|
|
172
182
|
clientSecretSet: clientSecret.source !== "unset",
|
|
173
183
|
clientSecretSource: clientSecret.source,
|
|
184
|
+
// Length only — enough to spot a truncated or half-pasted secret, which
|
|
185
|
+
// is otherwise indistinguishable from a wrong one behind a 401.
|
|
186
|
+
clientSecretLength: clientSecret.value?.length ?? 0,
|
|
174
187
|
userId: userId.value ?? null,
|
|
175
188
|
userIdSource: userId.source
|
|
176
189
|
},
|
|
@@ -184,12 +197,13 @@ async function configCommand(ctx) {
|
|
|
184
197
|
"financy configuration",
|
|
185
198
|
"",
|
|
186
199
|
` config dir ${endpoints.configDir}`,
|
|
200
|
+
` config file ${fileStatus}`,
|
|
187
201
|
` auth url ${endpoints.authTokenUrl}`,
|
|
188
202
|
` api url ${endpoints.apiBaseUrl}`,
|
|
189
203
|
` chat url ${endpoints.chatBaseUrl}`,
|
|
190
204
|
"",
|
|
191
205
|
` clientId ${maskId(clientId.value)} (${clientId.source})`,
|
|
192
|
-
` clientSecret ${clientSecret.
|
|
206
|
+
` clientSecret ${clientSecret.value ? `\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (set, ${clientSecret.value.length} chars)` : "(unset)"} (${clientSecret.source})`,
|
|
193
207
|
` userId ${userId.value ?? "(unset)"} (${userId.source})`
|
|
194
208
|
];
|
|
195
209
|
ctx.out(lines.join("\n") + "\n");
|
|
@@ -293,13 +307,7 @@ function renderGet(ctx, envelope) {
|
|
|
293
307
|
// src/commands/read-commands.ts
|
|
294
308
|
async function loadOrThrow(env) {
|
|
295
309
|
const result = await loadConfig(env);
|
|
296
|
-
if (!result.ok)
|
|
297
|
-
throw new CliError(
|
|
298
|
-
EXIT.AUTH,
|
|
299
|
-
"NOT_CONFIGURED",
|
|
300
|
-
`missing ${result.missing.join(", ")} \u2014 run financy setup`
|
|
301
|
-
);
|
|
302
|
-
}
|
|
310
|
+
if (!result.ok) throw notConfigured(result);
|
|
303
311
|
return result.config;
|
|
304
312
|
}
|
|
305
313
|
var ils = (n) => {
|
|
@@ -533,26 +541,99 @@ async function exists(path) {
|
|
|
533
541
|
|
|
534
542
|
// src/prompt.ts
|
|
535
543
|
import { createInterface } from "readline";
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
rl.question(`${label}: `, (answer) => {
|
|
544
|
+
var CTRL_C = String.fromCharCode(3);
|
|
545
|
+
var CTRL_U = String.fromCharCode(21);
|
|
546
|
+
var ESC = String.fromCharCode(27);
|
|
547
|
+
var BACKSPACE = String.fromCharCode(8);
|
|
548
|
+
var DEL = String.fromCharCode(127);
|
|
549
|
+
var SIGINT_EXIT = 130;
|
|
550
|
+
var PASTE_HINT = "note: a keystroke that entered no text was ignored. If you meant to paste and\nnothing appeared, this console does not paste with Ctrl+V \u2014 use right-click or\nCtrl+Shift+V, or type the value.\n";
|
|
551
|
+
function readLine(prompt, input, output) {
|
|
552
|
+
if (prompt) output.write(prompt);
|
|
553
|
+
const rl = createInterface({ input, terminal: false });
|
|
554
|
+
return new Promise((resolve) => {
|
|
555
|
+
rl.question("", (answer) => {
|
|
550
556
|
rl.close();
|
|
551
|
-
|
|
552
|
-
resolve(answer.trim());
|
|
557
|
+
resolve(answer);
|
|
553
558
|
});
|
|
554
559
|
});
|
|
555
560
|
}
|
|
561
|
+
function readMasked(prompt, input, output) {
|
|
562
|
+
output.write(prompt);
|
|
563
|
+
input.setRawMode?.(true);
|
|
564
|
+
input.setEncoding("utf8");
|
|
565
|
+
input.resume();
|
|
566
|
+
return new Promise((resolve) => {
|
|
567
|
+
let value = "";
|
|
568
|
+
let ignoredControl = false;
|
|
569
|
+
let inEscape = false;
|
|
570
|
+
const cleanup = () => {
|
|
571
|
+
input.removeListener("data", onData);
|
|
572
|
+
input.setRawMode?.(false);
|
|
573
|
+
input.pause();
|
|
574
|
+
};
|
|
575
|
+
const erase = (count) => {
|
|
576
|
+
if (count > 0) output.write(`${BACKSPACE} ${BACKSPACE}`.repeat(count));
|
|
577
|
+
};
|
|
578
|
+
const onData = (chunk) => {
|
|
579
|
+
for (const ch of String(chunk)) {
|
|
580
|
+
if (inEscape) {
|
|
581
|
+
if (/[A-Za-z~]/.test(ch)) inEscape = false;
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (ch === "\r" || ch === "\n") {
|
|
585
|
+
cleanup();
|
|
586
|
+
output.write("\n");
|
|
587
|
+
if (ignoredControl) output.write(PASTE_HINT);
|
|
588
|
+
resolve(value);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (ch === CTRL_C) {
|
|
592
|
+
cleanup();
|
|
593
|
+
output.write("\n");
|
|
594
|
+
process.exit(SIGINT_EXIT);
|
|
595
|
+
}
|
|
596
|
+
if (ch === DEL || ch === BACKSPACE) {
|
|
597
|
+
if (value.length > 0) {
|
|
598
|
+
value = value.slice(0, -1);
|
|
599
|
+
erase(1);
|
|
600
|
+
}
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (ch === CTRL_U) {
|
|
604
|
+
erase(value.length);
|
|
605
|
+
value = "";
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (ch === ESC) {
|
|
609
|
+
inEscape = true;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (isControlChar(ch)) {
|
|
613
|
+
ignoredControl = true;
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
value += ch;
|
|
617
|
+
output.write("*");
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
input.on("data", onData);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
function isControlChar(ch) {
|
|
624
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
625
|
+
return code < 32 || code === 127;
|
|
626
|
+
}
|
|
627
|
+
function createDefaultPrompt(input = process.stdin, output = process.stdout) {
|
|
628
|
+
return async ({ label, secret }) => {
|
|
629
|
+
if (!secret) return sanitizeCredential(await readLine(`${label}: `, input, output));
|
|
630
|
+
if (!input.isTTY) {
|
|
631
|
+
const prompt = `${label} (not masked \u2014 this terminal cannot hide input): `;
|
|
632
|
+
return sanitizeCredential(await readLine(prompt, input, output));
|
|
633
|
+
}
|
|
634
|
+
return sanitizeCredential(await readMasked(`${label}: `, input, output));
|
|
635
|
+
};
|
|
636
|
+
}
|
|
556
637
|
|
|
557
638
|
// src/run.ts
|
|
558
639
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
@@ -635,13 +716,7 @@ async function run(argv, io = {}) {
|
|
|
635
716
|
let exitCode = EXIT.OK;
|
|
636
717
|
program.command("status").description("Are my connections fresh? One-line-per-bank rollup").option("--json", "machine-readable output (stable schema, errors as JSON on stderr)").action(async () => {
|
|
637
718
|
const result = await loadConfig(env);
|
|
638
|
-
if (!result.ok)
|
|
639
|
-
throw new CliError(
|
|
640
|
-
EXIT.AUTH,
|
|
641
|
-
"NOT_CONFIGURED",
|
|
642
|
-
`missing ${result.missing.join(", ")} \u2014 run financy setup`
|
|
643
|
-
);
|
|
644
|
-
}
|
|
719
|
+
if (!result.ok) throw notConfigured(result);
|
|
645
720
|
exitCode = await statusCommand({ config: result.config, json, now, out, err });
|
|
646
721
|
});
|
|
647
722
|
program.command("setup").description("Save your API credentials (Financy \u2192 Settings \u2192 API; requires a paid plan)").option("--no-input", "read credentials from FINANCY_* env vars (for agents/CI)").action(async (opts) => {
|
|
@@ -655,20 +730,14 @@ async function run(argv, io = {}) {
|
|
|
655
730
|
});
|
|
656
731
|
program.command("refresh").description("Trigger an on-demand refresh of all connections (20 credits)").option("--json", "machine-readable output").action(async () => {
|
|
657
732
|
const result = await loadConfig(env);
|
|
658
|
-
if (!result.ok)
|
|
659
|
-
throw new CliError(
|
|
660
|
-
EXIT.AUTH,
|
|
661
|
-
"NOT_CONFIGURED",
|
|
662
|
-
`missing ${result.missing.join(", ")} \u2014 run financy setup`
|
|
663
|
-
);
|
|
664
|
-
}
|
|
733
|
+
if (!result.ok) throw notConfigured(result);
|
|
665
734
|
exitCode = await refreshCommand({ config: result.config, json, now, out });
|
|
666
735
|
});
|
|
667
736
|
program.command("config").description("Show the resolved endpoints and credential sources (secret masked)").option("--json", "machine-readable output").action(async () => {
|
|
668
737
|
exitCode = await configCommand({ env, json, out });
|
|
669
738
|
});
|
|
670
739
|
program.command("mcp").description("Run the embedded MCP server (stdio) exposing the CLI as agent tools").action(async () => {
|
|
671
|
-
const { startMcpServer } = await import("./server-
|
|
740
|
+
const { startMcpServer } = await import("./server-FZPZL65M.js");
|
|
672
741
|
await startMcpServer(env);
|
|
673
742
|
});
|
|
674
743
|
const skills = program.command("skills").description("Agent skills bundled with financy (Claude Code and other agents)");
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "financy",
|
|
6
|
-
version: "0.1.
|
|
6
|
+
version: "0.1.3",
|
|
7
7
|
description: "Your Open-Finance accounts, balances and transactions in the terminal \u2014 with an embedded MCP server and agent skills",
|
|
8
8
|
type: "module",
|
|
9
9
|
bin: {
|
|
@@ -71,10 +71,64 @@ var package_default = {
|
|
|
71
71
|
var VERSION = package_default.version;
|
|
72
72
|
var USER_AGENT = `financy-cli/${VERSION}`;
|
|
73
73
|
|
|
74
|
+
// src/exit-codes.ts
|
|
75
|
+
var EXIT = {
|
|
76
|
+
OK: 0,
|
|
77
|
+
UNEXPECTED: 1,
|
|
78
|
+
USAGE: 2,
|
|
79
|
+
AUTH: 3,
|
|
80
|
+
PLAN: 4,
|
|
81
|
+
CREDITS: 5,
|
|
82
|
+
NOT_FOUND: 6,
|
|
83
|
+
API: 7
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// src/errors.ts
|
|
87
|
+
var CliError = class extends Error {
|
|
88
|
+
code;
|
|
89
|
+
exitCode;
|
|
90
|
+
extra;
|
|
91
|
+
constructor(exitCode, code, message, extra = {}) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.name = "CliError";
|
|
94
|
+
this.code = code;
|
|
95
|
+
this.exitCode = exitCode;
|
|
96
|
+
this.extra = extra;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
var authFailed = (message = "authentication failed \u2014 check your credentials or run financy setup") => new CliError(EXIT.AUTH, "AUTH_FAILED", message);
|
|
100
|
+
var planNotEligible = () => new CliError(
|
|
101
|
+
EXIT.PLAN,
|
|
102
|
+
"NOT_AVAILABLE_ON_PLAN",
|
|
103
|
+
"the API is available on Starter and Pro plans \u2014 upgrade in Financy \u2192 Settings \u2192 Plan"
|
|
104
|
+
);
|
|
105
|
+
var forbidden = (detail) => new CliError(EXIT.PLAN, "FORBIDDEN", `the API denied this request (403): ${detail}`);
|
|
106
|
+
var insufficientCredits = (cost, balance) => new CliError(
|
|
107
|
+
EXIT.CREDITS,
|
|
108
|
+
"INSUFFICIENT_CREDITS",
|
|
109
|
+
`an initiated refresh costs ${cost} credits; you have ${balance}`,
|
|
110
|
+
{ cost, balance }
|
|
111
|
+
);
|
|
112
|
+
var noRefreshableConnections = (message = "no connections are eligible for refresh") => new CliError(EXIT.UNEXPECTED, "NO_REFRESHABLE_CONNECTIONS", message);
|
|
113
|
+
var apiUnavailable = (message = "the Open-Finance API is unavailable \u2014 check your connection and try again") => new CliError(EXIT.API, "API_UNAVAILABLE", message);
|
|
114
|
+
|
|
74
115
|
// src/config.ts
|
|
75
116
|
import { homedir } from "os";
|
|
76
117
|
import { join } from "path";
|
|
77
118
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
119
|
+
|
|
120
|
+
// src/credentials.ts
|
|
121
|
+
function isControl(ch) {
|
|
122
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
123
|
+
return code < 32 || code === 127;
|
|
124
|
+
}
|
|
125
|
+
function sanitizeCredential(raw) {
|
|
126
|
+
const stripped = Array.from(raw).filter((ch) => !isControl(ch)).join("").trim();
|
|
127
|
+
const quoted = /^(["'])(.*)\1$/.exec(stripped);
|
|
128
|
+
return (quoted?.[2] ?? stripped).trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/config.ts
|
|
78
132
|
var DEFAULT_AUTH_TOKEN_URL = "https://api.open-finance.ai/oauth/token";
|
|
79
133
|
var DEFAULT_API_BASE_URL = "https://api.open-finance.ai/v2";
|
|
80
134
|
var DEFAULT_CHAT_BASE_URL = "https://api.open-finance.ai/chat/chat";
|
|
@@ -94,14 +148,39 @@ function resolveEndpoints(env) {
|
|
|
94
148
|
function configFile(configDir) {
|
|
95
149
|
return join(configDir, "config.json");
|
|
96
150
|
}
|
|
151
|
+
function decodeConfigBytes(bytes) {
|
|
152
|
+
if (bytes[0] === 255 && bytes[1] === 254) return bytes.subarray(2).toString("utf16le");
|
|
153
|
+
if (bytes[0] === 254 && bytes[1] === 255) {
|
|
154
|
+
return Buffer.from(bytes.subarray(2)).swap16().toString("utf16le");
|
|
155
|
+
}
|
|
156
|
+
if (bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) {
|
|
157
|
+
return bytes.subarray(3).toString("utf8");
|
|
158
|
+
}
|
|
159
|
+
return bytes.toString("utf8");
|
|
160
|
+
}
|
|
97
161
|
async function readCredentialsFile(configDir) {
|
|
162
|
+
let bytes;
|
|
98
163
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return
|
|
164
|
+
bytes = await readFile(configFile(configDir));
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const code = error.code;
|
|
167
|
+
if (code === "ENOENT") return { status: "missing" };
|
|
168
|
+
return { status: "unreadable", detail: code ?? String(error) };
|
|
104
169
|
}
|
|
170
|
+
let parsed;
|
|
171
|
+
try {
|
|
172
|
+
parsed = JSON.parse(decodeConfigBytes(bytes));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
return { status: "malformed", detail: `not valid JSON (${error.message})` };
|
|
175
|
+
}
|
|
176
|
+
const credentials = parsed?.profiles?.default;
|
|
177
|
+
if (!credentials || typeof credentials !== "object") {
|
|
178
|
+
return {
|
|
179
|
+
status: "malformed",
|
|
180
|
+
detail: 'no "profiles.default" object \u2014 expected {"profiles":{"default":{\u2026}}}'
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return { status: "ok", credentials };
|
|
105
184
|
}
|
|
106
185
|
async function writeCredentialsFile(configDir, credentials) {
|
|
107
186
|
await mkdir(configDir, { recursive: true, mode: 448 });
|
|
@@ -111,63 +190,38 @@ async function writeCredentialsFile(configDir, credentials) {
|
|
|
111
190
|
{ mode: 384 }
|
|
112
191
|
);
|
|
113
192
|
}
|
|
193
|
+
function pick(envValue, fileValue) {
|
|
194
|
+
const raw = envValue ?? fileValue;
|
|
195
|
+
if (raw === void 0) return void 0;
|
|
196
|
+
const value = sanitizeCredential(raw);
|
|
197
|
+
return value === "" ? void 0 : value;
|
|
198
|
+
}
|
|
114
199
|
async function loadConfig(env) {
|
|
115
200
|
const endpoints = resolveEndpoints(env);
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const
|
|
201
|
+
const result = await readCredentialsFile(endpoints.configDir);
|
|
202
|
+
const file = result.status === "ok" ? result.credentials : {};
|
|
203
|
+
const fileProblem = result.status === "missing" || result.status === "ok" ? void 0 : `${configFile(endpoints.configDir)} could not be read: ${result.detail}`;
|
|
204
|
+
const clientId = pick(env.FINANCY_CLIENT_ID, file.clientId);
|
|
205
|
+
const clientSecret = pick(env.FINANCY_CLIENT_SECRET, file.clientSecret);
|
|
206
|
+
const userId = pick(env.FINANCY_USER_ID, file.userId);
|
|
120
207
|
const missing = [];
|
|
121
208
|
if (!clientId) missing.push("FINANCY_CLIENT_ID");
|
|
122
209
|
if (!clientSecret) missing.push("FINANCY_CLIENT_SECRET");
|
|
123
210
|
if (!userId) missing.push("FINANCY_USER_ID");
|
|
124
|
-
if (!clientId || !clientSecret || !userId) return { ok: false, missing };
|
|
211
|
+
if (!clientId || !clientSecret || !userId) return { ok: false, missing, fileProblem };
|
|
125
212
|
return {
|
|
126
213
|
ok: true,
|
|
127
214
|
config: { clientId, clientSecret, userId, ...endpoints }
|
|
128
215
|
};
|
|
129
216
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
CREDITS: 5,
|
|
139
|
-
NOT_FOUND: 6,
|
|
140
|
-
API: 7
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
// src/errors.ts
|
|
144
|
-
var CliError = class extends Error {
|
|
145
|
-
code;
|
|
146
|
-
exitCode;
|
|
147
|
-
extra;
|
|
148
|
-
constructor(exitCode, code, message, extra = {}) {
|
|
149
|
-
super(message);
|
|
150
|
-
this.name = "CliError";
|
|
151
|
-
this.code = code;
|
|
152
|
-
this.exitCode = exitCode;
|
|
153
|
-
this.extra = extra;
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
var authFailed = (message = "authentication failed \u2014 check your credentials or run financy setup") => new CliError(EXIT.AUTH, "AUTH_FAILED", message);
|
|
157
|
-
var planNotEligible = () => new CliError(
|
|
158
|
-
EXIT.PLAN,
|
|
159
|
-
"NOT_AVAILABLE_ON_PLAN",
|
|
160
|
-
"the API is available on Starter and Pro plans \u2014 upgrade in Financy \u2192 Settings \u2192 Plan"
|
|
161
|
-
);
|
|
162
|
-
var forbidden = (detail) => new CliError(EXIT.PLAN, "FORBIDDEN", `the API denied this request (403): ${detail}`);
|
|
163
|
-
var insufficientCredits = (cost, balance) => new CliError(
|
|
164
|
-
EXIT.CREDITS,
|
|
165
|
-
"INSUFFICIENT_CREDITS",
|
|
166
|
-
`an initiated refresh costs ${cost} credits; you have ${balance}`,
|
|
167
|
-
{ cost, balance }
|
|
168
|
-
);
|
|
169
|
-
var noRefreshableConnections = (message = "no connections are eligible for refresh") => new CliError(EXIT.UNEXPECTED, "NO_REFRESHABLE_CONNECTIONS", message);
|
|
170
|
-
var apiUnavailable = (message = "the Open-Finance API is unavailable \u2014 check your connection and try again") => new CliError(EXIT.API, "API_UNAVAILABLE", message);
|
|
217
|
+
function notConfigured(result) {
|
|
218
|
+
const suffix = result.fileProblem ? ` \u2014 ${result.fileProblem}` : "";
|
|
219
|
+
return new CliError(
|
|
220
|
+
EXIT.AUTH,
|
|
221
|
+
"NOT_CONFIGURED",
|
|
222
|
+
`missing ${result.missing.join(", ")} \u2014 run financy setup${suffix}`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
171
225
|
|
|
172
226
|
// src/http.ts
|
|
173
227
|
async function httpFetch(url, init) {
|
|
@@ -468,13 +522,15 @@ function fetchBranches(config, now) {
|
|
|
468
522
|
}
|
|
469
523
|
|
|
470
524
|
export {
|
|
525
|
+
sanitizeCredential,
|
|
526
|
+
EXIT,
|
|
527
|
+
CliError,
|
|
471
528
|
resolveEndpoints,
|
|
472
529
|
readCredentialsFile,
|
|
473
530
|
writeCredentialsFile,
|
|
474
531
|
loadConfig,
|
|
532
|
+
notConfigured,
|
|
475
533
|
VERSION,
|
|
476
|
-
EXIT,
|
|
477
|
-
CliError,
|
|
478
534
|
getConnections,
|
|
479
535
|
mintToken,
|
|
480
536
|
STALE_THRESHOLD_DAYS,
|
|
@@ -14,8 +14,9 @@ import {
|
|
|
14
14
|
fetchStatus,
|
|
15
15
|
fetchTransaction,
|
|
16
16
|
fetchTransactions,
|
|
17
|
-
loadConfig
|
|
18
|
-
|
|
17
|
+
loadConfig,
|
|
18
|
+
notConfigured
|
|
19
|
+
} from "./chunk-YOW6MO6F.js";
|
|
19
20
|
|
|
20
21
|
// src/mcp/server.ts
|
|
21
22
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -162,13 +163,7 @@ async function callTool(name, args, io) {
|
|
|
162
163
|
throw new CliError(EXIT.USAGE, "UNKNOWN_TOOL", `no tool named '${name}'`);
|
|
163
164
|
}
|
|
164
165
|
const result = await loadConfig(io.env);
|
|
165
|
-
if (!result.ok)
|
|
166
|
-
throw new CliError(
|
|
167
|
-
EXIT.AUTH,
|
|
168
|
-
"NOT_CONFIGURED",
|
|
169
|
-
`missing ${result.missing.join(", ")} \u2014 run financy setup`
|
|
170
|
-
);
|
|
171
|
-
}
|
|
166
|
+
if (!result.ok) throw notConfigured(result);
|
|
172
167
|
return await tool.run(args ?? {}, { config: result.config, now: io.now ?? /* @__PURE__ */ new Date() });
|
|
173
168
|
} catch (error) {
|
|
174
169
|
return errorEnvelope(error);
|
package/package.json
CHANGED
|
@@ -30,6 +30,9 @@ These are not optional and they apply to every step below.
|
|
|
30
30
|
pastes it anyway, do not repeat it, do not use it in a command, and tell them
|
|
31
31
|
to rotate it in Financy → Settings → API. Never print it, read it back for
|
|
32
32
|
confirmation, or include it in a summary, and never `cat` the config file.
|
|
33
|
+
If `setup` warns that the input is **not masked** (a terminal where Node cannot
|
|
34
|
+
hide input, e.g. Git Bash / mintty, where stdin is a pipe), tell the user to run
|
|
35
|
+
it in PowerShell or Windows Terminal instead of typing the secret in the clear.
|
|
33
36
|
- **Confirm before spending credits.** `financy refresh` costs 20 credits per
|
|
34
37
|
run. Never run it without the user explicitly agreeing to that cost in this
|
|
35
38
|
conversation.
|
|
@@ -95,7 +98,18 @@ where to find the three values: the Financy app → **Settings → API**.
|
|
|
95
98
|
|
|
96
99
|
`setup` validates the credentials against the live API before saving, so a wrong
|
|
97
100
|
value fails immediately rather than on their first real command. On success it
|
|
98
|
-
writes `~/.config/financy/config.json`
|
|
101
|
+
writes `~/.config/financy/config.json` and prints the path — permissions `600` on
|
|
102
|
+
macOS/Linux; on Windows the path is
|
|
103
|
+
`%USERPROFILE%\.config\financy\config.json` and file modes are not enforced.
|
|
104
|
+
|
|
105
|
+
A **failed** `setup` writes nothing at all. If the user believes they entered the
|
|
106
|
+
credentials but `financy config` shows them unset, setup did not get past
|
|
107
|
+
validation — do not go looking for a file-reading problem.
|
|
108
|
+
|
|
109
|
+
On Windows the secret prompt prints one `*` per character. If the user says they
|
|
110
|
+
pasted and saw no `*`s, their console did not paste: the legacy Windows console
|
|
111
|
+
ignores **Ctrl+V** at a prompt. Tell them to use **right-click** or
|
|
112
|
+
**Ctrl+Shift+V**, or to type the value.
|
|
99
113
|
|
|
100
114
|
Ask the user to tell you the exit code or what it printed — that is all you need.
|
|
101
115
|
Do not ask them to paste any of the three values.
|
|
@@ -103,8 +117,12 @@ Do not ask them to paste any of the three values.
|
|
|
103
117
|
Exit codes from `setup`:
|
|
104
118
|
|
|
105
119
|
- `0` — saved and verified.
|
|
106
|
-
- `3` — the credentials were rejected. Ask them to run
|
|
107
|
-
re-copy the values; a partial paste
|
|
120
|
+
- `3` — the credentials were rejected, and nothing was saved. Ask them to run
|
|
121
|
+
`financy setup` again and re-copy the values; a partial paste is the usual
|
|
122
|
+
cause (surrounding whitespace and wrapping quotes are stripped automatically).
|
|
123
|
+
Have them check that `Client ID` and `User ID` did not go into each other's
|
|
124
|
+
prompt — `financy config` shows the `userId` in the clear, so a swap is visible
|
|
125
|
+
there.
|
|
108
126
|
- `4` — the credentials are valid but the plan is not eligible. The CLI still
|
|
109
127
|
saves them so a later upgrade works immediately. Go to *Handling exit 4*.
|
|
110
128
|
- `7` — the API could not be reached. This is a network or endpoint problem, not
|
|
@@ -160,8 +178,11 @@ the secret:
|
|
|
160
178
|
financy config
|
|
161
179
|
```
|
|
162
180
|
|
|
163
|
-
It prints the resolved endpoints
|
|
164
|
-
|
|
181
|
+
It prints the resolved endpoints, the state of the config file (`ok`, `missing`,
|
|
182
|
+
`malformed`, `unreadable`), and marks each credential as coming from the
|
|
183
|
+
environment or the config file. The secret is never printed — only its length,
|
|
184
|
+
which is enough to spot a truncated paste. This is the first command to run when a
|
|
185
|
+
user reports a `401`.
|
|
165
186
|
|
|
166
187
|
## MCP alternative
|
|
167
188
|
|