chore(scaffold): bootstrap docmost-sync Node/TS project skeleton
Set up the project structure per the new-project guide, adapted from the Python skeleton to the Node/TS stack fixed in SPEC.md (reuses docmost-mcp). Scaffold only — the sync engine is not implemented yet. - src/settings.ts: single config layer on zod, schema keyed by real ENV names; credentials and own-service address have no default (fail fast). - src/config-errors.ts: loadSettingsOrExit — clear startup message naming the missing/invalid env var instead of a raw stack trace; exit(1). - src/index.ts: thin entry point that validates config and logs (stub). - test/: vitest unit tests for settings parsing and config errors (10 tests). - Makefile (install/env/build/test/run/dev/clean), strict tsconfig, vitest. - Dockerfile (single-stage, no EXPOSE, prunes dev deps), docker-compose (daemon, volume on /app/data, watchtower), ghcr CI with build needs test. - .env.example, .gitignore/.dockerignore, AGENTS.md, README.md. - Pinned deps (dotenv, zod) + committed package-lock.json.
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
test/
|
||||||
|
coverage/
|
||||||
|
.github/
|
||||||
|
*.md
|
||||||
|
.DS_Store
|
||||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Docmost connection (our own instance — no default in code)
|
||||||
|
DOCMOST_API_URL=https://docmost.example.com
|
||||||
|
|
||||||
|
# Docmost credentials for /auth/login (never commit real values)
|
||||||
|
DOCMOST_EMAIL=you@example.com
|
||||||
|
DOCMOST_PASSWORD=your_password_here
|
||||||
|
|
||||||
|
# Which Docmost space to mirror
|
||||||
|
DOCMOST_SPACE_ID=your_space_id_here
|
||||||
|
|
||||||
|
# Local git vault (state store). Kept under data/ so the docker volume persists it.
|
||||||
|
VAULT_PATH=data/vault
|
||||||
|
|
||||||
|
# Optional git remote the vault pushes to (leave unset to stay local-only)
|
||||||
|
# GIT_REMOTE=git@github.com:you/docmost-vault.git
|
||||||
|
|
||||||
|
# Tunables (sensible defaults; override only if needed)
|
||||||
|
POLL_INTERVAL_MS=15000
|
||||||
|
DEBOUNCE_MS=2000
|
||||||
|
LOG_LEVEL=info
|
||||||
54
.github/workflows/ghcr-check-publish.yml
vendored
Normal file
54
.github/workflows/ghcr-check-publish.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
name: Test and publish image
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Type-check / build
|
||||||
|
run: npm run build
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build the Docker image
|
||||||
|
run: docker build . --file Dockerfile
|
||||||
|
--tag ghcr.io/${{ github.repository }}:latest
|
||||||
|
--tag ghcr.io/${{ github.repository }}:${{ github.sha }}
|
||||||
|
# Publish only from main: on PRs the image is built as a check but never
|
||||||
|
# pushed — avoids overwriting :latest with unreviewed code and failing on
|
||||||
|
# fork PRs whose GITHUB_TOKEN has no packages:write.
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Push Docker images
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: |
|
||||||
|
docker push ghcr.io/${{ github.repository }}:latest
|
||||||
|
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
.DS_Store
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
|
.claude/worktrees/
|
||||||
61
AGENTS.md
Normal file
61
AGENTS.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Onboarding notes for agents working on **docmost-sync** (Node / TypeScript, ESM).
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A daemon that bidirectionally syncs Docmost articles with a local Markdown git
|
||||||
|
vault (git is the state store). It reuses the sibling project **docmost-mcp** as
|
||||||
|
a library (DocmostClient, ProseMirror ↔ Markdown converter, collab-write).
|
||||||
|
|
||||||
|
**Status: scaffold only — the sync engine is NOT implemented yet.** `src/index.ts`
|
||||||
|
is a thin stub that validates config and exits. See `SPEC.md` for the full design
|
||||||
|
and the phased plan before adding engine logic.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
- `src/` — application code.
|
||||||
|
- `src/settings.ts` — the single config entry point (zod schema keyed by the
|
||||||
|
real ENV var names; `parseSettings` is pure, `loadSettings` reads `.env`).
|
||||||
|
- `src/config-errors.ts` — `loadSettingsOrExit` turns a config error into a
|
||||||
|
clear startup message that names the missing/invalid variable, then exits.
|
||||||
|
- `src/index.ts` — thin entry point.
|
||||||
|
- `test/` — vitest tests (`*.test.ts`).
|
||||||
|
- `data/` — all mutable runtime state (the git vault lives here). Gitignored;
|
||||||
|
mounted as a docker volume in production. Never put code/static assets here.
|
||||||
|
- `build/` — compiled output (`tsc`). Gitignored.
|
||||||
|
|
||||||
|
Relative imports inside `src/` use the `.js` extension (NodeNext), e.g.
|
||||||
|
`import { loadSettings } from './settings.js'`.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- `make install` — install dependencies (`npm ci`).
|
||||||
|
- `make env` — create `.env` from `.env.example` (or `cp .env.example .env`),
|
||||||
|
then fill in the values.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
- `make test` — run the test suite (vitest).
|
||||||
|
- `make run` — build and run the app.
|
||||||
|
- `make dev` — run in watch mode (tsx).
|
||||||
|
|
||||||
|
`make` (or `make help`) lists all targets.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- All mutable state lives ONLY under `data/`. Static assets are code, never in `data/`.
|
||||||
|
- All config and credentials come ONLY from ENV / `.env`, read through
|
||||||
|
`src/settings.ts`. Credentials and the addresses of our own services that the
|
||||||
|
user provides go ONLY into `.env` (never into code, never as inline env vars on
|
||||||
|
the command line) and are read through settings.
|
||||||
|
- No default/example credentials in code. Addresses of our own services (Docmost)
|
||||||
|
have NO default either. A missing required variable must FAIL AT STARTUP with a
|
||||||
|
clear message that names the variable — no raw stack trace.
|
||||||
|
- Defaults are allowed only for non-secret tunables (log level, intervals, vault
|
||||||
|
path under `data/`).
|
||||||
|
- All code comments are written in ENGLISH.
|
||||||
|
- Repeated actions go through the `Makefile`.
|
||||||
|
- Tests are required for new code. In CI the `build` job needs `test` (tests gate
|
||||||
|
the docker build).
|
||||||
|
- No `EXPOSE` in the Dockerfile — this is a daemon with no inbound HTTP port.
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dependencies first (better layer caching): copy manifests, install from lock.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Runtime state directory (mounted as a volume in production).
|
||||||
|
RUN mkdir -p data
|
||||||
|
|
||||||
|
# Source + TS config, then compile to build/.
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src/ src/
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Drop dev dependencies (typescript, tsx, vitest) to slim the runtime image.
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
CMD ["node", "build/index.js"]
|
||||||
31
Makefile
Normal file
31
Makefile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help install env build test run dev clean
|
||||||
|
|
||||||
|
help: ## Show this help
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||||
|
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
node_modules: package.json package-lock.json
|
||||||
|
npm ci
|
||||||
|
@touch node_modules
|
||||||
|
|
||||||
|
install: node_modules ## Install dependencies (npm ci)
|
||||||
|
|
||||||
|
env: ## Create .env from the template if missing
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
|
||||||
|
build: install ## Compile TypeScript to build/
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
test: install ## Run the test suite
|
||||||
|
npm test
|
||||||
|
|
||||||
|
run: build ## Build and run the app
|
||||||
|
node build/index.js
|
||||||
|
|
||||||
|
dev: install ## Run in watch mode (tsx)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
clean: ## Remove build artifacts and node_modules
|
||||||
|
rm -rf build node_modules
|
||||||
53
README.md
Normal file
53
README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# docmost-sync
|
||||||
|
|
||||||
|
Bidirectional sync between Docmost articles and a local Markdown git vault — the
|
||||||
|
git repository is the state store. For the full design and the phased
|
||||||
|
implementation plan, see [`SPEC.md`](./SPEC.md).
|
||||||
|
|
||||||
|
> **Status: scaffold only — the sync engine is not implemented yet.**
|
||||||
|
> `src/index.ts` validates configuration and exits. The engine described in
|
||||||
|
> `SPEC.md` is out of scope for this scaffold.
|
||||||
|
|
||||||
|
It reuses the sibling project **docmost-mcp** as a library (DocmostClient,
|
||||||
|
ProseMirror ↔ Markdown converter, collab-write).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All config comes from ENV / `.env` (see [`.env.example`](./.env.example)), read
|
||||||
|
through the single settings layer in `src/settings.ts`. A missing required
|
||||||
|
variable fails at startup with a clear message that names it.
|
||||||
|
|
||||||
|
| Variable | Required | Default | Meaning |
|
||||||
|
| ------------------ | :------: | ------------ | -------------------------------------------------------------- |
|
||||||
|
| `DOCMOST_API_URL` | yes | — | Base URL of our Docmost instance (used for `/auth/login`). |
|
||||||
|
| `DOCMOST_EMAIL` | yes | — | Docmost login email. |
|
||||||
|
| `DOCMOST_PASSWORD` | yes | — | Docmost login password. |
|
||||||
|
| `DOCMOST_SPACE_ID` | yes | — | The Docmost space to mirror. |
|
||||||
|
| `VAULT_PATH` | no | `data/vault` | Local git vault path (kept under `data/` for the volume). |
|
||||||
|
| `GIT_REMOTE` | no | _(unset)_ | Optional git remote the vault pushes to; empty = local-only. |
|
||||||
|
| `POLL_INTERVAL_MS` | no | `15000` | How often to poll Docmost for changes (ms). |
|
||||||
|
| `DEBOUNCE_MS` | no | `2000` | Debounce window for local file changes (ms). |
|
||||||
|
| `LOG_LEVEL` | no | `info` | One of `debug`, `info`, `warn`, `error`. |
|
||||||
|
|
||||||
|
Credentials and the address of our own Docmost instance have NO default — they
|
||||||
|
go ONLY into `.env`, never into code or inline command-line env vars.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make install # install dependencies (npm ci)
|
||||||
|
make env # create .env from .env.example, then fill it in
|
||||||
|
make test # run the test suite (vitest)
|
||||||
|
make run # build and run
|
||||||
|
make dev # run in watch mode (tsx)
|
||||||
|
```
|
||||||
|
|
||||||
|
`make` (or `make help`) lists all targets.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Production runs a prebuilt image from `ghcr.io` (no build on prod):
|
||||||
|
`docker-compose.yml` pulls `ghcr.io/vvzvlad/docmost-sync:latest`, mounts a
|
||||||
|
volume at `/app/data`, and [watchtower](https://containrrr.dev/watchtower/)
|
||||||
|
auto-updates the container when a new image is published. CI (GitHub Actions)
|
||||||
|
builds and pushes the image; the `build` job runs only after `test` passes.
|
||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
volumes:
|
||||||
|
docmost_sync:
|
||||||
|
|
||||||
|
services:
|
||||||
|
docmost-sync:
|
||||||
|
image: ghcr.io/vvzvlad/docmost-sync:latest
|
||||||
|
container_name: docmost-sync
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- docmost_sync:/app/data # git vault + runtime state survive restarts/updates
|
||||||
|
environment:
|
||||||
|
DOCMOST_API_URL: XXX
|
||||||
|
DOCMOST_EMAIL: XXX
|
||||||
|
DOCMOST_PASSWORD: XXX
|
||||||
|
DOCMOST_SPACE_ID: XXX
|
||||||
|
# GIT_REMOTE: XXX # optional git remote for the vault
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-file: 5
|
||||||
|
max-size: 10m
|
||||||
|
labels:
|
||||||
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
2151
package-lock.json
generated
Normal file
2151
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "docmost-sync",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Bidirectional sync daemon between Docmost articles and a local Markdown git vault.",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node build/index.js",
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "17.4.2",
|
||||||
|
"zod": "3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.19.21",
|
||||||
|
"tsx": "4.22.4",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "3.2.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/config-errors.ts
Normal file
36
src/config-errors.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
|
// Turn a ZodError from settings validation into a clear, actionable startup
|
||||||
|
// message that names the offending env var(s), then exit(1) — no raw stack
|
||||||
|
// trace. Mirrors the Python new-project skeleton's load_settings_or_exit.
|
||||||
|
// A non-ZodError is left to propagate unchanged.
|
||||||
|
export function loadSettingsOrExit<T>(factory: () => T): T {
|
||||||
|
try {
|
||||||
|
return factory();
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ZodError)) throw err;
|
||||||
|
const missing: string[] = [];
|
||||||
|
const invalid: string[] = [];
|
||||||
|
for (const issue of err.issues) {
|
||||||
|
const name = issue.path.length ? String(issue.path[0]) : '?';
|
||||||
|
const isMissing =
|
||||||
|
issue.code === 'invalid_type' &&
|
||||||
|
(issue as { received?: unknown }).received === 'undefined';
|
||||||
|
if (isMissing) missing.push(name);
|
||||||
|
else invalid.push(`${name}: ${issue.message}`);
|
||||||
|
}
|
||||||
|
const lines = ['Configuration error in environment / .env:'];
|
||||||
|
if (missing.length) {
|
||||||
|
lines.push(' Missing required variable(s):');
|
||||||
|
for (const n of [...new Set(missing)]) lines.push(` - ${n}`);
|
||||||
|
}
|
||||||
|
if (invalid.length) {
|
||||||
|
lines.push(' Invalid value(s):');
|
||||||
|
for (const item of invalid) lines.push(` - ${item}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Set them in .env (see .env.example) and try again.');
|
||||||
|
process.stderr.write(lines.join('\n') + '\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/index.ts
Normal file
14
src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { loadSettings } from './settings.js';
|
||||||
|
|
||||||
|
// Thin entry point. loadSettings() validates the environment and exits with a
|
||||||
|
// clear message if anything is missing/invalid. The sync engine is not
|
||||||
|
// implemented yet — see SPEC.md for the design and the phased plan.
|
||||||
|
function main(): void {
|
||||||
|
const settings = loadSettings();
|
||||||
|
console.log(`docmost-sync starting (log level: ${settings.logLevel})`);
|
||||||
|
console.log(`Docmost: ${settings.docmostApiUrl} (space ${settings.docmostSpaceId})`);
|
||||||
|
console.log(`Vault: ${settings.vaultPath}`);
|
||||||
|
console.log('Engine not implemented yet — scaffold only. See SPEC.md.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
66
src/settings.ts
Normal file
66
src/settings.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { config as loadDotenv } from 'dotenv';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { loadSettingsOrExit } from './config-errors.js';
|
||||||
|
|
||||||
|
// Schema keyed by the real ENV variable names so validation errors name the
|
||||||
|
// exact variable. Credentials and the address of our OWN Docmost instance have
|
||||||
|
// NO default — a missing value must fail at startup, never silently fall back.
|
||||||
|
export const envSchema = z.object({
|
||||||
|
// Docmost connection — address of our own instance, no default.
|
||||||
|
DOCMOST_API_URL: z.string().url(),
|
||||||
|
// Credentials for /auth/login — no default, never hardcoded.
|
||||||
|
DOCMOST_EMAIL: z.string().min(1),
|
||||||
|
DOCMOST_PASSWORD: z.string().min(1),
|
||||||
|
// Which Docmost space to mirror.
|
||||||
|
DOCMOST_SPACE_ID: z.string().min(1),
|
||||||
|
|
||||||
|
// Local git vault (state store) — kept under data/ so the volume persists it.
|
||||||
|
VAULT_PATH: z.string().min(1).default('data/vault'),
|
||||||
|
// Optional git remote the vault pushes to. Empty string is treated as unset.
|
||||||
|
GIT_REMOTE: z.preprocess(
|
||||||
|
(v) => (v === '' ? undefined : v),
|
||||||
|
z.string().min(1).optional(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Non-secret tunables — sensible defaults are fine.
|
||||||
|
POLL_INTERVAL_MS: z.coerce.number().int().positive().default(15000),
|
||||||
|
DEBOUNCE_MS: z.coerce.number().int().positive().default(2000),
|
||||||
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Settings = {
|
||||||
|
docmostApiUrl: string;
|
||||||
|
docmostEmail: string;
|
||||||
|
docmostPassword: string;
|
||||||
|
docmostSpaceId: string;
|
||||||
|
vaultPath: string;
|
||||||
|
gitRemote?: string;
|
||||||
|
pollIntervalMs: number;
|
||||||
|
debounceMs: number;
|
||||||
|
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pure: validate a raw environment object and map it to a typed Settings.
|
||||||
|
// Throws ZodError on bad config. No side effects — safe to import in tests.
|
||||||
|
export function parseSettings(env: NodeJS.ProcessEnv): Settings {
|
||||||
|
const e = envSchema.parse(env);
|
||||||
|
return {
|
||||||
|
docmostApiUrl: e.DOCMOST_API_URL,
|
||||||
|
docmostEmail: e.DOCMOST_EMAIL,
|
||||||
|
docmostPassword: e.DOCMOST_PASSWORD,
|
||||||
|
docmostSpaceId: e.DOCMOST_SPACE_ID,
|
||||||
|
vaultPath: e.VAULT_PATH,
|
||||||
|
gitRemote: e.GIT_REMOTE,
|
||||||
|
pollIntervalMs: e.POLL_INTERVAL_MS,
|
||||||
|
debounceMs: e.DEBOUNCE_MS,
|
||||||
|
logLevel: e.LOG_LEVEL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load .env (if present; absent in prod where env comes from docker-compose),
|
||||||
|
// then build validated settings, failing fast with a clear message instead of a
|
||||||
|
// raw stack trace. Call once at startup from the entry point.
|
||||||
|
export function loadSettings(): Settings {
|
||||||
|
loadDotenv();
|
||||||
|
return loadSettingsOrExit(() => parseSettings(process.env));
|
||||||
|
}
|
||||||
56
test/config-errors.test.ts
Normal file
56
test/config-errors.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { loadSettingsOrExit } from '../src/config-errors.js';
|
||||||
|
|
||||||
|
describe('loadSettingsOrExit', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the factory value and does not exit on success', () => {
|
||||||
|
const exitSpy = vi
|
||||||
|
.spyOn(process, 'exit')
|
||||||
|
.mockImplementation((() => undefined) as never);
|
||||||
|
|
||||||
|
const result = loadSettingsOrExit(() => ({ ok: true }));
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(exitSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints a named-variable message and exits(1) on a ZodError', () => {
|
||||||
|
// Mock process.exit to throw so control stops at the exit point, mirroring
|
||||||
|
// the real exit-the-process behaviour without killing the test runner.
|
||||||
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((
|
||||||
|
code?: number,
|
||||||
|
) => {
|
||||||
|
throw new Error(`exit:${code}`);
|
||||||
|
}) as never);
|
||||||
|
const writeSpy = vi
|
||||||
|
.spyOn(process.stderr, 'write')
|
||||||
|
.mockImplementation(() => true);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
loadSettingsOrExit(() => z.object({ FOO: z.string() }).parse({})),
|
||||||
|
).toThrow('exit:1');
|
||||||
|
|
||||||
|
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||||
|
const written = writeSpy.mock.calls.map((c) => String(c[0])).join('');
|
||||||
|
expect(written).toContain('Missing required variable(s)');
|
||||||
|
expect(written).toContain('FOO');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a non-ZodError without exiting', () => {
|
||||||
|
const exitSpy = vi
|
||||||
|
.spyOn(process, 'exit')
|
||||||
|
.mockImplementation((() => undefined) as never);
|
||||||
|
const boom = new Error('x');
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
loadSettingsOrExit(() => {
|
||||||
|
throw boom;
|
||||||
|
}),
|
||||||
|
).toThrow(boom);
|
||||||
|
expect(exitSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
76
test/settings.test.ts
Normal file
76
test/settings.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseSettings } from '../src/settings.js';
|
||||||
|
|
||||||
|
// A minimal valid environment with every required variable set. Tests clone and
|
||||||
|
// mutate this object so process.env is never touched (hermetic).
|
||||||
|
const baseEnv = {
|
||||||
|
DOCMOST_API_URL: 'https://docmost.example.com',
|
||||||
|
DOCMOST_EMAIL: 'you@example.com',
|
||||||
|
DOCMOST_PASSWORD: 'secret',
|
||||||
|
DOCMOST_SPACE_ID: 'space-123',
|
||||||
|
} as NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
describe('parseSettings', () => {
|
||||||
|
it('maps a full valid env to the camelCase Settings object', () => {
|
||||||
|
const settings = parseSettings({
|
||||||
|
...baseEnv,
|
||||||
|
VAULT_PATH: 'data/custom-vault',
|
||||||
|
GIT_REMOTE: 'git@github.com:you/vault.git',
|
||||||
|
POLL_INTERVAL_MS: '5000',
|
||||||
|
DEBOUNCE_MS: '1000',
|
||||||
|
LOG_LEVEL: 'debug',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(settings).toEqual({
|
||||||
|
docmostApiUrl: 'https://docmost.example.com',
|
||||||
|
docmostEmail: 'you@example.com',
|
||||||
|
docmostPassword: 'secret',
|
||||||
|
docmostSpaceId: 'space-123',
|
||||||
|
vaultPath: 'data/custom-vault',
|
||||||
|
gitRemote: 'git@github.com:you/vault.git',
|
||||||
|
pollIntervalMs: 5000,
|
||||||
|
debounceMs: 1000,
|
||||||
|
logLevel: 'debug',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies defaults when optional vars are omitted', () => {
|
||||||
|
const settings = parseSettings({ ...baseEnv });
|
||||||
|
|
||||||
|
expect(settings.vaultPath).toBe('data/vault');
|
||||||
|
expect(settings.pollIntervalMs).toBe(15000);
|
||||||
|
expect(settings.debounceMs).toBe(2000);
|
||||||
|
expect(settings.logLevel).toBe('info');
|
||||||
|
expect(settings.gitRemote).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces numeric strings to numbers', () => {
|
||||||
|
const settings = parseSettings({ ...baseEnv, POLL_INTERVAL_MS: '3000' });
|
||||||
|
|
||||||
|
expect(settings.pollIntervalMs).toBe(3000);
|
||||||
|
expect(typeof settings.pollIntervalMs).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when a required var is missing', () => {
|
||||||
|
const { DOCMOST_API_URL: _omit, ...rest } = baseEnv;
|
||||||
|
void _omit;
|
||||||
|
expect(() => parseSettings(rest as NodeJS.ProcessEnv)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on an invalid LOG_LEVEL', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseSettings({ ...baseEnv, LOG_LEVEL: 'verbose' }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a non-numeric POLL_INTERVAL_MS', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseSettings({ ...baseEnv, POLL_INTERVAL_MS: 'soon' }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty GIT_REMOTE as undefined', () => {
|
||||||
|
const settings = parseSettings({ ...baseEnv, GIT_REMOTE: '' });
|
||||||
|
expect(settings.gitRemote).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "./build",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user