@morroc/open-ledger 0.14.1
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/LICENSE +202 -0
- package/README.md +191 -0
- package/datasets/defaults/cn.json +1 -0
- package/datasets/defaults/jp.json +1 -0
- package/datasets/defaults/th.json +1 -0
- package/datasets/defaults/us.json +1 -0
- package/datasets/institutions/cn.json +47 -0
- package/datasets/institutions/jp.json +59 -0
- package/datasets/institutions/th.json +99 -0
- package/datasets/institutions/us.json +70 -0
- package/dist/accounts/accounts.js +99 -0
- package/dist/accounts/balances.js +109 -0
- package/dist/accounts/matching.js +72 -0
- package/dist/accounts/resolve.js +118 -0
- package/dist/cli/commands/accounts.js +477 -0
- package/dist/cli/commands/config.js +175 -0
- package/dist/cli/commands/datasets.js +56 -0
- package/dist/cli/commands/doctor.js +153 -0
- package/dist/cli/commands/files.js +72 -0
- package/dist/cli/commands/ingest-commit.js +279 -0
- package/dist/cli/commands/ingest.js +203 -0
- package/dist/cli/commands/merchants.js +140 -0
- package/dist/cli/commands/notes.js +71 -0
- package/dist/cli/commands/open.js +54 -0
- package/dist/cli/commands/questions.js +119 -0
- package/dist/cli/commands/report.js +50 -0
- package/dist/cli/commands/setup.js +61 -0
- package/dist/cli/commands/status.js +187 -0
- package/dist/cli/commands/transactions.js +420 -0
- package/dist/cli/commands/vault.js +65 -0
- package/dist/cli/currency.js +28 -0
- package/dist/cli/db.js +5 -0
- package/dist/cli/format.js +71 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/output.js +304 -0
- package/dist/cli/program.js +140 -0
- package/dist/config.js +104 -0
- package/dist/context.js +30 -0
- package/dist/datasets/defaults.js +29 -0
- package/dist/datasets/index.js +33 -0
- package/dist/datasets/institutions.js +31 -0
- package/dist/datasets/loader.js +57 -0
- package/dist/db/connection.js +39 -0
- package/dist/db/encryption.js +42 -0
- package/dist/db/migrations/0001_baseline.js +121 -0
- package/dist/db/migrations/index.js +3 -0
- package/dist/db/queries/accounts.js +108 -0
- package/dist/db/queries/balances.js +61 -0
- package/dist/db/queries/files.js +72 -0
- package/dist/db/queries/merchants.js +163 -0
- package/dist/db/queries/notes.js +28 -0
- package/dist/db/queries/questions.js +117 -0
- package/dist/db/queries/transactions-dedup.js +80 -0
- package/dist/db/queries/transactions.js +319 -0
- package/dist/db/queries/vault.js +48 -0
- package/dist/db/schema.js +105 -0
- package/dist/extract/extract.js +89 -0
- package/dist/extract/ocr.js +164 -0
- package/dist/extract/pdf.js +127 -0
- package/dist/extract/presets/index.js +18 -0
- package/dist/extract/presets/lighton-ocr.js +12 -0
- package/dist/extract/presets/typhoon-ocr.js +23 -0
- package/dist/extract/route.js +40 -0
- package/dist/extract/source.js +84 -0
- package/dist/ingest/commit.js +313 -0
- package/dist/ingest/dedup.js +35 -0
- package/dist/ingest/prepare.js +241 -0
- package/dist/ingest/vault.js +56 -0
- package/dist/lib/date.js +6 -0
- package/dist/lib/ids.js +25 -0
- package/dist/lib/json.js +12 -0
- package/dist/lib/masked.js +49 -0
- package/dist/lib/money.js +35 -0
- package/dist/lib/patch.js +29 -0
- package/dist/lib/result.js +15 -0
- package/dist/lib/validate.js +154 -0
- package/dist/privacy/redactor.js +140 -0
- package/dist/setup/hosts.js +32 -0
- package/dist/setup/install.js +60 -0
- package/package.json +60 -0
- package/skills/SKILL.md +18 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { config } from "../../config.js";
|
|
2
|
+
import { emitList, emitObject, fail, readSecretFromStdin, requireYes, runAction, } from "../output.js";
|
|
3
|
+
import { openDb } from "../db.js";
|
|
4
|
+
const VAULT_COLUMNS = [
|
|
5
|
+
{ header: "ID", value: (r) => r.id },
|
|
6
|
+
{ header: "Pattern", value: (r) => r.pattern },
|
|
7
|
+
{ header: "Uses", value: (r) => String(r.use_count), align: "right" },
|
|
8
|
+
{ header: "Last Used", value: (r) => r.last_used_at ?? "-" },
|
|
9
|
+
];
|
|
10
|
+
async function addVaultEntry(pattern) {
|
|
11
|
+
try {
|
|
12
|
+
new RegExp(pattern);
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
fail("USAGE", `invalid regex pattern: ${err.message}`, {
|
|
16
|
+
hint: "a pattern is a regex matched against the file name, e.g. '^kbank.*' — see `oled vault add --help`",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const password = await readSecretFromStdin();
|
|
20
|
+
if (!password) {
|
|
21
|
+
fail("INPUT_REQUIRED", "no password on stdin", {
|
|
22
|
+
hint: "the password is read from stdin, e.g. `printf %s 'secret' | oled vault add <pattern>`",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const db = await openDb();
|
|
26
|
+
const { upsertPassword } = await import("../../db/queries/vault.js");
|
|
27
|
+
const id = upsertPassword(db, pattern, password, config.dbEncryptionKey);
|
|
28
|
+
emitObject({ id, pattern });
|
|
29
|
+
}
|
|
30
|
+
async function listVaultEntries() {
|
|
31
|
+
const db = await openDb();
|
|
32
|
+
const { listPasswords } = await import("../../db/queries/vault.js");
|
|
33
|
+
const rows = listPasswords(db);
|
|
34
|
+
emitList(rows, VAULT_COLUMNS);
|
|
35
|
+
}
|
|
36
|
+
async function removeVaultEntry(patternOrId, opts) {
|
|
37
|
+
requireYes(opts, `removing vault entry "${patternOrId}"`);
|
|
38
|
+
const db = await openDb();
|
|
39
|
+
const { deletePassword } = await import("../../db/queries/vault.js");
|
|
40
|
+
if (!deletePassword(db, patternOrId)) {
|
|
41
|
+
fail("NOT_FOUND", `no vault entry matching "${patternOrId}"`);
|
|
42
|
+
}
|
|
43
|
+
emitObject({ pattern_or_id: patternOrId, removed: true });
|
|
44
|
+
}
|
|
45
|
+
export function registerVault(program) {
|
|
46
|
+
const vault = program.command("vault").description("Manage file-password patterns for encrypted statements");
|
|
47
|
+
vault
|
|
48
|
+
.command("add <pattern>")
|
|
49
|
+
.description("Add a vault entry for a file-name pattern (pipe the password on stdin)")
|
|
50
|
+
.addHelpText("after", [
|
|
51
|
+
"",
|
|
52
|
+
"Pattern: a regex matched case-insensitively against the file NAME only — not the relative path `ingest list` shows.",
|
|
53
|
+
"Example: printf %s 'secret' | oled vault add '^kbank.*' --json",
|
|
54
|
+
].join("\n"))
|
|
55
|
+
.action(runAction(addVaultEntry));
|
|
56
|
+
vault
|
|
57
|
+
.command("list")
|
|
58
|
+
.description("List vault entries (never prints stored passwords)")
|
|
59
|
+
.action(runAction(listVaultEntries));
|
|
60
|
+
vault
|
|
61
|
+
.command("rm <patternOrId>")
|
|
62
|
+
.description("Remove a vault entry")
|
|
63
|
+
.option("--yes", "skip confirmation")
|
|
64
|
+
.action(runAction(removeVaultEntry));
|
|
65
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
/**
|
|
3
|
+
* buildConfig() resolves these with `||` over its own non-empty defaults, so
|
|
4
|
+
* config.displayLocale/displayCurrency are never empty; last-resort constants live in config.ts.
|
|
5
|
+
*/
|
|
6
|
+
export function getDisplayLocale() {
|
|
7
|
+
return config.displayLocale;
|
|
8
|
+
}
|
|
9
|
+
export function getDisplayCurrency() {
|
|
10
|
+
return config.displayCurrency;
|
|
11
|
+
}
|
|
12
|
+
export function formatCurrencyAmount(amount, options = {}) {
|
|
13
|
+
const locale = getDisplayLocale();
|
|
14
|
+
const currency = options.currency || getDisplayCurrency();
|
|
15
|
+
return new Intl.NumberFormat(locale, {
|
|
16
|
+
style: "currency",
|
|
17
|
+
currency,
|
|
18
|
+
minimumFractionDigits: options.minimumFractionDigits,
|
|
19
|
+
maximumFractionDigits: options.maximumFractionDigits,
|
|
20
|
+
}).format(Math.abs(amount));
|
|
21
|
+
}
|
|
22
|
+
export function formatAmount(amount, currency) {
|
|
23
|
+
return formatCurrencyAmount(amount, {
|
|
24
|
+
minimumFractionDigits: 2,
|
|
25
|
+
maximumFractionDigits: 2,
|
|
26
|
+
currency,
|
|
27
|
+
});
|
|
28
|
+
}
|
package/dist/cli/db.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
// eslint-disable-next-line no-control-regex
|
|
3
|
+
export const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
4
|
+
export function visibleLength(s) {
|
|
5
|
+
return s.replace(ANSI_RE, "").length;
|
|
6
|
+
}
|
|
7
|
+
export function formatInt(n) {
|
|
8
|
+
return n.toLocaleString("en-US");
|
|
9
|
+
}
|
|
10
|
+
/** Left-pad a key/value label to `width`, optionally bold (padding first so the ANSI codes don't count toward the width). */
|
|
11
|
+
function padLabel(label, width, opts = {}) {
|
|
12
|
+
const padded = label.padEnd(width);
|
|
13
|
+
return opts.bold ? chalk.bold(padded) : padded;
|
|
14
|
+
}
|
|
15
|
+
/** Human output only (TTY: aligned two-column, piped: tab-separated) — never emits JSON; the caller owns --json. */
|
|
16
|
+
export function printKeyValues(mode, rows, opts = {}) {
|
|
17
|
+
if (!mode.tty) {
|
|
18
|
+
process.stdout.write(rows.map(([k, v]) => `${k}\t${v}`).join("\n") + "\n");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const width = Math.max(...rows.map(([k]) => k.length));
|
|
22
|
+
for (const [k, v] of rows) {
|
|
23
|
+
process.stdout.write(`${padLabel(k, width, { bold: !!opts.bold })} ${v}\n`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function banner() {
|
|
27
|
+
return (chalk.bold("OpenLedger") +
|
|
28
|
+
chalk.dim(" · The Harness Layer for Personal Finance"));
|
|
29
|
+
}
|
|
30
|
+
const DISCLAIMER = "OpenLedger is an assistant, it only summarizes financial statements — verify amounts against your statements before relying on them.";
|
|
31
|
+
const SCENARIO = [
|
|
32
|
+
"Drop bank statements in a folder; your AI posts each row to a local encrypted ledger.",
|
|
33
|
+
"Ask for net worth, spending, subscriptions, or debt payoff. Answers come from the ledger, not guesses.",
|
|
34
|
+
"Every entry is double-entry and stays on your machine.",
|
|
35
|
+
];
|
|
36
|
+
const CONTRACT = [
|
|
37
|
+
"Exit codes: 0 ok · 1 error · 2 usage · 3 not ready · 4 input required · 5 not found · 6 invalid · 7 partial.",
|
|
38
|
+
"Errors are one stderr JSON object with a hint; read output masks PII as [USER]/[CARD], masks are not data.",
|
|
39
|
+
];
|
|
40
|
+
function section(label, lines) {
|
|
41
|
+
return [chalk.bold.yellow(label), ...lines.map((l) => ` ${l}`)].join("\n");
|
|
42
|
+
}
|
|
43
|
+
export function helpScreen(commands, extraOptions = []) {
|
|
44
|
+
const options = [
|
|
45
|
+
...extraOptions,
|
|
46
|
+
{ name: "--version", desc: "Show the version and exit" },
|
|
47
|
+
{ name: "--help", desc: "Show this help screen" },
|
|
48
|
+
];
|
|
49
|
+
const nameWidth = Math.max(...commands.map((c) => c.name.length), ...options.map((o) => o.name.length));
|
|
50
|
+
const row = (name, desc) => `${chalk.cyan(name.padEnd(nameWidth))} ${chalk.dim(desc)}`;
|
|
51
|
+
const usageLines = [
|
|
52
|
+
row("oled", "<command> [OPTIONS]"),
|
|
53
|
+
row("oled", "Show harness status (default)"),
|
|
54
|
+
];
|
|
55
|
+
return [
|
|
56
|
+
"",
|
|
57
|
+
banner(),
|
|
58
|
+
"",
|
|
59
|
+
section("Usage", usageLines),
|
|
60
|
+
"",
|
|
61
|
+
section("What it does", SCENARIO),
|
|
62
|
+
"",
|
|
63
|
+
section("Commands", commands.map((c) => row(c.name, c.desc))),
|
|
64
|
+
"",
|
|
65
|
+
section("Options", options.map((o) => row(o.name, o.desc))),
|
|
66
|
+
"",
|
|
67
|
+
section("Contract", CONTRACT),
|
|
68
|
+
"",
|
|
69
|
+
chalk.dim(DISCLAIMER),
|
|
70
|
+
].join("\n");
|
|
71
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { buildProgram } from "./program.js";
|
|
3
|
+
import { reportParseError } from "./output.js";
|
|
4
|
+
// Exit quietly when a downstream pipe closes early (e.g. `oled transactions list --json | head`).
|
|
5
|
+
const exitOnEpipe = (err) => {
|
|
6
|
+
if (err.code === "EPIPE")
|
|
7
|
+
process.exit(0);
|
|
8
|
+
throw err;
|
|
9
|
+
};
|
|
10
|
+
process.stdout.on("error", exitOnEpipe);
|
|
11
|
+
process.stderr.on("error", exitOnEpipe);
|
|
12
|
+
// Actions report their own failures inside runAction; only commander's parse
|
|
13
|
+
// errors reach here, thrown by the exit override wired in buildProgram().
|
|
14
|
+
try {
|
|
15
|
+
buildProgram().parse();
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
reportParseError(err);
|
|
19
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { visibleLength, ANSI_RE } from "./format.js";
|
|
5
|
+
import { ValidationError } from "../lib/validate.js";
|
|
6
|
+
import { errorMessage } from "../lib/result.js";
|
|
7
|
+
/**
|
|
8
|
+
* emit()/emitSummary() no-op outside --json, so a stray call from a command's
|
|
9
|
+
* human layout can't corrupt it. A single-result command should use
|
|
10
|
+
* emitObject() instead, which renders in every mode.
|
|
11
|
+
*/
|
|
12
|
+
export const EXIT = {
|
|
13
|
+
OK: 0,
|
|
14
|
+
GENERIC: 1,
|
|
15
|
+
USAGE: 2,
|
|
16
|
+
NOT_READY: 3,
|
|
17
|
+
INPUT_REQUIRED: 4,
|
|
18
|
+
NOT_FOUND: 5,
|
|
19
|
+
INVALID: 6,
|
|
20
|
+
PARTIAL: 7,
|
|
21
|
+
};
|
|
22
|
+
class CliError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
hint;
|
|
25
|
+
details;
|
|
26
|
+
constructor(code, message, opts) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "CliError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.hint = opts?.hint;
|
|
31
|
+
this.details = opts?.details;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Never returns, so callers can use it as a value guard. */
|
|
35
|
+
export function fail(code, message, opts) {
|
|
36
|
+
throw new CliError(code, message, opts);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* ORs flags across the whole ancestor chain: commander leaves a global flag
|
|
40
|
+
* on whichever level declared/consumed it, so walking up finds it regardless.
|
|
41
|
+
*/
|
|
42
|
+
function resolveMode(cmd) {
|
|
43
|
+
let json = false;
|
|
44
|
+
let noColorFlag = false;
|
|
45
|
+
let c = cmd;
|
|
46
|
+
while (c) {
|
|
47
|
+
const o = c.opts();
|
|
48
|
+
if (o.json)
|
|
49
|
+
json = true;
|
|
50
|
+
if (o.color === false)
|
|
51
|
+
noColorFlag = true;
|
|
52
|
+
c = c.parent ?? undefined;
|
|
53
|
+
}
|
|
54
|
+
const tty = !!process.stdout.isTTY;
|
|
55
|
+
const envNoColor = !!process.env.NO_COLOR;
|
|
56
|
+
const noColor = json || noColorFlag || envNoColor || !tty;
|
|
57
|
+
return { json, noColor, tty, color: !noColor };
|
|
58
|
+
}
|
|
59
|
+
let current = null;
|
|
60
|
+
function getOutputMode(cmd) {
|
|
61
|
+
current = resolveMode(cmd);
|
|
62
|
+
return current;
|
|
63
|
+
}
|
|
64
|
+
/** The mode resolved for the running action (lazily defaulted for direct calls). */
|
|
65
|
+
export function currentMode() {
|
|
66
|
+
if (!current)
|
|
67
|
+
current = resolveMode(undefined);
|
|
68
|
+
return current;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* libsql attaches `_metadata: { duration }` to every `.get()` row, so any
|
|
72
|
+
* command that spreads a row into its payload would publish it. Stripped once
|
|
73
|
+
* here at the boundary rather than at 39 query sites. Top level only: `.all()`
|
|
74
|
+
* rows carry no `_metadata`, so nested arrays never need it.
|
|
75
|
+
*/
|
|
76
|
+
function stripMetadata(value) {
|
|
77
|
+
if (value === null || typeof value !== "object" || !("_metadata" in value))
|
|
78
|
+
return value;
|
|
79
|
+
const rest = { ...value };
|
|
80
|
+
delete rest._metadata;
|
|
81
|
+
return rest;
|
|
82
|
+
}
|
|
83
|
+
function writeLine(stream, obj) {
|
|
84
|
+
stream.write(JSON.stringify(stripMetadata(obj)) + "\n");
|
|
85
|
+
}
|
|
86
|
+
export function emit(obj) {
|
|
87
|
+
if (currentMode().json)
|
|
88
|
+
writeLine(process.stdout, obj);
|
|
89
|
+
}
|
|
90
|
+
/** Terminal `{"type":"summary",...}` for a streaming command. */
|
|
91
|
+
export function emitSummary(fields = {}) {
|
|
92
|
+
if (currentMode().json)
|
|
93
|
+
writeLine(process.stdout, { type: "summary", ...fields });
|
|
94
|
+
}
|
|
95
|
+
export function emitList(rows, columns) {
|
|
96
|
+
const m = currentMode();
|
|
97
|
+
if (m.json) {
|
|
98
|
+
for (const row of rows)
|
|
99
|
+
writeLine(process.stdout, row);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (m.tty) {
|
|
103
|
+
renderTable(rows, columns, m.color);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
renderPlain(rows, columns);
|
|
107
|
+
}
|
|
108
|
+
/** Human/plain output is tab-separated key/value lines, ANSI-free so it stays stable when piped. */
|
|
109
|
+
export function emitObject(obj) {
|
|
110
|
+
const row = stripMetadata(obj);
|
|
111
|
+
if (currentMode().json) {
|
|
112
|
+
emit(row);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const [k, v] of Object.entries(row)) {
|
|
116
|
+
const s = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
117
|
+
process.stdout.write(`${k}\t${s}\n`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function renderPlain(rows, columns) {
|
|
121
|
+
const lines = rows.map((row) => columns.map((c) => c.value(row).replace(ANSI_RE, "")).join("\t"));
|
|
122
|
+
if (lines.length)
|
|
123
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
124
|
+
}
|
|
125
|
+
function renderTable(rows, columns, color) {
|
|
126
|
+
const cells = rows.map((row) => columns.map((c) => c.value(row)));
|
|
127
|
+
const widths = columns.map((c, i) => Math.max(visibleLength(c.header), ...cells.map((r) => visibleLength(r[i]))));
|
|
128
|
+
const pad = (s, width, align) => {
|
|
129
|
+
const gap = " ".repeat(Math.max(0, width - visibleLength(s)));
|
|
130
|
+
return align === "right" ? gap + s : s + gap;
|
|
131
|
+
};
|
|
132
|
+
const header = columns
|
|
133
|
+
.map((c, i) => pad(color ? chalk.bold(c.header) : c.header, widths[i], c.align))
|
|
134
|
+
.join(" ")
|
|
135
|
+
.trimEnd();
|
|
136
|
+
const out = [header];
|
|
137
|
+
for (const r of cells) {
|
|
138
|
+
out.push(columns.map((c, i) => pad(r[i], widths[i], c.align)).join(" ").trimEnd());
|
|
139
|
+
}
|
|
140
|
+
process.stdout.write(out.join("\n") + "\n");
|
|
141
|
+
}
|
|
142
|
+
export function requireYes(opts, what) {
|
|
143
|
+
if (!opts.yes) {
|
|
144
|
+
fail("INPUT_REQUIRED", `${what} needs confirmation`, {
|
|
145
|
+
hint: "re-run with --yes to proceed",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Empty string when stdin is a TTY (no pipe). */
|
|
150
|
+
export async function readStdinToEnd() {
|
|
151
|
+
if (process.stdin.isTTY)
|
|
152
|
+
return "";
|
|
153
|
+
const chunks = [];
|
|
154
|
+
for await (const chunk of process.stdin) {
|
|
155
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
156
|
+
}
|
|
157
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
158
|
+
}
|
|
159
|
+
/** Trims a single trailing newline, CRLF-aware. */
|
|
160
|
+
export async function readSecretFromStdin() {
|
|
161
|
+
const raw = await readStdinToEnd();
|
|
162
|
+
return raw.replace(/\r?\n$/, "");
|
|
163
|
+
}
|
|
164
|
+
/** Auto-detects a JSON array (first non-ws char is `[`) vs NDJSON. Row validation is the caller's job. */
|
|
165
|
+
export async function readStdinBatch(inputPath) {
|
|
166
|
+
let source;
|
|
167
|
+
if (inputPath) {
|
|
168
|
+
try {
|
|
169
|
+
source = readFileSync(inputPath, "utf8");
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
fail("NOT_FOUND", `cannot read --input file: ${err.message}`, {
|
|
173
|
+
hint: "pass a readable NDJSON (or JSON array) file path",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
source = await readStdinToEnd();
|
|
179
|
+
}
|
|
180
|
+
const raw = source.replace(/^\uFEFF/, "");
|
|
181
|
+
const firstNonWs = raw.match(/\S/);
|
|
182
|
+
if (!firstNonWs)
|
|
183
|
+
fail("USAGE", inputPath ? `no transaction data in ${inputPath}` : "no transaction data on stdin", {
|
|
184
|
+
hint: "pass NDJSON rows via --input <file> or pipe them on stdin",
|
|
185
|
+
});
|
|
186
|
+
if (firstNonWs[0] === "[") {
|
|
187
|
+
try {
|
|
188
|
+
const parsed = JSON.parse(raw);
|
|
189
|
+
if (!Array.isArray(parsed))
|
|
190
|
+
fail("USAGE", "stdin JSON must be an array of transactions");
|
|
191
|
+
return parsed;
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
if (err instanceof CliError)
|
|
195
|
+
throw err;
|
|
196
|
+
fail("USAGE", `invalid JSON array on stdin: ${err.message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const out = [];
|
|
200
|
+
const lines = raw.split(/\r?\n/);
|
|
201
|
+
for (let i = 0; i < lines.length; i++) {
|
|
202
|
+
const line = lines[i].trim();
|
|
203
|
+
if (!line)
|
|
204
|
+
continue;
|
|
205
|
+
try {
|
|
206
|
+
out.push(JSON.parse(line));
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
fail("USAGE", `invalid JSON on line ${i + 1}: ${err.message}`, {
|
|
210
|
+
details: { line: i + 1 },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
export function asRecord(value) {
|
|
217
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
218
|
+
? value
|
|
219
|
+
: null;
|
|
220
|
+
}
|
|
221
|
+
const NOT_READY_PATTERNS = [
|
|
222
|
+
"failed to open database",
|
|
223
|
+
"wrong encryption key",
|
|
224
|
+
"corrupt database",
|
|
225
|
+
"not a database",
|
|
226
|
+
"file is encrypted",
|
|
227
|
+
"not configured",
|
|
228
|
+
"not an openledger database",
|
|
229
|
+
];
|
|
230
|
+
function isNotReadyError(err) {
|
|
231
|
+
const msg = err instanceof Error ? err.message.toLowerCase() : "";
|
|
232
|
+
return NOT_READY_PATTERNS.some((p) => msg.includes(p));
|
|
233
|
+
}
|
|
234
|
+
function toCliError(err) {
|
|
235
|
+
if (err instanceof CliError)
|
|
236
|
+
return err;
|
|
237
|
+
if (err instanceof ValidationError) {
|
|
238
|
+
return new CliError("USAGE", err.message, {
|
|
239
|
+
hint: "append --help to the command for its flags and usage",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (isNotReadyError(err)) {
|
|
243
|
+
return new CliError("NOT_READY", err.message, {
|
|
244
|
+
hint: "run `oled config --generate-key` to configure the harness",
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return new CliError("GENERIC", errorMessage(err));
|
|
248
|
+
}
|
|
249
|
+
/** Domain errors are thrown as plain `Error`s, so they're matched by message, not type. */
|
|
250
|
+
export function mapNotFoundError(err, extraNotFound) {
|
|
251
|
+
const message = errorMessage(err);
|
|
252
|
+
if (/not found/i.test(message) || (extraNotFound && extraNotFound.test(message))) {
|
|
253
|
+
fail("NOT_FOUND", message);
|
|
254
|
+
}
|
|
255
|
+
fail("INVALID", message);
|
|
256
|
+
}
|
|
257
|
+
function reportError(err) {
|
|
258
|
+
const cliErr = toCliError(err);
|
|
259
|
+
if (currentMode().json) {
|
|
260
|
+
const payload = {
|
|
261
|
+
code: `E_${cliErr.code}`,
|
|
262
|
+
message: cliErr.message,
|
|
263
|
+
};
|
|
264
|
+
if (cliErr.hint !== undefined)
|
|
265
|
+
payload.hint = cliErr.hint;
|
|
266
|
+
if (cliErr.details !== undefined)
|
|
267
|
+
payload.details = cliErr.details;
|
|
268
|
+
process.stderr.write(JSON.stringify({ error: payload }) + "\n");
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
process.stderr.write(`error: ${cliErr.message}\n`);
|
|
272
|
+
if (cliErr.hint)
|
|
273
|
+
process.stderr.write(`hint: ${cliErr.hint}\n`);
|
|
274
|
+
}
|
|
275
|
+
return EXIT[cliErr.code];
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* At the parse boundary commander aborts before any action runs, so nothing
|
|
279
|
+
* resolved the mode and `--json` may still be unparsed — argv is the only
|
|
280
|
+
* signal left.
|
|
281
|
+
*/
|
|
282
|
+
export function jsonRequested() {
|
|
283
|
+
return process.argv.includes("--json");
|
|
284
|
+
}
|
|
285
|
+
export function reportParseError(err) {
|
|
286
|
+
current = { json: jsonRequested(), noColor: true, tty: !!process.stdout.isTTY, color: false };
|
|
287
|
+
process.exitCode = reportError(err);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The command is commander's last positional arg. Sets `process.exitCode` rather
|
|
291
|
+
* than calling `process.exit` so buffered stdout/stderr flush before the process ends.
|
|
292
|
+
*/
|
|
293
|
+
export function runAction(fn) {
|
|
294
|
+
return async (...args) => {
|
|
295
|
+
const last = args[args.length - 1];
|
|
296
|
+
getOutputMode(last instanceof Command ? last : undefined);
|
|
297
|
+
try {
|
|
298
|
+
await fn(...args);
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
process.exitCode = reportError(err);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Command, Help } from "commander";
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
// Side-effect import: loads .env and resolves the config singleton first.
|
|
4
|
+
import "../config.js";
|
|
5
|
+
import { helpScreen } from "./format.js";
|
|
6
|
+
import { fail, jsonRequested, runAction } from "./output.js";
|
|
7
|
+
import { registerStatus, showStatus } from "./commands/status.js";
|
|
8
|
+
import { registerDoctor } from "./commands/doctor.js";
|
|
9
|
+
import { registerSetup } from "./commands/setup.js";
|
|
10
|
+
import { registerConfig } from "./commands/config.js";
|
|
11
|
+
import { registerIngest } from "./commands/ingest.js";
|
|
12
|
+
import { registerFiles } from "./commands/files.js";
|
|
13
|
+
import { registerVault } from "./commands/vault.js";
|
|
14
|
+
import { registerTransactions } from "./commands/transactions.js";
|
|
15
|
+
import { registerAccounts } from "./commands/accounts.js";
|
|
16
|
+
import { registerMerchants } from "./commands/merchants.js";
|
|
17
|
+
import { registerQuestions } from "./commands/questions.js";
|
|
18
|
+
import { registerReport } from "./commands/report.js";
|
|
19
|
+
import { registerNotes } from "./commands/notes.js";
|
|
20
|
+
import { registerDatasets } from "./commands/datasets.js";
|
|
21
|
+
import { registerOpen } from "./commands/open.js";
|
|
22
|
+
export const COMMANDS = [
|
|
23
|
+
{ name: "status", desc: "Status: config, database, ledger counts, net worth (default)" },
|
|
24
|
+
{ name: "doctor", desc: "Diagnose the harness environment" },
|
|
25
|
+
{ name: "setup", desc: "Install the skill for an agent CLI (--host <id> | --dir <path>)" },
|
|
26
|
+
{ name: "config", desc: "Configuration" },
|
|
27
|
+
{ name: "ingest", desc: "Ingest pipeline: list / prepare / commit / done / fail" },
|
|
28
|
+
{ name: "files", desc: "Browse ingested files (list / show / drop)" },
|
|
29
|
+
{ name: "vault", desc: "Manage file-password patterns for encrypted statements" },
|
|
30
|
+
{ name: "transactions", desc: "Transactions: list / show / add / update / delete / recategorize / dedupe / merge" },
|
|
31
|
+
{ name: "accounts", desc: "Manage the chart of accounts" },
|
|
32
|
+
{ name: "merchants", desc: "Manage merchants and their default accounts" },
|
|
33
|
+
{ name: "questions", desc: "List, answer, and defer open questions" },
|
|
34
|
+
{ name: "report", desc: "Income, expenses, and net" },
|
|
35
|
+
{ name: "notes", desc: "Manage freeform notes" },
|
|
36
|
+
{ name: "datasets", desc: "Reference datasets" },
|
|
37
|
+
{ name: "open", desc: "Open the data folder in file explorer" },
|
|
38
|
+
];
|
|
39
|
+
const GLOBAL_OPTIONS = [
|
|
40
|
+
{ name: "--json", desc: "Emit NDJSON (machine-readable) instead of human output" },
|
|
41
|
+
{ name: "--no-color", desc: "Disable ANSI color output" },
|
|
42
|
+
];
|
|
43
|
+
/** `oled ingest list` for a leaf, `oled` for the root: the command whose help answers the error. */
|
|
44
|
+
function commandPath(cmd) {
|
|
45
|
+
const names = [];
|
|
46
|
+
for (let c = cmd; c; c = c.parent)
|
|
47
|
+
names.unshift(c.name());
|
|
48
|
+
return names.join(" ");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse failures never reach runAction, so they are mapped onto the same error
|
|
52
|
+
* contract here — USAGE, with the erroring command's own help as the hint.
|
|
53
|
+
* --help and --version already printed and keep their exit code.
|
|
54
|
+
*/
|
|
55
|
+
function parseFailureHandler(cmd) {
|
|
56
|
+
return (err) => {
|
|
57
|
+
if (err.exitCode === 0)
|
|
58
|
+
return;
|
|
59
|
+
// A noun reached without a verb: commander printed the noun's help screen,
|
|
60
|
+
// which is the answer for a human. --json promised one error line instead.
|
|
61
|
+
if (err.code === "commander.help") {
|
|
62
|
+
if (!jsonRequested())
|
|
63
|
+
return;
|
|
64
|
+
fail("USAGE", `${commandPath(cmd)} needs a subcommand`, {
|
|
65
|
+
hint: `one of: ${cmd.commands.map((sub) => sub.name()).join(", ")}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// The root takes no positional argument, so a stray one is a mistyped command.
|
|
69
|
+
if (err.code === "commander.excessArguments" && !cmd.parent) {
|
|
70
|
+
fail("USAGE", `unknown command '${cmd.args[0]}'`, {
|
|
71
|
+
hint: "run `oled --help` for the list of commands",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
fail("USAGE", err.message.replace(/^error:\s*/, ""), {
|
|
75
|
+
hint: `run \`${commandPath(cmd)} --help\` for its flags and usage`,
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Builds the full commander program: pure construction — callers own `.parse()` / `.parseAsync()`. */
|
|
80
|
+
export function buildProgram() {
|
|
81
|
+
const require = createRequire(import.meta.url);
|
|
82
|
+
const { version } = require("../../package.json");
|
|
83
|
+
const program = new Command();
|
|
84
|
+
// Required so a command with BOTH a bare action and subcommands (config)
|
|
85
|
+
// dispatches the subcommand instead of swallowing its options into the bare action.
|
|
86
|
+
program.enablePositionalOptions();
|
|
87
|
+
program
|
|
88
|
+
.name("oled")
|
|
89
|
+
.description("The Harness Layer for Personal Finance")
|
|
90
|
+
.version(version)
|
|
91
|
+
.addHelpCommand(false)
|
|
92
|
+
// Bare `oled` reports harness status — the same action as `status`, which
|
|
93
|
+
// redacts by default; `oled status --no-redact` is the way to opt out.
|
|
94
|
+
.action(runAction(showStatus));
|
|
95
|
+
registerOpen(program);
|
|
96
|
+
registerStatus(program);
|
|
97
|
+
registerDoctor(program);
|
|
98
|
+
registerSetup(program);
|
|
99
|
+
registerConfig(program);
|
|
100
|
+
registerIngest(program);
|
|
101
|
+
registerFiles(program);
|
|
102
|
+
registerVault(program);
|
|
103
|
+
registerTransactions(program);
|
|
104
|
+
registerAccounts(program);
|
|
105
|
+
registerMerchants(program);
|
|
106
|
+
registerQuestions(program);
|
|
107
|
+
registerReport(program);
|
|
108
|
+
registerNotes(program);
|
|
109
|
+
registerDatasets(program);
|
|
110
|
+
// --json/--no-color on every command so they work before or after the
|
|
111
|
+
// subcommand name (getOutputMode() OR-walks the chain to find them), and an
|
|
112
|
+
// exit override per command so a parse failure names its own help.
|
|
113
|
+
function configureEveryLevel(cmd) {
|
|
114
|
+
cmd
|
|
115
|
+
.option("--json", "Emit NDJSON (machine-readable) instead of human output")
|
|
116
|
+
.option("--no-color", "Disable ANSI color output")
|
|
117
|
+
.exitOverride(parseFailureHandler(cmd))
|
|
118
|
+
// Commander writes its own plain-text error line, and a help screen for a
|
|
119
|
+
// verbless noun, before exiting; under --json the CliError contract is the
|
|
120
|
+
// only thing allowed on stderr.
|
|
121
|
+
.configureOutput({
|
|
122
|
+
outputError: () => { },
|
|
123
|
+
writeErr: (str) => {
|
|
124
|
+
if (!jsonRequested())
|
|
125
|
+
process.stderr.write(str);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
for (const sub of cmd.commands)
|
|
129
|
+
configureEveryLevel(sub);
|
|
130
|
+
}
|
|
131
|
+
configureEveryLevel(program);
|
|
132
|
+
program.configureHelp({
|
|
133
|
+
// configureHelp is inherited by subcommands, so guard explicitly: only the
|
|
134
|
+
// root gets the branded screen; subcommands keep commander's default formatter.
|
|
135
|
+
formatHelp: (cmd, helper) => cmd === program
|
|
136
|
+
? helpScreen(COMMANDS, GLOBAL_OPTIONS)
|
|
137
|
+
: Help.prototype.formatHelp.call(helper, cmd, helper),
|
|
138
|
+
});
|
|
139
|
+
return program;
|
|
140
|
+
}
|