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

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();
});
});