diff --git a/package-lock.json b/package-lock.json index 5c71b86..226dcb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,26 +9,28 @@ "version": "0.0.3", "license": "MIT", "dependencies": { - "chalk": "*", - "commander": "*", - "open": "*" + "chalk": "latest", + "commander": "latest", + "open": "latest", + "yaml": "^2.9.0", + "zod": "^4.4.3" }, "bin": { "agentage": "dist/cli.js" }, "devDependencies": { "@anthropic-ai/sdk": "0.110.0", - "@playwright/test": "*", - "@types/node": "*", - "@typescript-eslint/eslint-plugin": "*", - "@typescript-eslint/parser": "*", - "@vitest/coverage-v8": "*", - "eslint": "*", - "eslint-config-prettier": "*", - "eslint-plugin-prettier": "*", + "@playwright/test": "latest", + "@types/node": "latest", + "@typescript-eslint/eslint-plugin": "latest", + "@typescript-eslint/parser": "latest", + "@vitest/coverage-v8": "latest", + "eslint": "latest", + "eslint-config-prettier": "latest", + "eslint-plugin-prettier": "latest", "prettier": "latest", - "typescript": "*", - "vitest": "*" + "typescript": "latest", + "vitest": "latest" }, "engines": { "node": ">=22.0.0", @@ -3103,6 +3105,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3115,6 +3132,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index edfbaa3..a779197 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "dependencies": { "chalk": "latest", "commander": "latest", - "open": "latest" + "open": "latest", + "yaml": "^2.9.0", + "zod": "^4.4.3" }, "devDependencies": { "@anthropic-ai/sdk": "0.110.0", diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 963bd73..9dae847 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -12,6 +12,7 @@ import { type TokenResponse, } from '../lib/oauth.js'; import { links, siteFqdn } from '../lib/origins.js'; +import { ensureVaultsConfig } from '../lib/vaults.js'; import { runStatus } from './status.js'; export interface SetupOptions { @@ -80,6 +81,7 @@ export const runSetup = async ( const fqdn = siteFqdn(); const target = links(fqdn); ensureConfigDir(); + ensureVaultsConfig(); const { verifier, challenge } = pkcePair(); const state = randomState(); const server = await deps.startServer(state); diff --git a/src/lib/vaults.schema.test.ts b/src/lib/vaults.schema.test.ts new file mode 100644 index 0000000..3be8491 --- /dev/null +++ b/src/lib/vaults.schema.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { Discover, Vault, VaultsConfig } from './vaults.schema.js'; + +describe('Vault schema', () => { + it('accepts a local vault (no sync block)', () => { + const v = Vault.parse({ name: 'scratch', type: 'local', path: '~/vaults/scratch' }); + expect(v).toEqual({ name: 'scratch', type: 'local', path: '~/vaults/scratch' }); + }); + + it('defaults a couchdb vault to server agentage + continuous sync', () => { + const v = Vault.parse({ name: 'default', type: 'couchdb', path: '~/vaults/default' }); + expect(v).toMatchObject({ + type: 'couchdb', + server: 'agentage', + sync: { auto: true, mode: 'continuous', ignore: ['.obsidian/', 'data.json'] }, + }); + }); + + it('defaults a git vault sync block and requires a remote', () => { + const v = Vault.parse({ + name: 'work', + type: 'git', + path: '~/vaults/work', + remote: 'git@github.com:me/work.git', + }); + expect(v).toMatchObject({ sync: { interval: '5m', message: 'vault: auto-sync', auto: true } }); + expect(() => Vault.parse({ name: 'work', type: 'git', path: '~/w' })).toThrow(); + }); + + it('setting ignore replaces the defaults ([] = sync everything)', () => { + const v = Vault.parse({ + name: 'work', + type: 'couchdb', + path: '~/w', + sync: { ignore: [] }, + }); + expect(v).toMatchObject({ sync: { ignore: [] } }); + }); + + it('hard-fails an unknown type', () => { + expect(() => Vault.parse({ name: 'x', type: 'ftp', path: '~/x' })).toThrow(); + }); + + it('rejects a name that breaks the cloud-path allowlist', () => { + expect(() => Vault.parse({ name: 'has spaces', type: 'local', path: '~/x' })).toThrow(); + expect(() => Vault.parse({ name: 'a/b', type: 'local', path: '~/x' })).toThrow(); + }); + + it('rejects unknown keys (strict)', () => { + expect(() => Vault.parse({ name: 'x', type: 'local', path: '~/x', mcp: ['local'] })).toThrow(); + }); +}); + +describe('Discover schema', () => { + it('defaults type couchdb, autosync on, dot/underscore ignores', () => { + expect(Discover.parse({ path: '~/vaults' })).toEqual({ + path: '~/vaults', + type: 'couchdb', + autosync: true, + ignore: ['.*', '_*'], + }); + }); +}); + +describe('VaultsConfig schema', () => { + it('defaults empty discover + vaults arrays', () => { + expect(VaultsConfig.parse({ version: 1 })).toEqual({ version: 1, discover: [], vaults: [] }); + }); + + it('accepts and ignores $schema', () => { + const c = VaultsConfig.parse({ $schema: 'https://x/y.json', version: 1 }); + expect(c.version).toBe(1); + }); + + it('rejects a version other than 1', () => { + expect(() => VaultsConfig.parse({ version: 2 })).toThrow(); + }); + + it('rejects duplicate vault names', () => { + expect(() => + VaultsConfig.parse({ + version: 1, + vaults: [ + { name: 'dup', type: 'local', path: '~/a' }, + { name: 'dup', type: 'local', path: '~/b' }, + ], + }) + ).toThrow(/duplicate vault name/); + }); +}); diff --git a/src/lib/vaults.schema.ts b/src/lib/vaults.schema.ts new file mode 100644 index 0000000..a6ea515 --- /dev/null +++ b/src/lib/vaults.schema.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; + +// The public JSON Schema location (served by the landing). Written into the file on +// create so editors get autocomplete + inline validation; accepted-and-ignored on load. +// A fixed production URL by design: editor tooling fetches it regardless of the CLI's +// target FQDN, so it is intentionally not derived from AGENTAGE_SITE_FQDN. +export const VAULTS_SCHEMA_URL = 'https://agentage.io/schemas/vaults.schema.json'; + +// ADR-013 AA-4: every name stays a valid cloud path segment (`/.git`). +export const VaultName = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'invalid vault name'); + +const Base = z.object({ name: VaultName, path: z.string() }).strict(); + +// Setting `ignore` REPLACES these defaults ([] = sync everything). +const SyncIgnore = z.array(z.string()).default(['.obsidian/', 'data.json']); +// false = the daemon skips the vault; manual `vault sync ` only. +const SyncAuto = z.boolean().default(true); + +export const Vault = z.discriminatedUnion('type', [ + Base.extend({ type: z.literal('local') }), + Base.extend({ + type: z.literal('git'), + remote: z.string().min(1), + sync: z + .object({ + auto: SyncAuto, + interval: z.string().default('5m'), + message: z.string().default('vault: auto-sync'), + ignore: SyncIgnore, + }) + .strict() + // prefault (not default) so zod 4 re-parses `{}` and applies the inner defaults + .prefault({}), + }), + Base.extend({ + type: z.literal('couchdb'), + // A raw CouchDB URL for self-hosted is a later extension. + server: z.literal('agentage').default('agentage'), + sync: z + .object({ + auto: SyncAuto, + mode: z.enum(['continuous', 'interval']).default('continuous'), + ignore: SyncIgnore, + }) + .strict() + // prefault (not default) so zod 4 re-parses `{}` and applies the inner defaults + .prefault({}), + }), +]); +export type Vault = z.infer; +export type VaultType = Vault['type']; + +// `git` cannot be a discovery type (it needs a remote) - use `vault add --git`. +export const Discover = z + .object({ + path: z.string(), + type: z.enum(['couchdb', 'local']).default('couchdb'), + // seeds discovered entries' sync.auto + autosync: z.boolean().default(true), + ignore: z.array(z.string()).default(['.*', '_*']), + }) + .strict(); +export type Discover = z.infer; + +export const VaultsConfig = z + .object({ + // editor-tooling pointer, written on create, otherwise ignored + $schema: z.string().optional(), + version: z.literal(1), + discover: z.array(Discover).default([]), + vaults: z.array(Vault).default([]), + }) + .strict() + .refine((c) => new Set(c.vaults.map((v) => v.name)).size === c.vaults.length, { + message: 'duplicate vault name', + }); +export type VaultsConfig = z.infer; diff --git a/src/lib/vaults.test.ts b/src/lib/vaults.test.ts new file mode 100644 index 0000000..b559236 --- /dev/null +++ b/src/lib/vaults.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ensureConfigDir } from './config.js'; +import type { VaultsConfig } from './vaults.schema.js'; +import { + ensureVaultsConfig, + loadVaultsConfig, + saveVaultsConfig, + vaultsJsonPath, + vaultsYamlPath, +} from './vaults.js'; + +const write = (path: string, text: string): void => { + ensureConfigDir(); + writeFileSync(path, text); +}; + +describe('vaults config store', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'agentage-vaults-')); + process.env['AGENTAGE_CONFIG_DIR'] = join(dir, 'cfg'); + }); + + afterEach(() => { + delete process.env['AGENTAGE_CONFIG_DIR']; + rmSync(dir, { recursive: true, force: true }); + }); + + it('returns an empty config when no file exists (zero-config)', () => { + const loaded = loadVaultsConfig(); + expect(loaded).toEqual({ + config: { version: 1, discover: [], vaults: [] }, + source: null, + }); + }); + + it('loads and validates the array shape from vaults.json', () => { + write( + vaultsJsonPath(), + JSON.stringify({ version: 1, vaults: [{ name: 'work', type: 'local', path: '~/w' }] }) + ); + const { config } = loadVaultsConfig(); + expect(config.vaults[0]).toMatchObject({ name: 'work', type: 'local' }); + }); + + it('loads YAML from vaults.yaml', () => { + write( + vaultsYamlPath(), + 'version: 1\nvaults:\n - name: work\n type: local\n path: ~/w\n' + ); + const { config, source } = loadVaultsConfig(); + expect(config.vaults[0]).toMatchObject({ name: 'work' }); + expect(source).toBe(vaultsYamlPath()); + }); + + it('prefers vaults.json when both files exist', () => { + write(vaultsJsonPath(), JSON.stringify({ version: 1, vaults: [] })); + write(vaultsYamlPath(), 'version: 1\nvaults:\n - name: y\n type: local\n path: ~/y\n'); + const { config, source } = loadVaultsConfig(); + expect(source).toBe(vaultsJsonPath()); + expect(config.vaults).toHaveLength(0); + }); + + it('rejects a legacy object-map file (no migration)', () => { + write( + vaultsJsonPath(), + JSON.stringify({ vaults: { work: { origin: [{ remote: 'agentage' }] } } }) + ); + expect(() => loadVaultsConfig()).toThrow(); + }); + + it('throws on an unknown vault type', () => { + write(vaultsJsonPath(), JSON.stringify({ version: 1, vaults: [{ name: 'x', type: 'ftp' }] })); + expect(() => loadVaultsConfig()).toThrow(); + }); + + it('round-trips a save with $schema first, 0600, and a trailing newline', () => { + const config: VaultsConfig = { + version: 1, + discover: [], + vaults: [{ name: 'work', type: 'local', path: '~/w' }], + }; + const path = saveVaultsConfig(config); + const raw = readFileSync(path, 'utf-8'); + expect(raw.endsWith('\n')).toBe(true); + expect(Object.keys(JSON.parse(raw))[0]).toBe('$schema'); + expect(statSync(path).mode & 0o777).toBe(0o600); + const { config: reloaded } = loadVaultsConfig(); + expect(reloaded.vaults).toEqual(config.vaults); + }); + + it('always rewrites the canonical $schema url on save', () => { + const path = saveVaultsConfig({ + $schema: 'https://evil/x.json', + version: 1, + discover: [], + vaults: [], + } as VaultsConfig); + expect(JSON.parse(readFileSync(path, 'utf-8'))['$schema']).toBe( + 'https://agentage.io/schemas/vaults.schema.json' + ); + }); + + it('scaffolds an empty, $schema-linked vaults.json when none exists', () => { + ensureVaultsConfig(); + const raw = JSON.parse(readFileSync(vaultsJsonPath(), 'utf-8')); + expect(raw).toEqual({ + $schema: 'https://agentage.io/schemas/vaults.schema.json', + version: 1, + discover: [], + vaults: [], + }); + }); + + it('leaves an existing vaults.json untouched when scaffolding', () => { + write(vaultsJsonPath(), JSON.stringify({ version: 1, vaults: [] })); + ensureVaultsConfig(); + expect(readFileSync(vaultsJsonPath(), 'utf-8')).toBe( + JSON.stringify({ version: 1, vaults: [] }) + ); + }); +}); diff --git a/src/lib/vaults.ts b/src/lib/vaults.ts new file mode 100644 index 0000000..157a008 --- /dev/null +++ b/src/lib/vaults.ts @@ -0,0 +1,58 @@ +import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { ensureConfigDir, getConfigDir } from './config.js'; +import { VAULTS_SCHEMA_URL, VaultsConfig } from './vaults.schema.js'; + +export const vaultsJsonPath = (): string => join(getConfigDir(), 'vaults.json'); +export const vaultsYamlPath = (): string => join(getConfigDir(), 'vaults.yaml'); + +const EMPTY: VaultsConfig = { version: 1, discover: [], vaults: [] }; + +export interface LoadedVaults { + config: VaultsConfig; + // the file read, or null when none exists (zero-config) + source: string | null; +} + +const readRaw = (): { text: string; path: string } | null => { + const jsonPath = vaultsJsonPath(); + if (existsSync(jsonPath)) return { text: readFileSync(jsonPath, 'utf-8'), path: jsonPath }; + const yamlPath = vaultsYamlPath(); + if (existsSync(yamlPath)) return { text: readFileSync(yamlPath, 'utf-8'), path: yamlPath }; + return null; +}; + +// No legacy migration: a file that is not the current array schema fails validation +// loudly (start fresh with the new schema / `vault add`). +export const loadVaultsConfig = (): LoadedVaults => { + const raw = readRaw(); + if (!raw) return { config: EMPTY, source: null }; + const parsed: unknown = raw.path.endsWith('.yaml') ? parseYaml(raw.text) : JSON.parse(raw.text); + return { config: VaultsConfig.parse(parsed), source: raw.path }; +}; + +// Scaffold an empty, $schema-linked vaults.json when none exists yet, so a fresh machine +// always has an editable file with editor autocomplete. Leaves any existing file untouched +// (it is validated on actual use, not here). Safe to call on every connect. +export const ensureVaultsConfig = (): void => { + if (readRaw()) return; + saveVaultsConfig(EMPTY); +}; + +// Atomic 0600 write of the canonical array shape, always to vaults.json (never .yaml). +export const saveVaultsConfig = (config: VaultsConfig): string => { + ensureConfigDir(); + const path = vaultsJsonPath(); + const out = { + $schema: VAULTS_SCHEMA_URL, + version: config.version, + discover: config.discover, + vaults: config.vaults, + }; + const tmp = `${path}.tmp`; + writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }); + renameSync(tmp, path); + chmodSync(path, 0o600); + return path; +}; diff --git a/src/package-guard.test.ts b/src/package-guard.test.ts index a8d4737..d1547ab 100644 --- a/src/package-guard.test.ts +++ b/src/package-guard.test.ts @@ -40,7 +40,15 @@ describe('package guard (R6)', () => { dependencies: Record; bin: Record; }; - expect(Object.keys(pkg.dependencies).sort()).toEqual(['chalk', 'commander', 'open']); + // zod + yaml join at M1 for vaults.json validation + YAML parsing (both are the exact + // runtime deps of the sibling @agentage/memory-core); the set grows again at M3. + expect(Object.keys(pkg.dependencies).sort()).toEqual([ + 'chalk', + 'commander', + 'open', + 'yaml', + 'zod', + ]); expect(pkg.bin['agentage']).toBe('./dist/cli.js'); }); });