From 768b3d0feab07c37469e25ffb604bf4cebf49ac2 Mon Sep 17 00:00:00 2001 From: simse Date: Thu, 16 Oct 2025 13:20:14 +0100 Subject: [PATCH 1/2] feat: Create configuration reference page --- .gitignore | 1 + docs/.vitepress/config.ts | 1 + docs/config-reference.data.ts | 39 ++++ docs/config-reference.md | 337 ++++++++++++++++++++++++++++++++++ package.json | 7 +- src/config.ts | 120 ++++++++---- src/index.ts | 42 ++++- 7 files changed, 509 insertions(+), 38 deletions(-) create mode 100644 docs/config-reference.data.ts create mode 100644 docs/config-reference.md diff --git a/.gitignore b/.gitignore index e96e106..0728873 100644 --- a/.gitignore +++ b/.gitignore @@ -184,6 +184,7 @@ ynab_connect # docs docs/.vitepress/dist docs/.vitepress/cache +docs/.vitepress/config-schema.json # Claude Code .claude \ No newline at end of file diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 732e618..2626bf6 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -59,6 +59,7 @@ export default defineConfig({ items: [ { text: "Quick Start", link: "/quick-start" }, { text: "Configuration", link: "/configuration" }, + { text: "Configuration Reference", link: "/config-reference" }, ], }, { diff --git a/docs/config-reference.data.ts b/docs/config-reference.data.ts new file mode 100644 index 0000000..ab064b8 --- /dev/null +++ b/docs/config-reference.data.ts @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; + +interface JsonSchemaProperty { + type?: string; + properties?: Record; + items?: JsonSchemaProperty; + anyOf?: JsonSchemaProperty[]; + required?: string[]; + minLength?: number; + maxLength?: number; + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; + pattern?: string; + format?: string; + default?: unknown; + const?: unknown; + minItems?: number; + maxItems?: number; + additionalProperties?: boolean; +} + +interface JsonSchema { + $schema?: string; + type?: string; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +} + +export default { + load() { + const schemaPath = path.join(__dirname, ".vitepress/config-schema.json"); + const schema: JsonSchema = JSON.parse(fs.readFileSync(schemaPath, "utf-8")); + return schema; + }, +}; diff --git a/docs/config-reference.md b/docs/config-reference.md new file mode 100644 index 0000000..4cc75ff --- /dev/null +++ b/docs/config-reference.md @@ -0,0 +1,337 @@ +--- +title: Configuration Reference +--- + + + +# Configuration Reference + +{{ schema.description }} + +## Top-Level Configuration + +The configuration file is a YAML file with the following top-level properties: + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyTypeRequiredDescription
{{ section.key }}{{ formatType(section.prop) }}{{ isRequired(schema.required, section.key) }}{{ section.prop.description || '' }}
accountsArray{{ isRequired(schema.required, 'accounts') }}{{ schema.properties?.accounts?.description || '' }}
+ +
+ +## {{ section.key.charAt(0).toUpperCase() + section.key.slice(1) }} Configuration + +{{ section.prop.description }} + + + + + + + + + + + + + + + + + + + + +
PropertyTypeRequiredDefaultDescription
{{ key }}{{ formatType(prop) }}{{ isRequired(section.prop.required, key) }}{{ prop.default !== undefined ? prop.default : '-' }}{{ prop.description || '' }}
+ +
+ +See the [Create YNAB Token](/guide/create-ynab-token) guide for instructions on obtaining these values. + +
+ +
+ +See the [Browser](/browser) documentation for more information. + +
+ +
+ +## Accounts Configuration + +{{ schema.properties?.accounts?.description }} + +### Common Account Fields + +All account types share these fields: + + + + + + + + + + + + + + + + + + +
PropertyTypeRequiredDescription
{{ field.key }}{{ formatType(field.prop) }}Yes{{ field.prop.description || '' }}
+ +### Account Types + +
+ +#### {{ formatConnectorName(accountType.type) }} + +**Type:** `{{ accountType.type }}` + + + + + + + + + + + + + + + + + + +
PropertyTypeRequiredDescription
{{ key }}{{ formatType(prop) }}{{ isRequired(accountType.schema.required, key) }}{{ prop.description || '' }}
+ +

