atheneum-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.npmrc.ci +1 -0
- package/CHANGELOG.md +27 -0
- package/LICENSE +13 -0
- package/README.md +191 -0
- package/eslint.config.js +52 -0
- package/package.json +37 -0
- package/src/auth.ts +60 -0
- package/src/cli.ts +32 -0
- package/src/config.ts +248 -0
- package/src/driveClient.ts +491 -0
- package/src/server.ts +757 -0
- package/src/wikiClient.ts +689 -0
- package/tsconfig.json +20 -0
package/.npmrc.ci
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Multi-instance Wiki.js support with path scoping and access controls
|
|
13
|
+
- Google Drive folder prioritization with recursive search
|
|
14
|
+
- YAML configuration with environment variable interpolation
|
|
15
|
+
- CLI entry point with `auth` subcommand for Google OAuth
|
|
16
|
+
- Configurable read-only mode, strict path enforcement, and path hiding per wiki instance
|
|
17
|
+
- Paginated Drive search that fills results across API pages
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Generalized from single-instance to reusable, config-driven package
|
|
22
|
+
- Refactored wiki client from module functions to WikiClient class
|
|
23
|
+
- Refactored drive client from module functions to DriveClient class
|
|
24
|
+
- Google OAuth tokens now stored in `~/.config/atheneum-mcp/`
|
|
25
|
+
- Configuration via YAML file or environment variables
|
|
26
|
+
|
|
27
|
+
[Unreleased]: https://gitlab.com/morgul/atheneum-mcp/-/compare/v0.1.0...main
|
package/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
|
2
|
+
Version 2, December 2004
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2026 Christopher S. Case <chris.case@g33xnexus.com>
|
|
5
|
+
|
|
6
|
+
Everyone is permitted to copy and distribute verbatim or modified
|
|
7
|
+
copies of this license document, and changing it is allowed as long
|
|
8
|
+
as the name is changed.
|
|
9
|
+
|
|
10
|
+
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
|
11
|
+
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
|
12
|
+
|
|
13
|
+
0. You just DO WHAT THE FUCK YOU WANT TO.
|
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# atheneum-mcp
|
|
2
|
+
|
|
3
|
+
An MCP (Model Context Protocol) server that connects AI assistants to Wiki.js and Google Drive.
|
|
4
|
+
Supports multiple Wiki.js instances with path scoping and access controls, plus Google Drive with
|
|
5
|
+
folder prioritization. Runs as a standalone stdio server, designed to be invoked via `npx`.
|
|
6
|
+
|
|
7
|
+
**Requires Node.js 24+** (native TypeScript, no build step)
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
The fastest way to get running with a single Wiki.js instance:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Set environment variables
|
|
15
|
+
export WIKIJS_URL="https://wiki.example.com"
|
|
16
|
+
export WIKIJS_TOKEN="your-api-token"
|
|
17
|
+
|
|
18
|
+
# Run directly
|
|
19
|
+
npx atheneum-mcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or add it to your MCP client config (see [MCP Client Configuration](#mcp-client-configuration)).
|
|
23
|
+
|
|
24
|
+
## YAML Configuration
|
|
25
|
+
|
|
26
|
+
For multiple wikis, Drive integration, or path scoping, use a YAML config file.
|
|
27
|
+
The server checks `./atheneum.yaml` by default, or pass `--config <path>` explicitly.
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
wikis:
|
|
31
|
+
- name: docs
|
|
32
|
+
url: https://wiki.example.com
|
|
33
|
+
token: ${WIKI_TOKEN}
|
|
34
|
+
basePath: /docs
|
|
35
|
+
readOnly: true
|
|
36
|
+
|
|
37
|
+
- name: internal
|
|
38
|
+
url: https://internal-wiki.example.com
|
|
39
|
+
token: ${INTERNAL_WIKI_TOKEN}
|
|
40
|
+
|
|
41
|
+
drive:
|
|
42
|
+
folders:
|
|
43
|
+
- "1abc123def456"
|
|
44
|
+
recursive: true
|
|
45
|
+
strictFolders: false
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
YAML values support `$VAR` and `${VAR}` interpolation, so you can keep secrets in environment
|
|
49
|
+
variables or `.env` files.
|
|
50
|
+
|
|
51
|
+
See `atheneum.example.yaml` for a full example with all options.
|
|
52
|
+
|
|
53
|
+
## Google Drive Setup
|
|
54
|
+
|
|
55
|
+
### 1. Create OAuth Credentials
|
|
56
|
+
|
|
57
|
+
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
|
58
|
+
2. Create a project (or use existing)
|
|
59
|
+
3. Enable the Google Drive API
|
|
60
|
+
4. Create OAuth 2.0 credentials (Desktop app type)
|
|
61
|
+
5. Download the JSON and save it (default location: `~/.config/atheneum-mcp/gcp-oauth.keys.json`)
|
|
62
|
+
|
|
63
|
+
### 2. Authenticate
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npx atheneum-mcp auth
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This opens a browser for Google sign-in and saves credentials to
|
|
70
|
+
`~/.config/atheneum-mcp/tokens.json`. Custom paths can be set via YAML config (`oauthPath` and
|
|
71
|
+
`credentialsPath` under `drive:`).
|
|
72
|
+
|
|
73
|
+
## MCP Client Configuration
|
|
74
|
+
|
|
75
|
+
### Claude Code
|
|
76
|
+
|
|
77
|
+
Add to your project `.mcp.json`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"atheneum": {
|
|
83
|
+
"command": "npx",
|
|
84
|
+
"args": ["atheneum-mcp", "--config", "./atheneum.yaml"]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Or add globally via the CLI:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
claude mcp add atheneum -s user -- npx atheneum-mcp --config ~/atheneum.yaml
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Simple Setup (env vars only, no YAML)
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"mcpServers": {
|
|
101
|
+
"atheneum": {
|
|
102
|
+
"command": "npx",
|
|
103
|
+
"args": ["atheneum-mcp"],
|
|
104
|
+
"env": {
|
|
105
|
+
"WIKIJS_URL": "https://wiki.example.com",
|
|
106
|
+
"WIKIJS_TOKEN": "your-api-token"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Configuration Reference
|
|
114
|
+
|
|
115
|
+
### Config Resolution Order
|
|
116
|
+
|
|
117
|
+
1. `--config <path>` CLI argument (must exist)
|
|
118
|
+
2. `./atheneum.yaml` in the current working directory (if present)
|
|
119
|
+
3. Environment variable fallback
|
|
120
|
+
4. `.env` file (loaded automatically via dotenv)
|
|
121
|
+
|
|
122
|
+
### Environment Variables (simple mode)
|
|
123
|
+
|
|
124
|
+
| Variable | Description |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `WIKIJS_URL` | Wiki.js instance URL (creates a single "default" wiki) |
|
|
127
|
+
| `WIKIJS_TOKEN` | Wiki.js API token |
|
|
128
|
+
| `GDRIVE_OAUTH_PATH` | Path to Google OAuth keys JSON |
|
|
129
|
+
| `GDRIVE_CREDENTIALS_PATH` | Path to Google credentials JSON |
|
|
130
|
+
|
|
131
|
+
### YAML: `wikis[]`
|
|
132
|
+
|
|
133
|
+
| Key | Type | Default | Description |
|
|
134
|
+
|---|---|---|---|
|
|
135
|
+
| `name` | string | **required** | Instance name (used in multi-wiki `instance` parameter) |
|
|
136
|
+
| `url` | string | **required** | Wiki.js base URL |
|
|
137
|
+
| `token` | string | **required** | Wiki.js API token |
|
|
138
|
+
| `basePath` | string | `/` | Restrict operations to a subtree |
|
|
139
|
+
| `readOnly` | boolean | `false` | Disable all write operations |
|
|
140
|
+
| `strictPath` | boolean | `true` | Hard reject paths outside basePath |
|
|
141
|
+
| `hidePaths` | boolean | `false` | Return "not found" instead of "access denied" for out-of-scope paths |
|
|
142
|
+
|
|
143
|
+
### YAML: `drive`
|
|
144
|
+
|
|
145
|
+
| Key | Type | Default | Description |
|
|
146
|
+
|---|---|---|---|
|
|
147
|
+
| `folders` | string[] | `[]` | Folder IDs to prioritize or restrict to |
|
|
148
|
+
| `recursive` | boolean | `true` | Search subfolders of listed folders |
|
|
149
|
+
| `strictFolders` | boolean | `false` | Treat folders as a hard restriction (not just priority hints) |
|
|
150
|
+
| `oauthPath` | string | `~/.config/atheneum-mcp/gcp-oauth.keys.json` | Path to OAuth client keys |
|
|
151
|
+
| `credentialsPath` | string | `~/.config/atheneum-mcp/tokens.json` | Path to saved auth tokens |
|
|
152
|
+
|
|
153
|
+
## Wiki Tools
|
|
154
|
+
|
|
155
|
+
All wiki tools accept an optional `instance` parameter when multiple wikis are configured.
|
|
156
|
+
|
|
157
|
+
| Tool | Description | Write |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `search_wiki` | Full-text search, results filtered to basePath | No |
|
|
160
|
+
| `get_wiki_page` | Get page by path (relative to basePath) | No |
|
|
161
|
+
| `get_wiki_page_by_id` | Get page by numeric ID (checked against basePath) | No |
|
|
162
|
+
| `list_wiki_pages` | List recent pages under basePath | No |
|
|
163
|
+
| `get_wiki_tree` | Folder structure under basePath | No |
|
|
164
|
+
| `list_wiki_tags` | All tags (unscoped) | No |
|
|
165
|
+
| `get_pages_by_tag` | Pages with tag, filtered to basePath | No |
|
|
166
|
+
| `create_wiki_page` | Create page under basePath | Yes |
|
|
167
|
+
| `update_wiki_page` | Update page (must be under basePath) | Yes |
|
|
168
|
+
| `delete_wiki_page` | Delete page (must be under basePath) | Yes |
|
|
169
|
+
| `move_wiki_page` | Move page (source and dest must be under basePath) | Yes |
|
|
170
|
+
|
|
171
|
+
## Drive Tools
|
|
172
|
+
|
|
173
|
+
| Tool | Description |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `search_gdrive` | Search files with folder prioritization/restriction |
|
|
176
|
+
| `read_gdrive_file` | Read file by ID (checked against folders if strict) |
|
|
177
|
+
| `list_gdrive_folder` | List folder contents (checked against allowed folders if strict) |
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
npm install # Install dependencies
|
|
183
|
+
npm start # Run the server
|
|
184
|
+
npm run lint # Lint code
|
|
185
|
+
npm run lint:types # Type check
|
|
186
|
+
npm run release # Version bump + changelog + tag + push
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
[WTFPL](http://www.wtfpl.net/)
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
2
|
+
// ESLint Configuration
|
|
3
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import eslint from '@eslint/js';
|
|
6
|
+
import tseslint from 'typescript-eslint';
|
|
7
|
+
import globals from 'globals';
|
|
8
|
+
|
|
9
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
export default tseslint.config(
|
|
12
|
+
eslint.configs.recommended,
|
|
13
|
+
...tseslint.configs.recommended,
|
|
14
|
+
{
|
|
15
|
+
languageOptions: {
|
|
16
|
+
globals: {
|
|
17
|
+
...globals.node
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
rules: {
|
|
21
|
+
// Enforce Allman brace style
|
|
22
|
+
'brace-style': [ 'error', 'allman', { allowSingleLine: true } ],
|
|
23
|
+
|
|
24
|
+
// 4 space indentation
|
|
25
|
+
'indent': [ 'error', 4, { SwitchCase: 1 } ],
|
|
26
|
+
|
|
27
|
+
// 120 character line limit
|
|
28
|
+
'max-len': [ 'warn', { code: 120, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true } ],
|
|
29
|
+
|
|
30
|
+
// Spacing preferences
|
|
31
|
+
'array-bracket-spacing': [ 'error', 'always' ],
|
|
32
|
+
'object-curly-spacing': [ 'error', 'always' ],
|
|
33
|
+
'computed-property-spacing': [ 'error', 'never' ],
|
|
34
|
+
'space-in-parens': [ 'error', 'never' ],
|
|
35
|
+
'template-curly-spacing': [ 'error', 'always' ],
|
|
36
|
+
|
|
37
|
+
// TypeScript specific
|
|
38
|
+
'@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_' } ],
|
|
39
|
+
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
40
|
+
'@typescript-eslint/no-explicit-any': 'warn'
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
ignores: [
|
|
45
|
+
'**/dist/**',
|
|
46
|
+
'**/node_modules/**',
|
|
47
|
+
'**/*.d.ts'
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
//----------------------------------------------------------------------------------------------------------------------
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "atheneum-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Wiki.js and Google Drive",
|
|
5
|
+
"author": "Christopher S. Case <chris.case@g33xnexus.com>",
|
|
6
|
+
"license": "WTFPL",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/cli.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"atheneum-mcp": "src/cli.ts"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node src/cli.ts",
|
|
17
|
+
"lint": "eslint src/",
|
|
18
|
+
"lint:types": "tsc --noEmit",
|
|
19
|
+
"release": "bash scripts/release.sh"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@google-cloud/local-auth": "^3.0.1",
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
24
|
+
"dotenv": "^16.3.1",
|
|
25
|
+
"googleapis": "^166.0.0",
|
|
26
|
+
"yaml": "^2.7.0",
|
|
27
|
+
"zod": "^3.25.76"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@eslint/js": "^9.0.0",
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"eslint": "^9.0.0",
|
|
33
|
+
"globals": "^15.0.0",
|
|
34
|
+
"typescript": "^5.3.0",
|
|
35
|
+
"typescript-eslint": "^8.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
2
|
+
// Atheneum MCP - Google OAuth Authentication Flow
|
|
3
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as os from 'os';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import { authenticate } from '@google-cloud/local-auth';
|
|
9
|
+
|
|
10
|
+
// Config
|
|
11
|
+
import type { AtheneumConfig } from './config.ts';
|
|
12
|
+
|
|
13
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
14
|
+
// Constants
|
|
15
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const DEFAULT_OAUTH_PATH = path.join(os.homedir(), '.config', 'atheneum-mcp', 'gcp-oauth.keys.json');
|
|
18
|
+
const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), '.config', 'atheneum-mcp', 'tokens.json');
|
|
19
|
+
|
|
20
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
21
|
+
// Auth Flow
|
|
22
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runs the interactive Google OAuth flow, opening a browser for the user to authorize, then saves the resulting
|
|
26
|
+
* credentials to disk. Intended to be invoked via `npx atheneum-mcp auth`.
|
|
27
|
+
*/
|
|
28
|
+
export async function runAuthFlow(config : AtheneumConfig) : Promise<void>
|
|
29
|
+
{
|
|
30
|
+
const oauthPath = config.drive?.oauthPath ?? DEFAULT_OAUTH_PATH;
|
|
31
|
+
const credentialsPath = config.drive?.credentialsPath ?? DEFAULT_CREDENTIALS_PATH;
|
|
32
|
+
|
|
33
|
+
// Bail early if the OAuth keys file is missing
|
|
34
|
+
if(!fs.existsSync(oauthPath))
|
|
35
|
+
{
|
|
36
|
+
process.stderr.write(
|
|
37
|
+
`OAuth keys not found at ${ oauthPath }. Download from Google Cloud Console.\n`
|
|
38
|
+
);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Run the interactive OAuth flow (opens a browser)
|
|
43
|
+
const auth = await authenticate({
|
|
44
|
+
keyfilePath: oauthPath,
|
|
45
|
+
scopes: [
|
|
46
|
+
'https://www.googleapis.com/auth/drive',
|
|
47
|
+
'https://www.googleapis.com/auth/drive.readonly',
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Ensure the config directory exists
|
|
52
|
+
fs.mkdirSync(path.dirname(credentialsPath), { recursive: true });
|
|
53
|
+
|
|
54
|
+
// Write credentials with restrictive permissions
|
|
55
|
+
fs.writeFileSync(credentialsPath, JSON.stringify(auth.credentials, null, 2), { mode: 0o600 });
|
|
56
|
+
|
|
57
|
+
process.stderr.write(`Authentication successful. Credentials saved to ${ credentialsPath }\n`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
//----------------------------------------------------------------------------------------------------------------------
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
3
|
+
// CLI Entry Point
|
|
4
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
import { loadConfig } from './config.ts';
|
|
7
|
+
import { runAuthFlow } from './auth.ts';
|
|
8
|
+
import { startServer } from './server.ts';
|
|
9
|
+
|
|
10
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
async function main() : Promise<void>
|
|
13
|
+
{
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const config = loadConfig(args);
|
|
16
|
+
|
|
17
|
+
if(args[0] === 'auth')
|
|
18
|
+
{
|
|
19
|
+
await runAuthFlow(config);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
await startServer(config);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
main().catch((err) =>
|
|
27
|
+
{
|
|
28
|
+
console.error(err);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
//----------------------------------------------------------------------------------------------------------------------
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
2
|
+
// Atheneum MCP - Configuration
|
|
3
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from 'fs';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import dotenv from 'dotenv';
|
|
8
|
+
import { parse as parseYaml } from 'yaml';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
12
|
+
// Schemas
|
|
13
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const WikiConfigSchema = z.object({
|
|
16
|
+
name: z.string(),
|
|
17
|
+
url: z.string().url(),
|
|
18
|
+
token: z.string(),
|
|
19
|
+
basePath: z.string().default('/'),
|
|
20
|
+
readOnly: z.boolean().default(false),
|
|
21
|
+
strictPath: z.boolean().default(true),
|
|
22
|
+
hidePaths: z.boolean().default(false),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const DriveConfigSchema = z.object({
|
|
26
|
+
folders: z.array(z.string()).default([]),
|
|
27
|
+
recursive: z.boolean().default(true),
|
|
28
|
+
strictFolders: z.boolean().default(false),
|
|
29
|
+
oauthPath: z.string().optional(),
|
|
30
|
+
credentialsPath: z.string().optional(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const AtheneumConfigSchema = z.object({
|
|
34
|
+
wikis: z.array(WikiConfigSchema).default([]),
|
|
35
|
+
drive: DriveConfigSchema.optional(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
39
|
+
// Types
|
|
40
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
export type WikiConfig = z.infer<typeof WikiConfigSchema>;
|
|
43
|
+
export type DriveConfig = z.infer<typeof DriveConfigSchema>;
|
|
44
|
+
export type AtheneumConfig = z.infer<typeof AtheneumConfigSchema>;
|
|
45
|
+
|
|
46
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
47
|
+
// Env Var Interpolation
|
|
48
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Replaces `$VAR` and `${VAR}` patterns in a string with the corresponding environment variable value.
|
|
52
|
+
* Throws if the referenced variable is not set.
|
|
53
|
+
*/
|
|
54
|
+
export function interpolateEnvVars(value : string) : string
|
|
55
|
+
{
|
|
56
|
+
// Match both ${VAR} and $VAR patterns. The ${VAR} form is checked first to avoid partial matches.
|
|
57
|
+
const envVarPattern = /\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
58
|
+
return value.replace(envVarPattern, (
|
|
59
|
+
_match,
|
|
60
|
+
braced : string | undefined,
|
|
61
|
+
bare : string | undefined
|
|
62
|
+
) =>
|
|
63
|
+
{
|
|
64
|
+
const varName = braced ?? bare;
|
|
65
|
+
if(!varName)
|
|
66
|
+
{
|
|
67
|
+
return _match;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const envValue = process.env[varName];
|
|
71
|
+
if(envValue === undefined)
|
|
72
|
+
{
|
|
73
|
+
throw new Error(`Environment variable '${ varName }' is not set (referenced in config)`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return envValue;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Recursively walks all string values in an object and applies env var interpolation.
|
|
82
|
+
*/
|
|
83
|
+
function interpolateObject(obj : unknown) : unknown
|
|
84
|
+
{
|
|
85
|
+
if(typeof obj === 'string')
|
|
86
|
+
{
|
|
87
|
+
return interpolateEnvVars(obj);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if(Array.isArray(obj))
|
|
91
|
+
{
|
|
92
|
+
return obj.map((item) => interpolateObject(item));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if(obj !== null && typeof obj === 'object')
|
|
96
|
+
{
|
|
97
|
+
const result : Record<string, unknown> = {};
|
|
98
|
+
for(const [ key, val ] of Object.entries(obj as Record<string, unknown>))
|
|
99
|
+
{
|
|
100
|
+
result[key] = interpolateObject(val);
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Booleans, numbers, null, etc. pass through untouched
|
|
106
|
+
return obj;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
110
|
+
// CLI Arg Parsing
|
|
111
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Extracts the `--config <path>` argument from a CLI args array. Returns undefined if not present.
|
|
115
|
+
*/
|
|
116
|
+
function parseConfigPath(args : string[]) : string | undefined
|
|
117
|
+
{
|
|
118
|
+
const idx = args.indexOf('--config');
|
|
119
|
+
if(idx === -1)
|
|
120
|
+
{
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const next = args[idx + 1];
|
|
125
|
+
if(!next || next.startsWith('--'))
|
|
126
|
+
{
|
|
127
|
+
throw new Error('--config requires a path argument');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return next;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
134
|
+
// Config Loading
|
|
135
|
+
//----------------------------------------------------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Builds a fallback config from environment variables when no YAML config is available.
|
|
139
|
+
* Returns undefined if the minimum required env vars aren't set.
|
|
140
|
+
*/
|
|
141
|
+
function buildEnvFallbackConfig() : Record<string, unknown> | undefined
|
|
142
|
+
{
|
|
143
|
+
const wikiUrl = process.env.WIKIJS_URL;
|
|
144
|
+
const wikiToken = process.env.WIKIJS_TOKEN;
|
|
145
|
+
|
|
146
|
+
const hasWiki = wikiUrl && wikiToken;
|
|
147
|
+
const oauthPath = process.env.GDRIVE_OAUTH_PATH;
|
|
148
|
+
const credentialsPath = process.env.GDRIVE_CREDENTIALS_PATH;
|
|
149
|
+
const hasDrive = oauthPath || credentialsPath;
|
|
150
|
+
|
|
151
|
+
if(!hasWiki && !hasDrive)
|
|
152
|
+
{
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const config : Record<string, unknown> = {};
|
|
157
|
+
|
|
158
|
+
if(hasWiki)
|
|
159
|
+
{
|
|
160
|
+
config.wikis = [
|
|
161
|
+
{
|
|
162
|
+
name: 'default',
|
|
163
|
+
url: wikiUrl,
|
|
164
|
+
token: wikiToken,
|
|
165
|
+
basePath: '/',
|
|
166
|
+
}
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if(hasDrive)
|
|
171
|
+
{
|
|
172
|
+
const drive : Record<string, unknown> = {};
|
|
173
|
+
if(oauthPath) { drive.oauthPath = oauthPath; }
|
|
174
|
+
if(credentialsPath) { drive.credentialsPath = credentialsPath; }
|
|
175
|
+
config.drive = drive;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return config;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseAndValidate(raw : unknown) : AtheneumConfig
|
|
182
|
+
{
|
|
183
|
+
const result = AtheneumConfigSchema.safeParse(raw);
|
|
184
|
+
if(!result.success)
|
|
185
|
+
{
|
|
186
|
+
const issues = result.error.issues.map(i => ` - ${ i.path.join('.') }: ${ i.message }`).join('\n');
|
|
187
|
+
throw new Error(`Invalid configuration:\n${ issues }`);
|
|
188
|
+
}
|
|
189
|
+
return result.data;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Loads and validates the Atheneum configuration.
|
|
194
|
+
*
|
|
195
|
+
* Resolution order:
|
|
196
|
+
* 1. `--config <path>` CLI argument
|
|
197
|
+
* 2. `./atheneum.yaml` in the current working directory
|
|
198
|
+
* 3. Environment variable fallback (`WIKIJS_URL` + `WIKIJS_TOKEN`, `GDRIVE_*`)
|
|
199
|
+
*/
|
|
200
|
+
export function loadConfig(args : string[]) : AtheneumConfig
|
|
201
|
+
{
|
|
202
|
+
// Load .env file into process.env (no-ops if .env doesn't exist)
|
|
203
|
+
dotenv.config();
|
|
204
|
+
|
|
205
|
+
const configPath = parseConfigPath(args);
|
|
206
|
+
let rawConfig : unknown;
|
|
207
|
+
|
|
208
|
+
if(configPath)
|
|
209
|
+
{
|
|
210
|
+
// Explicit --config path: must exist
|
|
211
|
+
const resolved = resolve(configPath);
|
|
212
|
+
if(!existsSync(resolved))
|
|
213
|
+
{
|
|
214
|
+
throw new Error(`Config file not found: ${ resolved }`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const content = readFileSync(resolved, 'utf-8');
|
|
218
|
+
rawConfig = parseYaml(content);
|
|
219
|
+
}
|
|
220
|
+
else
|
|
221
|
+
{
|
|
222
|
+
// Try default location
|
|
223
|
+
const defaultPath = resolve('atheneum.yaml');
|
|
224
|
+
if(existsSync(defaultPath))
|
|
225
|
+
{
|
|
226
|
+
const content = readFileSync(defaultPath, 'utf-8');
|
|
227
|
+
rawConfig = parseYaml(content);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// yaml.parse returns null for empty/whitespace-only files
|
|
232
|
+
if(rawConfig !== undefined && rawConfig !== null)
|
|
233
|
+
{
|
|
234
|
+
return parseAndValidate(interpolateObject(rawConfig));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// No YAML found -- try env var fallback
|
|
238
|
+
const fallback = buildEnvFallbackConfig();
|
|
239
|
+
if(fallback)
|
|
240
|
+
{
|
|
241
|
+
return parseAndValidate(fallback);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Nothing configured at all -- return empty (valid) config
|
|
245
|
+
return parseAndValidate({});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
//----------------------------------------------------------------------------------------------------------------------
|