actual-jev 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.
package/dist/cli.js ADDED
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ import * as actual from '@actual-app/api';
3
+ import { TypeSafeClient } from '@typesafe-ai/sdk';
4
+ import search from '@inquirer/search';
5
+ import { input, password as passwordPrompt, confirm } from '@inquirer/prompts';
6
+ import { stdin, stdout } from 'node:process';
7
+ import { mkdir } from 'node:fs/promises';
8
+ import { createActualClassifier } from './actual.js';
9
+ import { categoryChoices } from './choices.js';
10
+ import { runCategorization } from './workflow.js';
11
+ import { parseArgs } from './args.js';
12
+ import { budgetDataDir, configSummary, missingSettings, readConfig, resolveConfig, userPaths } from './config.js';
13
+ import { runSetup } from './setup.js';
14
+ import { resolve } from 'node:path';
15
+ export { parseArgs } from './args.js';
16
+ function usage() {
17
+ return `Usage: actual-jev [--interactive | --auto | --dry-run] [options]
18
+ actual-jev setup
19
+ actual-jev config show [--env-file PATH]
20
+
21
+ Run setup once to save your connection settings. config show hides secrets.
22
+
23
+ Modes: --interactive (default), --auto, --dry-run
24
+ Interactive: type to search grouped categories, use arrow keys to move,
25
+ and press Enter to select the highlighted category or Skip.
26
+ Options:
27
+ --threshold NUMBER Minimum confidence for auto/dry-run (default: 0.9)
28
+ --max-examples-per-category NUMBER Historical examples per category, 0–100 (default: 3)
29
+ --account ID_OR_NAME Limit to one account
30
+ --from YYYY-MM-DD Inclusive start date
31
+ --to YYYY-MM-DD Inclusive end date
32
+ --data-dir PATH Override the user Actual cache directory
33
+ --env-file PATH Read environment settings from a file
34
+ --help
35
+
36
+ Use setup for saved settings, or supply all connection settings through
37
+ exported environment variables and/or --env-file:
38
+ ACTUAL_SERVER_URL, ACTUAL_PASSWORD, ACTUAL_SYNC_ID, TYPESAFE_API_KEY
39
+ ACTUAL_ENCRYPTION_PASSWORD (only for encrypted budgets)
40
+
41
+ Environment configuration never uses saved settings. Exported variables
42
+ win over values in --env-file. No .env file is loaded automatically.
43
+ Use command flags for run options. The saved maxExamplesPerCategory setting
44
+ provides the example limit for saved configuration; the flag overrides it.`;
45
+ }
46
+ async function main() {
47
+ const options = parseArgs(process.argv.slice(2));
48
+ if (options.help) {
49
+ console.log(usage());
50
+ return;
51
+ }
52
+ const paths = userPaths();
53
+ const setup = async () => {
54
+ if (!stdin.isTTY || !stdout.isTTY)
55
+ throw new Error('Setup requires a terminal. Use environment variables for unattended runs.');
56
+ await runSetup(await readConfig(paths.configFile), paths.configFile, {
57
+ input: (message, initial) => input({ message, default: initial, validate: (value) => Boolean(value.trim()) || 'Enter a value' }),
58
+ secret: (message) => passwordPrompt({ message, mask: '*', validate: (value) => Boolean(value.trim()) || 'Enter a value' }),
59
+ select: (message, choices, initial) => search({
60
+ message,
61
+ default: initial,
62
+ source: (term) => choices.filter((choice) => choice.name.toLowerCase().includes((term ?? '').toLowerCase())),
63
+ }),
64
+ print: (line) => console.log(line),
65
+ actual,
66
+ });
67
+ };
68
+ if (options.command === 'setup') {
69
+ await setup();
70
+ return;
71
+ }
72
+ const { config, source } = await resolveConfig(paths.configFile, process.env, options.envFile);
73
+ const maxExamplesPerCategory = options.maxExamplesPerCategory ?? config.maxExamplesPerCategory ?? 3;
74
+ const dataDir = options.dataDir ? resolve(options.dataDir) : budgetDataDir(paths.dataRoot, config);
75
+ if (options.command === 'config-show') {
76
+ console.log([
77
+ `Configuration: ${source}${source === 'saved configuration' ? ` (${paths.configFile})` : ''}`,
78
+ `Actual data: ${dataDir} (${options.dataDir ? '--data-dir' : 'default'})`,
79
+ ...configSummary(config),
80
+ `threshold: ${options.threshold}`,
81
+ `maxExamplesPerCategory: ${maxExamplesPerCategory}`,
82
+ ].join('\n'));
83
+ return;
84
+ }
85
+ const missing = missingSettings(config);
86
+ if (missing.length) {
87
+ if (source === 'saved configuration' &&
88
+ options.mode === 'interactive' &&
89
+ stdin.isTTY &&
90
+ stdout.isTTY &&
91
+ (await confirm({ message: 'Configuration is incomplete. Start setup?', default: true }))) {
92
+ await setup();
93
+ return;
94
+ }
95
+ throw new Error(`Missing configuration: ${missing.join(', ')}. ${source === 'environment' ? 'Provide all connection settings through environment variables or --env-file (see --help). Saved settings are not used.' : 'Run actual-jev setup.'}`);
96
+ }
97
+ const { serverURL, password, syncId } = config;
98
+ if (options.mode === 'interactive' && (!stdin.isTTY || !stdout.isTTY))
99
+ throw new Error('Interactive mode requires a terminal; use --dry-run or --auto');
100
+ let initialized = false;
101
+ let writes = false;
102
+ try {
103
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
104
+ await actual.init({ serverURL: serverURL, password: password, dataDir, verbose: false });
105
+ initialized = true;
106
+ await actual.downloadBudget(syncId, { password: config.encryptionPassword });
107
+ await actual.sync();
108
+ const [accounts, payees, queryResult] = await Promise.all([
109
+ actual.getAccounts(),
110
+ actual.getPayees(),
111
+ actual.aqlQuery(actual.q('transactions').select('*').options({ splits: 'grouped' })),
112
+ ]);
113
+ const transactions = queryResult.data;
114
+ if (!Array.isArray(transactions))
115
+ throw new Error('ActualQL did not return transaction rows');
116
+ const jev = new TypeSafeClient({ apiKey: config.apiKey });
117
+ const classifier = await createActualClassifier(actual, {
118
+ client: jev,
119
+ maxExamplesPerCategory,
120
+ history: transactions,
121
+ eligibleAccountIds: new Set(accounts.filter((account) => !account.offbudget).map((account) => account.id)),
122
+ });
123
+ const transferPayeeAccountIds = new Map(payees.flatMap((payee) => (payee.transfer_acct ? [[payee.id, payee.transfer_acct]] : [])));
124
+ const categories = classifier.categories;
125
+ const summary = await runCategorization({
126
+ accounts,
127
+ transactions,
128
+ transferPayeeAccountIds,
129
+ payeeNames: new Map(payees.map((payee) => [payee.id, payee.name])),
130
+ categories,
131
+ classify: (transaction) => classifier.classify(transaction),
132
+ updateTransaction: async (id, fields) => {
133
+ await actual.updateTransaction(id, fields);
134
+ writes = true;
135
+ },
136
+ print: (line) => console.log(line),
137
+ color: Boolean(stdout.isTTY && !('NO_COLOR' in process.env)),
138
+ choose: options.mode === 'interactive'
139
+ ? async (_transaction, result) => search({
140
+ message: 'Category (type to search, arrows to move, Enter to select)',
141
+ source: (term) => categoryChoices(categories, term),
142
+ default: result.categoryId,
143
+ pageSize: Math.max(7, Math.min((stdout.rows ?? 20) - 6, 20)),
144
+ })
145
+ : undefined,
146
+ }, options);
147
+ console.log(`Examined ${summary.examined}; applied ${summary.applied}; would apply ${summary.wouldApply}; skipped ${summary.skipped}; transfers skipped ${summary.transfersSkipped}.`);
148
+ }
149
+ finally {
150
+ if (initialized) {
151
+ try {
152
+ if (writes)
153
+ await actual.sync();
154
+ }
155
+ finally {
156
+ await actual.shutdown();
157
+ }
158
+ }
159
+ }
160
+ }
161
+ main().catch((error) => {
162
+ if (error instanceof Error && error.name === 'ExitPromptError') {
163
+ console.error('Setup or selection cancelled.');
164
+ process.exitCode = 130;
165
+ return;
166
+ }
167
+ console.error(error instanceof Error ? error.message : String(error));
168
+ process.exitCode = 1;
169
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,130 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { spawn } from 'node:child_process';
4
+ import { openSync, closeSync } from 'node:fs';
5
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { saveConfig, userPaths } from './config.js';
10
+ void test('CLI works outside the project with saved settings and fixture clients', async (t) => {
11
+ const root = await mkdtemp(join(tmpdir(), 'jev-cli-'));
12
+ t.after(() => rm(root, { recursive: true, force: true }));
13
+ const first = join(root, 'first');
14
+ const second = join(root, 'second');
15
+ await Promise.all([mkdir(first), mkdir(second)]);
16
+ // Deliberately do not inherit any Actual or TypeSafe credentials from the environment.
17
+ const env = {
18
+ PATH: process.env.PATH,
19
+ HOME: root,
20
+ USERPROFILE: root,
21
+ XDG_CONFIG_HOME: join(root, 'config'),
22
+ XDG_DATA_HOME: join(root, 'data'),
23
+ APPDATA: join(root, 'roaming'),
24
+ LOCALAPPDATA: join(root, 'local'),
25
+ FIXTURE_LOG: join(root, 'calls.json'),
26
+ };
27
+ const hooks = join(root, 'hooks.mjs');
28
+ await writeFile(hooks, `import { registerHooks } from 'node:module';
29
+ import { readFileSync } from 'node:fs';
30
+ import ts from ${JSON.stringify(import.meta.resolve('typescript'))};
31
+ const actual = ${JSON.stringify(`
32
+ import { writeFile } from 'node:fs/promises';
33
+ export async function init(options) {
34
+ if (options.password !== 'fixture-password') throw new Error('Incorrect resolved password');
35
+ await writeFile(process.env.FIXTURE_LOG, JSON.stringify(options));
36
+ }
37
+ export async function downloadBudget(id) { if (id !== 'fixture-budget') throw new Error('Incorrect budget'); }
38
+ export async function sync() {}
39
+ export async function shutdown() {}
40
+ export async function getAccounts() { return []; }
41
+ export async function getPayees() { return []; }
42
+ export async function getCategoryGroups() { return []; }
43
+ export async function getNote() { return {}; }
44
+ export async function aqlQuery() { return { data: [] }; }
45
+ export function q() { return { select() { return this; }, options() { return this; } }; }
46
+ export async function updateTransaction() { throw new Error('Unexpected write'); }
47
+ `)};
48
+ const sdk = "export function choice() { throw new Error('Unexpected classification'); } export class TypeSafeClient { constructor(config) { if (config.apiKey !== 'fixture-key') throw new Error('Incorrect resolved API key'); } }";
49
+ registerHooks({ resolve(specifier, context, next) {
50
+ if (specifier === '@actual-app/api' || specifier === '@typesafe-ai/sdk') return { url: 'data:text/javascript,' + encodeURIComponent(specifier === '@actual-app/api' ? actual : sdk), shortCircuit: true };
51
+ if (context.parentURL?.endsWith('.ts') && specifier.startsWith('./') && specifier.endsWith('.js')) return next(specifier.slice(0, -3) + '.ts', context);
52
+ return next(specifier, context);
53
+ }, load(url, context, next) {
54
+ if (url.endsWith('.ts')) return { format: 'module', source: ts.transpileModule(readFileSync(new URL(url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2024 } }).outputText, shortCircuit: true };
55
+ return next(url, context);
56
+ } });`);
57
+ const entry = process.env.JEV_TEST_CLI_ENTRY ?? fileURLToPath(new URL('./cli.ts', import.meta.url));
58
+ const run = async (args, cwd = first, connection = {}) => {
59
+ const outputPath = join(root, 'stdout');
60
+ const errorPath = join(root, 'stderr');
61
+ const out = openSync(outputPath, 'w');
62
+ const err = openSync(errorPath, 'w');
63
+ let code;
64
+ try {
65
+ code = await new Promise((resolve, reject) => {
66
+ const child = spawn(process.execPath, ['--import', hooks, entry, ...args], {
67
+ cwd,
68
+ env: { ...env, ...connection },
69
+ stdio: ['ignore', out, err],
70
+ });
71
+ child.once('error', reject);
72
+ child.once('close', resolve);
73
+ });
74
+ }
75
+ finally {
76
+ closeSync(out);
77
+ closeSync(err);
78
+ }
79
+ const stdout = await readFile(outputPath, 'utf8');
80
+ const stderr = await readFile(errorPath, 'utf8');
81
+ if (code !== 0)
82
+ throw new Error(stderr || `CLI exited with ${code}`);
83
+ return { stdout, stderr };
84
+ };
85
+ const help = await run(['--help']);
86
+ assert.match(help.stdout, /actual-jev setup/);
87
+ await assert.rejects(run(['--dry-run']), (error) => error instanceof Error && error.message.includes('Missing configuration'));
88
+ await assert.rejects(run(['setup']), (error) => error instanceof Error && error.message.includes('requires a terminal'));
89
+ await saveConfig(userPaths(env, process.platform, root).configFile, {
90
+ version: 1,
91
+ serverURL: 'https://fixture.example',
92
+ password: 'fixture-password',
93
+ syncId: 'fixture-budget',
94
+ apiKey: 'fixture-key',
95
+ maxExamplesPerCategory: 2,
96
+ });
97
+ await writeFile(join(first, '.env'), 'ACTUAL_PASSWORD=wrong\nTYPESAFE_API_KEY=wrong');
98
+ const output = (await run(['--dry-run'])).stdout;
99
+ assert.match(output, /Examined 0; applied 0/);
100
+ const firstLog = await readFile(env.FIXTURE_LOG, 'utf8');
101
+ await run(['--dry-run'], second);
102
+ assert.equal(await readFile(env.FIXTURE_LOG, 'utf8'), firstLog);
103
+ const summary = (await run(['config', 'show'])).stdout;
104
+ assert.match(summary, /saved configuration/);
105
+ assert.ok(!summary.includes('fixture-password') && !summary.includes('fixture-key'));
106
+ assert.match(summary, /maxExamplesPerCategory: 2/);
107
+ const flags = (await run(['config', 'show', '--threshold', '0.6', '--max-examples-per-category', '0'])).stdout;
108
+ assert.match(flags, /threshold: 0.6/);
109
+ assert.match(flags, /maxExamplesPerCategory: 0/);
110
+ const connection = {
111
+ ACTUAL_SERVER_URL: 'https://fixture.example',
112
+ ACTUAL_PASSWORD: 'fixture-password',
113
+ ACTUAL_SYNC_ID: 'fixture-budget',
114
+ TYPESAFE_API_KEY: 'fixture-key',
115
+ };
116
+ await assert.rejects(run(['--dry-run'], first, { TYPESAFE_API_KEY: 'fixture-key' }), /Saved settings are not used/);
117
+ const envFile = join(root, 'automation.env');
118
+ await writeFile(envFile, Object.entries(connection)
119
+ .map(([key, value]) => `${key}=${value}`)
120
+ .join('\n'));
121
+ // Automation does not even read the personal config, including when it is malformed.
122
+ await writeFile(userPaths(env, process.platform, root).configFile, 'invalid JSON');
123
+ assert.match((await run(['--dry-run'], first, connection)).stdout, /Examined 0; applied 0/);
124
+ assert.match((await run(['--env-file', envFile, '--dry-run'])).stdout, /Examined 0; applied 0/);
125
+ const exportedSummary = (await run(['config', 'show'], first, connection)).stdout;
126
+ const fileSummary = (await run(['config', 'show', '--env-file', envFile])).stdout;
127
+ assert.equal(fileSummary, exportedSummary);
128
+ assert.match(fileSummary, /Configuration: environment/);
129
+ assert.match(fileSummary, /maxExamplesPerCategory: 3/);
130
+ });
@@ -0,0 +1,28 @@
1
+ export interface Config {
2
+ version: 1;
3
+ serverURL?: string;
4
+ password?: string;
5
+ syncId?: string;
6
+ encryptionPassword?: string;
7
+ apiKey?: string;
8
+ maxExamplesPerCategory?: number;
9
+ }
10
+ export type Environment = Record<string, string | undefined>;
11
+ export declare function userPaths(env?: Environment, platform?: NodeJS.Platform, home?: string): {
12
+ configFile: string;
13
+ dataRoot: string;
14
+ };
15
+ export declare function validateConfig(value: unknown): Config;
16
+ export declare function readConfig(path: string): Promise<Config>;
17
+ export declare function saveConfig(path: string, config: Config): Promise<void>;
18
+ /** Environment configuration is complete on its own and never reads saved credentials. */
19
+ export declare function resolveConfig(configFile: string, env: Environment, envFile?: string): Promise<{
20
+ config: Config;
21
+ source: 'saved configuration';
22
+ } | {
23
+ config: Config;
24
+ source: 'environment';
25
+ }>;
26
+ export declare function missingSettings(config: Config): string[];
27
+ export declare function budgetDataDir(root: string, config: Config): string;
28
+ export declare function configSummary(config: Config): string[];
package/dist/config.js ADDED
@@ -0,0 +1,135 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import { parseEnv } from 'node:util';
6
+ export function userPaths(env = process.env, platform = process.platform, home = homedir()) {
7
+ const configRoot = platform === 'win32'
8
+ ? (env.APPDATA ?? join(home, 'AppData', 'Roaming'))
9
+ : platform === 'darwin'
10
+ ? join(home, 'Library', 'Application Support')
11
+ : env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME)
12
+ ? env.XDG_CONFIG_HOME
13
+ : join(home, '.config');
14
+ const dataRoot = platform === 'win32'
15
+ ? (env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'))
16
+ : platform === 'darwin'
17
+ ? join(home, 'Library', 'Application Support')
18
+ : env.XDG_DATA_HOME && isAbsolute(env.XDG_DATA_HOME)
19
+ ? env.XDG_DATA_HOME
20
+ : join(home, '.local', 'share');
21
+ return {
22
+ configFile: join(configRoot, 'actual-jev', 'config.json'),
23
+ dataRoot: join(dataRoot, 'actual-jev', 'budgets'),
24
+ };
25
+ }
26
+ export function validateConfig(value) {
27
+ if (!value || typeof value !== 'object' || Array.isArray(value))
28
+ throw new Error('Configuration must be an object');
29
+ const raw = value;
30
+ if (raw.version !== 1)
31
+ throw new Error('Unsupported configuration version');
32
+ const allowed = [
33
+ 'version',
34
+ 'serverURL',
35
+ 'password',
36
+ 'syncId',
37
+ 'encryptionPassword',
38
+ 'apiKey',
39
+ 'maxExamplesPerCategory',
40
+ ];
41
+ for (const key of Object.keys(raw))
42
+ if (!allowed.includes(key))
43
+ throw new Error(`Unknown configuration setting: ${key}`);
44
+ for (const key of ['serverURL', 'password', 'syncId', 'encryptionPassword', 'apiKey']) {
45
+ if (raw[key] !== undefined && (typeof raw[key] !== 'string' || !raw[key].trim()))
46
+ throw new Error(`Invalid ${key}: expected a nonempty string`);
47
+ }
48
+ if (raw.serverURL !== undefined) {
49
+ let url;
50
+ try {
51
+ url = new URL(raw.serverURL);
52
+ }
53
+ catch {
54
+ throw new Error('serverURL must be an http or https URL');
55
+ }
56
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
57
+ throw new Error('serverURL must be an http or https URL without embedded credentials');
58
+ }
59
+ if (raw.maxExamplesPerCategory !== undefined &&
60
+ (typeof raw.maxExamplesPerCategory !== 'number' ||
61
+ !Number.isSafeInteger(raw.maxExamplesPerCategory) ||
62
+ raw.maxExamplesPerCategory < 0 ||
63
+ raw.maxExamplesPerCategory > 100))
64
+ throw new Error('maxExamplesPerCategory must be an integer between 0 and 100');
65
+ return raw;
66
+ }
67
+ export async function readConfig(path) {
68
+ let content;
69
+ try {
70
+ content = await readFile(path, 'utf8');
71
+ }
72
+ catch (error) {
73
+ if (error.code === 'ENOENT')
74
+ return { version: 1 };
75
+ throw new Error(`Cannot read configuration at ${path}`, { cause: error });
76
+ }
77
+ try {
78
+ return validateConfig(JSON.parse(content));
79
+ }
80
+ catch (error) {
81
+ throw new Error(`Invalid configuration at ${path}. Correct or remove the file. ${error instanceof SyntaxError ? 'Invalid JSON.' : error.message}`, { cause: error });
82
+ }
83
+ }
84
+ export async function saveConfig(path, config) {
85
+ validateConfig(config);
86
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
87
+ if (process.platform !== 'win32')
88
+ await chmod(dirname(path), 0o700);
89
+ const temporary = `${path}.${randomUUID()}.tmp`;
90
+ try {
91
+ await writeFile(temporary, JSON.stringify(config, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
92
+ await rename(temporary, path);
93
+ }
94
+ finally {
95
+ await rm(temporary, { force: true });
96
+ }
97
+ }
98
+ const envKeys = {
99
+ serverURL: 'ACTUAL_SERVER_URL',
100
+ password: 'ACTUAL_PASSWORD',
101
+ syncId: 'ACTUAL_SYNC_ID',
102
+ encryptionPassword: 'ACTUAL_ENCRYPTION_PASSWORD',
103
+ apiKey: 'TYPESAFE_API_KEY',
104
+ };
105
+ /** Environment configuration is complete on its own and never reads saved credentials. */
106
+ export async function resolveConfig(configFile, env, envFile) {
107
+ const useEnvironment = envFile !== undefined || Object.values(envKeys).some((name) => env[name] !== undefined);
108
+ if (!useEnvironment)
109
+ return { config: await readConfig(configFile), source: 'saved configuration' };
110
+ const fileEnv = envFile ? parseEnv(await readFile(resolve(envFile), 'utf8')) : {};
111
+ const config = { version: 1 };
112
+ for (const key of Object.keys(envKeys)) {
113
+ const name = envKeys[key];
114
+ const value = env[name] ?? fileEnv[name];
115
+ if (value !== undefined && !(key === 'encryptionPassword' && value === ''))
116
+ config[key] = value;
117
+ }
118
+ return { config: validateConfig(config), source: 'environment' };
119
+ }
120
+ export function missingSettings(config) {
121
+ return ['serverURL', 'password', 'syncId', 'apiKey'].filter((key) => !config[key]);
122
+ }
123
+ export function budgetDataDir(root, config) {
124
+ const server = config.serverURL ? new URL(config.serverURL).href.replace(/\/$/, '') : '';
125
+ return join(root, createHash('sha256')
126
+ .update(JSON.stringify([server, config.syncId]))
127
+ .digest('hex'));
128
+ }
129
+ export function configSummary(config) {
130
+ return Object.keys(envKeys).map((key) => {
131
+ const value = config[key];
132
+ const secret = ['password', 'encryptionPassword', 'apiKey'].includes(key);
133
+ return `${key}: ${value === undefined ? '(not set)' : secret ? '[redacted]' : String(value)}`;
134
+ });
135
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { budgetDataDir, configSummary, missingSettings, readConfig, resolveConfig, saveConfig, userPaths, validateConfig, } from './config.js';
7
+ const saved = {
8
+ version: 1,
9
+ serverURL: 'https://actual.example',
10
+ password: ' secret ',
11
+ syncId: 'budget',
12
+ apiKey: 'test-key',
13
+ maxExamplesPerCategory: 2,
14
+ };
15
+ void test('saved and environment configurations are independent', async (t) => {
16
+ const dir = await mkdtemp(join(tmpdir(), 'jev-config-'));
17
+ t.after(() => rm(dir, { recursive: true, force: true }));
18
+ const configFile = join(dir, 'config.json');
19
+ await saveConfig(configFile, { ...saved, encryptionPassword: 'saved-encryption' });
20
+ const personal = await resolveConfig(configFile, {});
21
+ assert.equal(personal.source, 'saved configuration');
22
+ assert.equal(personal.config.maxExamplesPerCategory, 2);
23
+ assert.equal(personal.config.apiKey, saved.apiKey);
24
+ const partial = await resolveConfig(configFile, { ACTUAL_SERVER_URL: 'https://automation.example' });
25
+ assert.equal(partial.source, 'environment');
26
+ assert.deepEqual(missingSettings(partial.config), ['password', 'syncId', 'apiKey']);
27
+ assert.equal(partial.config.encryptionPassword, undefined);
28
+ assert.equal(partial.config.maxExamplesPerCategory, undefined);
29
+ const envFile = join(dir, 'test.env');
30
+ await writeFile(envFile, 'ACTUAL_SERVER_URL=https://automation.example\nACTUAL_PASSWORD="file password"\nACTUAL_SYNC_ID=automation-budget\nTYPESAFE_API_KEY=automation-key\nACTUAL_ENCRYPTION_PASSWORD=');
31
+ const file = await resolveConfig(configFile, {}, envFile);
32
+ const exported = await resolveConfig(configFile, {
33
+ ACTUAL_SERVER_URL: 'https://automation.example',
34
+ ACTUAL_PASSWORD: 'file password',
35
+ ACTUAL_SYNC_ID: 'automation-budget',
36
+ TYPESAFE_API_KEY: 'automation-key',
37
+ ACTUAL_ENCRYPTION_PASSWORD: '',
38
+ });
39
+ assert.deepEqual(file, exported);
40
+ assert.deepEqual(missingSettings(file.config), []);
41
+ assert.equal((await resolveConfig(configFile, { ACTUAL_PASSWORD: 'exported' }, envFile)).config.password, 'exported');
42
+ await writeFile(configFile, 'invalid JSON');
43
+ assert.deepEqual(await resolveConfig(configFile, {}, envFile), file);
44
+ await assert.rejects(resolveConfig(configFile, {}), /Invalid configuration/);
45
+ await assert.rejects(resolveConfig(configFile, {}, join(dir, 'missing.env')), /ENOENT/);
46
+ await writeFile(envFile, '# An empty file must not fall back to saved credentials');
47
+ assert.deepEqual(missingSettings((await resolveConfig(configFile, {}, envFile)).config), [
48
+ 'serverURL',
49
+ 'password',
50
+ 'syncId',
51
+ 'apiKey',
52
+ ]);
53
+ });
54
+ void test('configuration roundtrips privately and invalid files give safe errors', async (t) => {
55
+ const dir = await mkdtemp(join(tmpdir(), 'jev-config-'));
56
+ t.after(() => rm(dir, { recursive: true, force: true }));
57
+ const file = join(dir, 'settings', 'config.json');
58
+ assert.deepEqual(await readConfig(file), { version: 1 });
59
+ await saveConfig(file, saved);
60
+ assert.deepEqual(await readConfig(file), saved);
61
+ await saveConfig(file, { ...saved, password: 'replacement' });
62
+ assert.equal((await readConfig(file)).password, 'replacement');
63
+ assert.deepEqual(await readdir(join(dir, 'settings')), ['config.json']);
64
+ if (process.platform !== 'win32') {
65
+ assert.equal((await stat(file)).mode & 0o777, 0o600);
66
+ assert.equal((await stat(join(dir, 'settings'))).mode & 0o777, 0o700);
67
+ }
68
+ await writeFile(file, '{"password":"sensitive",oops');
69
+ await assert.rejects(readConfig(file), (error) => error instanceof Error && error.message.includes(file) && !error.message.includes('sensitive'));
70
+ await writeFile(file, JSON.stringify({ version: 2 }));
71
+ await assert.rejects(readConfig(file), /Unsupported configuration version/);
72
+ assert.match(await readFile(file, 'utf8'), /version/);
73
+ });
74
+ void test('validation and redaction keep secrets out of displays', () => {
75
+ for (const value of [
76
+ { version: 1, threshold: 2 },
77
+ { version: 1, serverURL: 'ftp://example.com' },
78
+ { version: 1, serverURL: 'https://user:secret@example.com' },
79
+ { version: 1, apiKey: '' },
80
+ { version: 1, typo: true },
81
+ ])
82
+ assert.throws(() => validateConfig(value));
83
+ const output = configSummary({ ...saved, encryptionPassword: 'encrypted-secret' }).join('\n');
84
+ for (const value of [' secret ', 'test-key', 'encrypted-secret'])
85
+ assert.ok(!output.includes(value));
86
+ assert.match(output, /password: \[redacted\]/);
87
+ assert.match(output, /https:\/\/actual.example/);
88
+ });
89
+ void test('platform paths and budget cache identity are independent of working directory', () => {
90
+ assert.equal(userPaths({}, 'linux', '/users/test').configFile, '/users/test/.config/actual-jev/config.json');
91
+ assert.equal(userPaths({ XDG_CONFIG_HOME: '/config', XDG_DATA_HOME: '/data' }, 'linux', '/home/test').dataRoot, '/data/actual-jev/budgets');
92
+ assert.match(userPaths({}, 'darwin', '/users/test').configFile, /Library\/Application Support\/actual-jev/);
93
+ assert.match(userPaths({ APPDATA: '/roaming', LOCALAPPDATA: '/local' }, 'win32', '/test').configFile, /roaming\/actual-jev/);
94
+ const root = '/data/budgets';
95
+ assert.equal(budgetDataDir(root, saved), budgetDataDir(root, { ...saved, serverURL: `${saved.serverURL}/` }));
96
+ assert.notEqual(budgetDataDir(root, saved), budgetDataDir(root, { ...saved, syncId: 'other' }));
97
+ assert.notEqual(budgetDataDir(root, saved), budgetDataDir(root, { ...saved, serverURL: 'https://other.example' }));
98
+ });
@@ -0,0 +1,3 @@
1
+ export type { CategoryCandidate, CategorizedExample, TransactionDetails, RankedCategory, Classification, JevChoiceClient, } from './classifier.js';
2
+ export { ActualJev } from './actual.js';
3
+ export type { ActualDataClient, ActualJevConfig, ActualTransaction, ActualTransactionInput } from './actual.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { ActualJev } from './actual.js';
@@ -0,0 +1,4 @@
1
+ import type { ActualTransaction } from './actual.js';
2
+ import type { CategoryCandidate } from './classifier.js';
3
+ export declare function decision(value: string, color: boolean, applied?: boolean): string;
4
+ export declare function describeSuggestion(transaction: ActualTransaction, accountName: string, category: CategoryCandidate | undefined, confidence: number, payeeNames?: ReadonlyMap<string, string>, color?: boolean): string;
package/dist/output.js ADDED
@@ -0,0 +1,36 @@
1
+ function wrapText(value) {
2
+ const prefix = ' ';
3
+ const words = value.replace(/\s+/g, ' ').trim().split(' ');
4
+ const lines = [];
5
+ let line = prefix;
6
+ for (const word of words) {
7
+ if (line !== prefix && line.length + word.length + 1 > 88) {
8
+ lines.push(line);
9
+ line = `${prefix}${word}`;
10
+ }
11
+ else {
12
+ line += `${line === prefix ? '' : ' '}${word}`;
13
+ }
14
+ }
15
+ lines.push(line);
16
+ return lines;
17
+ }
18
+ function emphasize(value, code, color) {
19
+ return color ? `\u001b[${code}m${value}\u001b[0m` : value;
20
+ }
21
+ export function decision(value, color, applied = false) {
22
+ return ` Decision ${emphasize(value, applied ? '32' : '33', color)}`;
23
+ }
24
+ function describe(transaction, accountName, payeeNames, color = false) {
25
+ const amount = (transaction.amount / 100).toFixed(2);
26
+ const namedPayee = transaction.payee ? payeeNames?.get(transaction.payee)?.trim() : undefined;
27
+ const importedPayee = transaction.imported_payee?.trim();
28
+ const merchant = importedPayee?.match(/\bPresso\s+(.+?)\s+-\s+Transazione\b/i)?.[1]?.trim();
29
+ const payee = namedPayee && namedPayee !== importedPayee ? namedPayee : (merchant ?? namedPayee ?? importedPayee);
30
+ const payeeLines = wrapText(payee || 'Unknown payee');
31
+ return `\n ${emphasize(amount, '1;36', color)}\n${payeeLines.map((line) => emphasize(line, '1', color)).join('\n')}\n ${emphasize(`${transaction.date} · ${accountName}`, '2', color)}`;
32
+ }
33
+ export function describeSuggestion(transaction, accountName, category, confidence, payeeNames, color = false) {
34
+ const name = category ? `${category.groupName} / ${category.name}` : 'no match';
35
+ return `${describe(transaction, accountName, payeeNames, color)}\n\n Suggestion ${emphasize(name, '36', color)} · ${Math.round(confidence * 100)}% confidence`;
36
+ }
@@ -0,0 +1,30 @@
1
+ import { type Config } from './config.js';
2
+ export interface BudgetChoice {
3
+ name: string;
4
+ cloudFileId: string;
5
+ groupId?: string | null;
6
+ encryptKeyId?: string | null;
7
+ }
8
+ export interface SetupPort {
9
+ input(message: string, initial?: string): Promise<string>;
10
+ secret(message: string): Promise<string>;
11
+ select(message: string, choices: {
12
+ name: string;
13
+ value: string;
14
+ }[], initial?: string): Promise<string>;
15
+ print(message: string): void;
16
+ actual: {
17
+ init(options: {
18
+ serverURL: string;
19
+ password: string;
20
+ dataDir: string;
21
+ verbose: boolean;
22
+ }): Promise<unknown>;
23
+ getBudgets(): Promise<BudgetChoice[]>;
24
+ downloadBudget(id: string, options: {
25
+ password?: string;
26
+ }): Promise<unknown>;
27
+ shutdown(): Promise<unknown>;
28
+ };
29
+ }
30
+ export declare function runSetup(saved: Config, configFile: string, port: SetupPort): Promise<void>;