See the {{ formatConnectorName(accountType.type) }} connector documentation for setup instructions.

+ +
+ +## Example Configuration + +Here's a complete example showing all configuration options: + +
yaml
{{ exampleYaml }}
+ +## Notes + +- You can configure multiple accounts of the same type +- The `interval` field uses cron syntax. Use [crontab.guru](https://crontab.guru/) to help create cron expressions +- Make sure each account has a unique `name` +- The configuration file should be placed at `/config.yaml` in production, or in the project root for development diff --git a/package.json b/package.json index 5c79544..3bd44f0 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "scripts": { "lint": "biome check", "lint:fix": "biome check --write", - "types": "tsc --noEmit", + "types": "tsc --noEmit", "build:binary": "bun build src/index.ts --compile --minify --sourcemaps --outfile ynab-connect", - "docs:dev": "vitepress dev docs", - "docs:build": "vitepress build docs", + "schema:export": "bun run src/index.ts export-schema > docs/.vitepress/config-schema.json", + "docs:dev": "bun run schema:export && vitepress dev docs", + "docs:build": "bun run schema:export && vitepress build docs", "docs:preview": "vitepress preview docs" }, "devDependencies": { diff --git a/src/config.ts b/src/config.ts index bc1ba03..2b7c740 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,59 +7,113 @@ import { fromError } from "zod-validation-error"; import logger from "./logger.ts"; const commonFields = { - name: z.string().min(1, "Account name is required"), - ynabAccountId: z.string().min(1, "YNAB Account ID is required"), + name: z + .string() + .min(1, "Account name is required") + .describe("Unique name for this account"), + ynabAccountId: z + .string() + .min(1, "YNAB Account ID is required") + .describe("The ID of the YNAB account to sync to"), interval: z .string() - .refine((val) => cron.validate(val), { error: "Invalid CRON expression" }), + .refine((val) => cron.validate(val), { error: "Invalid CRON expression" }) + .describe( + "Cron expression for sync schedule (e.g., '0 2 * * *' for daily at 2 AM)", + ), }; const accountConfig = z.discriminatedUnion("type", [ z.object({ ...commonFields, - type: z.literal("trading212"), - trading212ApiKey: z.string().min(1, "Trading212 API Key is required"), - trading212SecretKey: z.string().min(1, "Trading212 Secret Key is required"), + type: z.literal("trading212").describe("Connector type"), + trading212ApiKey: z + .string() + .min(1, "Trading212 API Key is required") + .describe("Your Trading 212 API key"), + trading212SecretKey: z + .string() + .min(1, "Trading212 Secret Key is required") + .describe("Your Trading 212 API secret"), }), z.object({ ...commonFields, - type: z.literal("uk_student_loan"), - email: z.email("UK Student Loan email is required"), - password: z.string().min(1, "UK Student Loan password is required"), + type: z.literal("uk_student_loan").describe("Connector type"), + email: z + .email("UK Student Loan email is required") + .describe("Email address for login"), + password: z + .string() + .min(1, "UK Student Loan password is required") + .describe("Password for login"), secretAnswer: z .string() - .min(1, "UK Student Loan secret answer is required"), + .min(1, "UK Student Loan secret answer is required") + .describe("Secret answer for security question"), }), z.object({ ...commonFields, - type: z.literal("standard_life_pension"), - username: z.string().min(1, "Standard Life username is required"), - password: z.string().min(1, "Standard Life password is required"), - policyNumber: z.string().min(1, "Standard Life policy number is required"), + type: z.literal("standard_life_pension").describe("Connector type"), + username: z + .string() + .min(1, "Standard Life username is required") + .describe("Username for login"), + password: z + .string() + .min(1, "Standard Life password is required") + .describe("Password for login"), + policyNumber: z + .string() + .min(1, "Standard Life policy number is required") + .describe("Policy number for the pension account"), }), ]); export type Account = z.infer; -const schemaConfig = z.object({ - ynab: z.object({ - accessToken: z.string().min(1, "YNAB Access Token is required"), - budgetId: z.string().min(1, "YNAB Budget ID is required"), - }), - browser: z - .object({ - endpoint: z.string(), - }) - .optional(), - server: z - .object({ - port: z.number().int().positive().default(4030), - }) - .default({ port: 4030 }), - accounts: accountConfig - .array() - .min(1, "At least one account must be configured"), -}); +const schemaConfig = z + .object({ + ynab: z + .object({ + accessToken: z + .string() + .min(1, "YNAB Access Token is required") + .describe("Your YNAB personal access token"), + budgetId: z + .string() + .min(1, "YNAB Budget ID is required") + .describe("The ID of your YNAB budget"), + }) + .describe("YNAB API configuration"), + browser: z + .object({ + endpoint: z + .string() + .describe( + "URL of a browserless endpoint for headless browser automation", + ), + }) + .optional() + .describe("Browser automation configuration (optional)"), + server: z + .object({ + port: z + .number() + .int() + .positive() + .default(4030) + .describe("Port number for the 2FA server"), + }) + .optional() + .describe("2FA server configuration (optional)"), + accounts: accountConfig + .array() + .min(1, "At least one account must be configured") + .describe("List of accounts to sync"), + }) + .describe("ynab-connect configuration"); + +export { schemaConfig }; const configPath = Bun.env.NODE_ENV === "production" diff --git a/src/index.ts b/src/index.ts index f7626fb..1f5c769 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,28 @@ import cron, { type ScheduledTask } from "node-cron"; +import { z } from "zod"; import { start2FAServer, stop2FAServer } from "./2fa.ts"; -import config from "./config.ts"; import logger, { createLogger } from "./logger.ts"; import { runSyncJob } from "./runtime.ts"; import { ensureBudgetExists } from "./ynab.ts"; +// parse command line arguments +const args = Bun.argv.slice(2); +const command = args[0]; +const connectorName = args[1]; + +// handle "export-schema" command before loading config +if (command === "export-schema") { + const { schemaConfig } = await import("./config.ts"); + const jsonSchema = z.toJSONSchema(schemaConfig); + console.log(JSON.stringify(jsonSchema, null, 2)); + process.exit(0); +} + logger.info("Welcome to ynab-connect"); +// load config only when needed +const config = (await import("./config.ts")).default; + // check YNAB budget exists const budgetExists = await ensureBudgetExists(config.ynab.budgetId); @@ -19,8 +35,30 @@ if (!budgetExists) { logger.info(`Using YNAB budget ID: ${config.ynab.budgetId}`); +// handle "run " command +if (command === "run") { + if (!connectorName) { + logger.error("Please provide a connector name: run "); + process.exit(1); + } + + const account = config.accounts.find((acc) => acc.name === connectorName); + + if (!account) { + logger.error( + `Connector "${connectorName}" not found. Available connectors: ${config.accounts.map((acc) => acc.name).join(", ")}`, + ); + process.exit(1); + } + + logger.info(`Running connector "${connectorName}"`); + await runSyncJob(account); + logger.info(`Connector "${connectorName}" completed`); + process.exit(0); +} + // start 2FA server -start2FAServer(config.server.port); +start2FAServer(config.server?.port || 4030); // schedule jobs for each account const jobs: Map = new Map(); From cd25f763a4ca6b21651a0cd5616f65402718e8fa Mon Sep 17 00:00:00 2001 From: simse Date: Thu, 16 Oct 2025 18:50:37 +0100 Subject: [PATCH 2/2] docs: Add config reference --- docs/.vitepress/config.ts | 15 ++-- docs/browser.md | 51 +++++++++++-- docs/config-reference.data.ts | 4 +- docs/config-reference.md | 139 ++++------------------------------ docs/configuration.md | 76 ++++++++++++++----- docs/guide/sms-forwarding.md | 13 ++-- 6 files changed, 132 insertions(+), 166 deletions(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 2626bf6..8c64812 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -56,19 +56,20 @@ export default defineConfig({ sidebar: [ { text: "Get Started", + items: [{ text: "Quick Start", link: "/quick-start" }], + }, + { + text: "Configuration", items: [ - { text: "Quick Start", link: "/quick-start" }, - { text: "Configuration", link: "/configuration" }, + { text: "Overview", link: "/configuration" }, { text: "Configuration Reference", link: "/config-reference" }, + { text: "Browser Setup", link: "/browser" }, + { text: "SMS Forwarding for 2FA", link: "/guide/sms-forwarding" }, ], }, { text: "Guides", - items: generateSidebarItems("guide", "guide"), - }, - { - text: "Features", - items: [{ text: "Browser", link: "/browser" }], + items: [{ text: "Create YNAB Token", link: "/guide/create-ynab-token" }], }, { text: "Connectors", diff --git a/docs/browser.md b/docs/browser.md index a27170d..b6ec90e 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -1,14 +1,51 @@ --- -title: Browser +title: Browser Setup --- -# Set up a browser -Some sources require a browser to be configured. `ynab-connect` uses Puppeteer for browser automation. +# Browser Setup + +Some connectors require browser automation to access your data. `ynab-connect` uses Puppeteer for browser automation. + +## When is this needed? + +Some financial institutions do not provide an API to access your data. In these cases, `ynab-connect` can use a headless browser to log in to your account and retrieve your balance. + +Connectors that require browser automation will indicate this in their documentation. + +## Configuration + +Set up a headless browser service like [Browserless](https://github.com/browserless/browserless) and add the following to your configuration: + +```yaml +browser: + endpoint: "wss://your-browserless-endpoint" +``` + +### Using Browserless Cloud + +The easiest way to get started is with [Browserless Cloud](https://www.browserless.io/): + +1. Sign up for a free account +2. Get your connection URL (includes your token) +3. Add it to your configuration: + +```yaml +browser: + endpoint: "wss://chrome.browserless.io?token=YOUR_TOKEN" +``` + +### Self-Hosting Browserless + +You can also run Browserless yourself using Docker: + +```bash +docker run -d -p 3000:3000 browserless/chrome +``` + +Then configure: -Set up a headless browser like [Browserless](https://github.com/browserless/browserless) and add the following to your configuration: ```yaml browser: - endpoint: "ws://your-browserless-endpoint:3000" + endpoint: "ws://localhost:3000" ``` -## Why might a browser be needed? -Some sources do not provide an API to access your data. In these cases, `ynab-connect` can use a headless browser to log in to your account and retrieve your data. \ No newline at end of file +See the [Browserless documentation](https://docs.browserless.io/) for more details on self-hosting. \ No newline at end of file diff --git a/docs/config-reference.data.ts b/docs/config-reference.data.ts index ab064b8..82589ba 100644 --- a/docs/config-reference.data.ts +++ b/docs/config-reference.data.ts @@ -20,6 +20,7 @@ interface JsonSchemaProperty { minItems?: number; maxItems?: number; additionalProperties?: boolean; + description?: string; } interface JsonSchema { @@ -28,12 +29,13 @@ interface JsonSchema { properties?: Record; required?: string[]; additionalProperties?: boolean; + description?: string; } export default { load() { const schemaPath = path.join(__dirname, ".vitepress/config-schema.json"); const schema: JsonSchema = JSON.parse(fs.readFileSync(schemaPath, "utf-8")); - return schema; + return { schema }; }, }; diff --git a/docs/config-reference.md b/docs/config-reference.md index 4cc75ff..8f6046b 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -3,7 +3,9 @@ title: Configuration Reference --- # Configuration Reference @@ -310,11 +209,11 @@ All account types share these fields: - -{{ key }} -{{ formatType(prop) }} -{{ isRequired(accountType.schema.required, key) }} -{{ prop.description || '' }} + +{{ field.key }} +{{ formatType(field.prop) }} +{{ isRequired(accountType.schema.required, field.key) }} +{{ field.prop.description || '' }} @@ -323,12 +222,6 @@ All account types share these fields: -## Example Configuration - -Here's a complete example showing all configuration options: - -
yaml
{{ exampleYaml }}
- ## Notes - You can configure multiple accounts of the same type diff --git a/docs/configuration.md b/docs/configuration.md index d68126a..b9ef70c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,30 +1,64 @@ --- -title: Configuration +title: Overview --- -# Configuration -Here's a full example configuration file with all available options: +# Configuration Overview + +ynab-connect uses a YAML configuration file to define which accounts to sync and how to connect to YNAB. + +## Configuration File Location + +- **Production**: `/config.yaml` + +## Basic Structure + +The configuration file has three main sections: + +### YNAB Configuration + +Configure your YNAB API access token and budget ID. See the [Create YNAB Token](/guide/create-ynab-token) guide for instructions. ```yaml ynab: - accessToken: "" - budgetId: "" -browser: - endpoint: "" # Optional + accessToken: "your-ynab-access-token" + budgetId: "your-ynab-budget-id" +``` + +### Accounts + +Define the accounts you want to sync. Each account requires: +- A unique name +- The connector type (e.g., `trading212`, `uk_student_loan`) +- A sync interval (cron expression) +- The YNAB account ID to sync to +- Connector-specific credentials + +```yaml accounts: - - name: "Student Loan" - type: "uk_student_loan" - interval: "0 2 * * *" - ynabAccountId: "" - email: "" - password: "" - secretAnswer: "" - - name: "Trading 212" - type: "trading212" - interval: "0 * * * *" - ynabAccountId: "" - trading212ApiKey: "" - trading212SecretKey: "" + - name: "My Trading 212" + type: "trading212" + interval: "0 * * * *" # Every hour + ynabAccountId: "your-ynab-account-id" + trading212ApiKey: "your-api-key" + trading212SecretKey: "your-api-secret" ``` +### Browser (Optional) + +Some connectors require browser automation. See [Browser Setup](/browser) for details. + +```yaml +browser: + endpoint: "wss://chrome.browserless.io?token=YOUR_TOKEN" +``` + +## Next Steps + +- See the [Configuration Reference](/config-reference) for a complete list of all configuration options +- Check out the [Connectors](/connectors/trading-212) section for connector-specific setup guides +- Use [crontab.guru](https://crontab.guru/) to create cron expressions for sync intervals + ## Notes -You may configure multiple accounts of the same type. \ No newline at end of file + +- You can configure multiple accounts of the same type +- Each account must have a unique name +- The configuration file is validated on startup \ No newline at end of file diff --git a/docs/guide/sms-forwarding.md b/docs/guide/sms-forwarding.md index dc905e8..80b26d2 100644 --- a/docs/guide/sms-forwarding.md +++ b/docs/guide/sms-forwarding.md @@ -9,18 +9,17 @@ Some connectors require two-factor authentication via SMS. To enable automatic a The SMS forwarding setup allows ynab-connect to receive 2FA codes sent to your phone automatically. When a connector requires a 2FA code, it will wait for the code to be forwarded to the application. -## Requirements -- A smartphone (iOS or Android) -- Access to SMS messages -- A method to forward SMS to the ynab-connect server +## Setup Instructions (iOS) -## Setup Instructions +To be added. -Documentation for setting up SMS forwarding will be added here. +## Setup Instructions (Android) + +To be added. ## Supported Connectors -The following connectors support 2FA via SMS: +The following connectors require SMS forwarding for two-factor authentication: - [Standard Life Pension](/connectors/standard-life-pension)