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:
vvzvlad
2026-06-16 18:54:29 +03:00
parent cc584a97f3
commit ef223e13ff
19 changed files with 2736 additions and 0 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
node_modules/
build/
.env
data/
test/
coverage/
.github/
*.md
.DS_Store

20
.env.example Normal file
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View File

24
docker-compose.yml Normal file
View 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

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View 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
View 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
View 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
View 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));
}

View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.ts'],
},
});