Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
90 changes: 90 additions & 0 deletions src/lib/vaults.schema.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
77 changes: 77 additions & 0 deletions src/lib/vaults.schema.ts
Original file line number Diff line number Diff line change
@@ -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 (`<sub>/<name>.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 <name>` 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<typeof Vault>;
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<typeof Discover>;

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<typeof VaultsConfig>;
Loading