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