@dinoxx/dinox-cli 1.0.14 → 1.0.16
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/dist/auth/userInfo.d.ts +1 -0
- package/dist/auth/userInfo.js +29 -10
- package/dist/cli.js +6 -0
- package/dist/commands/auth/index.js +16 -19
- package/dist/commands/boxes/index.js +1 -10
- package/dist/commands/graph/index.d.ts +2 -0
- package/dist/commands/graph/index.js +98 -0
- package/dist/commands/graph/repo.d.ts +33 -0
- package/dist/commands/graph/repo.js +76 -0
- package/dist/commands/mcp/auth.d.ts +4 -0
- package/dist/commands/mcp/auth.js +75 -0
- package/dist/commands/mcp/index.d.ts +2 -0
- package/dist/commands/mcp/index.js +84 -0
- package/dist/commands/mcp/server.d.ts +30 -0
- package/dist/commands/mcp/server.js +324 -0
- package/dist/commands/mcp/tools.d.ts +2 -0
- package/dist/commands/mcp/tools.js +90 -0
- package/dist/commands/mcp/types.d.ts +24 -0
- package/dist/commands/mcp/types.js +1 -0
- package/dist/commands/notes/index.d.ts +39 -0
- package/dist/commands/notes/index.js +61 -12
- package/dist/commands/prompt/index.js +1 -10
- package/dist/commands/sync.js +1 -11
- package/dist/commands/tags/index.js +1 -10
- package/dist/commands/todo/index.js +1 -10
- package/dist/config/resolve.js +3 -2
- package/dist/config/store.js +17 -2
- package/dist/config/types.d.ts +1 -2
- package/dist/daemon/client.d.ts +2 -0
- package/dist/daemon/client.js +86 -0
- package/dist/daemon/ensure.d.ts +1 -0
- package/dist/daemon/ensure.js +23 -0
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.js +151 -0
- package/dist/daemon/pid.d.ts +10 -0
- package/dist/daemon/pid.js +55 -0
- package/dist/daemon/server.d.ts +10 -0
- package/dist/daemon/server.js +137 -0
- package/dist/daemon/types.d.ts +19 -0
- package/dist/daemon/types.js +1 -0
- package/dist/powersync/connector.d.ts +2 -1
- package/dist/powersync/connector.js +27 -9
- package/dist/powersync/runtime.js +4 -9
- package/dist/powersync/uploader.d.ts +1 -0
- package/dist/powersync/uploader.js +36 -13
- package/dist/utils/abort.d.ts +6 -0
- package/dist/utils/abort.js +23 -0
- package/dist/utils/authToken.d.ts +2 -0
- package/dist/utils/authToken.js +23 -0
- package/dist/utils/syncTimeout.d.ts +1 -0
- package/dist/utils/syncTimeout.js +19 -0
- package/package.json +1 -1
package/dist/auth/userInfo.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type AuthIdentity = {
|
|
|
8
8
|
export declare function fetchUserInfoWithAuthorization(input: {
|
|
9
9
|
authorization: string;
|
|
10
10
|
config: ResolvedDinoxConfig;
|
|
11
|
+
timeoutMs?: number;
|
|
11
12
|
signal?: AbortSignal;
|
|
12
13
|
}): Promise<AuthIdentity>;
|
|
13
14
|
export declare function refreshAndPersistAuthIdentity(rawConfig: DinoxConfig, resolvedConfig: ResolvedDinoxConfig): Promise<AuthIdentity>;
|
package/dist/auth/userInfo.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { saveConfig } from '../config/store.js';
|
|
2
2
|
import { DinoxError } from '../utils/errors.js';
|
|
3
|
+
import { createTimeoutSignal, mergeAbortSignals } from '../utils/abort.js';
|
|
4
|
+
import { toAuthorizationHeaderValue } from '../utils/authToken.js';
|
|
3
5
|
const AUTH_CODE_OK = '000000';
|
|
4
6
|
function normalizeOptionalString(value) {
|
|
5
7
|
if (typeof value !== 'string') {
|
|
@@ -59,17 +61,34 @@ function resolveUserInfoEndpoint(config) {
|
|
|
59
61
|
export async function fetchUserInfoWithAuthorization(input) {
|
|
60
62
|
const authorization = normalizeOptionalString(input.authorization);
|
|
61
63
|
if (!authorization) {
|
|
62
|
-
throw new DinoxError('
|
|
64
|
+
throw new DinoxError('Token is required for user info');
|
|
63
65
|
}
|
|
64
66
|
const endpoint = resolveUserInfoEndpoint(input.config);
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
const effectiveTimeoutMs = input.timeoutMs ?? input.config.sync.timeoutMs;
|
|
68
|
+
const timeout = typeof effectiveTimeoutMs === 'number' && Number.isFinite(effectiveTimeoutMs) && effectiveTimeoutMs > 0
|
|
69
|
+
? createTimeoutSignal(effectiveTimeoutMs)
|
|
70
|
+
: null;
|
|
71
|
+
const requestSignal = mergeAbortSignals([input.signal, timeout?.signal]);
|
|
72
|
+
let response;
|
|
73
|
+
try {
|
|
74
|
+
response = await fetch(endpoint, {
|
|
75
|
+
method: 'GET',
|
|
76
|
+
headers: {
|
|
77
|
+
Authorization: toAuthorizationHeaderValue(authorization),
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
},
|
|
80
|
+
signal: requestSignal,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (timeout?.signal.aborted && !input.signal?.aborted) {
|
|
85
|
+
throw new DinoxError(`User info request timed out after ${effectiveTimeoutMs}ms`);
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
timeout?.cancel();
|
|
91
|
+
}
|
|
73
92
|
if (!response.ok) {
|
|
74
93
|
const body = await response.text().catch(() => '');
|
|
75
94
|
throw new DinoxError(`User info request failed (${response.status}): ${body || endpoint}`);
|
|
@@ -80,7 +99,7 @@ export async function fetchUserInfoWithAuthorization(input) {
|
|
|
80
99
|
export async function refreshAndPersistAuthIdentity(rawConfig, resolvedConfig) {
|
|
81
100
|
const authorization = normalizeOptionalString(resolvedConfig.auth.authorization);
|
|
82
101
|
if (!authorization) {
|
|
83
|
-
throw new DinoxError('
|
|
102
|
+
throw new DinoxError('Token is required to fetch user info');
|
|
84
103
|
}
|
|
85
104
|
const identity = await fetchUserInfoWithAuthorization({
|
|
86
105
|
authorization,
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,10 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { registerAuthCommands } from './commands/auth/index.js';
|
|
3
3
|
import { registerBoxesCommand } from './commands/boxes/index.js';
|
|
4
4
|
import { registerConfigCommands } from './commands/config/index.js';
|
|
5
|
+
import { registerDaemonCommands } from './daemon/index.js';
|
|
6
|
+
import { registerGraphCommands } from './commands/graph/index.js';
|
|
5
7
|
import { registerInfoCommand } from './commands/info/index.js';
|
|
8
|
+
import { registerMCPCommands } from './commands/mcp/index.js';
|
|
6
9
|
import { registerNoteCommands } from './commands/notes/index.js';
|
|
7
10
|
import { registerPromptCommands } from './commands/prompt/index.js';
|
|
8
11
|
import { registerSyncCommand } from './commands/sync.js';
|
|
@@ -25,7 +28,10 @@ export function createCli() {
|
|
|
25
28
|
registerAuthCommands(program);
|
|
26
29
|
registerBoxesCommand(program);
|
|
27
30
|
registerConfigCommands(program);
|
|
31
|
+
registerDaemonCommands(program);
|
|
32
|
+
registerGraphCommands(program);
|
|
28
33
|
registerInfoCommand(program);
|
|
34
|
+
registerMCPCommands(program);
|
|
29
35
|
registerSyncCommand(program);
|
|
30
36
|
registerUpdateCommand(program);
|
|
31
37
|
registerPromptCommands(program);
|
|
@@ -5,20 +5,12 @@ import { getPowerSyncDbPath } from '../../config/paths.js';
|
|
|
5
5
|
import { redactAuthorization } from '../../utils/redact.js';
|
|
6
6
|
import { DinoxError } from '../../utils/errors.js';
|
|
7
7
|
import { printYaml } from '../../utils/output.js';
|
|
8
|
+
import { parseSyncTimeoutMs } from '../../utils/syncTimeout.js';
|
|
9
|
+
import { normalizeAuthToken } from '../../utils/authToken.js';
|
|
8
10
|
import { refreshAndPersistAuthIdentity } from '../../auth/userInfo.js';
|
|
9
11
|
import { connectPowerSync, getStatusSnapshot } from '../../powersync/runtime.js';
|
|
10
12
|
import { fetchCredentialsWithAuthorization } from '../../powersync/connector.js';
|
|
11
13
|
import { runSingleSyncPass } from '../../powersync/syncPass.js';
|
|
12
|
-
function parseSyncTimeoutMs(value) {
|
|
13
|
-
if (typeof value !== 'string') {
|
|
14
|
-
return undefined;
|
|
15
|
-
}
|
|
16
|
-
const parsed = Number(value);
|
|
17
|
-
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
18
|
-
throw new DinoxError('--sync-timeout must be a positive integer');
|
|
19
|
-
}
|
|
20
|
-
return Math.trunc(parsed);
|
|
21
|
-
}
|
|
22
14
|
async function removeSqliteArtifacts(dbPath) {
|
|
23
15
|
const artifacts = ['', '-wal', '-shm'];
|
|
24
16
|
for (const suffix of artifacts) {
|
|
@@ -31,15 +23,15 @@ export function registerAuthCommands(program) {
|
|
|
31
23
|
.description('Authentication and PowerSync connectivity commands');
|
|
32
24
|
auth
|
|
33
25
|
.command('login')
|
|
34
|
-
.description('Save
|
|
35
|
-
.argument('<
|
|
36
|
-
.action(async (
|
|
37
|
-
const
|
|
38
|
-
if (!
|
|
39
|
-
throw new DinoxError('
|
|
26
|
+
.description('Save login token and verify PowerSync connectivity')
|
|
27
|
+
.argument('<token>', 'login token (pass the raw token string, without Bearer prefix)')
|
|
28
|
+
.action(async (token, _options, command) => {
|
|
29
|
+
const rawToken = normalizeAuthToken(token);
|
|
30
|
+
if (!rawToken) {
|
|
31
|
+
throw new DinoxError('Token is required');
|
|
40
32
|
}
|
|
41
33
|
const config = await loadConfig();
|
|
42
|
-
config.auth = { ...(config.auth ?? {}), authorization:
|
|
34
|
+
config.auth = { ...(config.auth ?? {}), authorization: rawToken };
|
|
43
35
|
await saveConfig(config);
|
|
44
36
|
const resolved = resolveConfig(config);
|
|
45
37
|
const globals = command.optsWithGlobals?.() ?? {};
|
|
@@ -47,9 +39,10 @@ export function registerAuthCommands(program) {
|
|
|
47
39
|
const jsonOutput = Boolean(globals.json);
|
|
48
40
|
// 1) Token exchange sanity check.
|
|
49
41
|
await fetchCredentialsWithAuthorization({
|
|
50
|
-
authorization:
|
|
42
|
+
authorization: rawToken,
|
|
51
43
|
endpoint: resolved.powersync.endpoint,
|
|
52
44
|
credentialsUrl: resolved.powersync.tokenEndpoint,
|
|
45
|
+
timeoutMs,
|
|
53
46
|
});
|
|
54
47
|
// 1.5) Fetch and persist user identity.
|
|
55
48
|
const identity = await refreshAndPersistAuthIdentity(config, resolved);
|
|
@@ -93,12 +86,16 @@ export function registerAuthCommands(program) {
|
|
|
93
86
|
});
|
|
94
87
|
auth
|
|
95
88
|
.command('logout')
|
|
96
|
-
.description('Clear saved
|
|
89
|
+
.description('Clear saved login token')
|
|
97
90
|
.option('--clear-local-db', 'delete the local PowerSync SQLite database')
|
|
98
91
|
.action(async (options) => {
|
|
99
92
|
const config = await loadConfig();
|
|
100
93
|
if (config.auth) {
|
|
101
94
|
config.auth.authorization = undefined;
|
|
95
|
+
config.auth.userId = undefined;
|
|
96
|
+
config.auth.username = undefined;
|
|
97
|
+
config.auth.email = undefined;
|
|
98
|
+
config.auth.userInfoSyncedAt = undefined;
|
|
102
99
|
}
|
|
103
100
|
await saveConfig(config);
|
|
104
101
|
if (options.clearLocalDb) {
|
|
@@ -2,20 +2,11 @@ import { loadConfig } from '../../config/store.js';
|
|
|
2
2
|
import { resolveConfig } from '../../config/resolve.js';
|
|
3
3
|
import { DinoxError } from '../../utils/errors.js';
|
|
4
4
|
import { printYaml } from '../../utils/output.js';
|
|
5
|
+
import { parseSyncTimeoutMs } from '../../utils/syncTimeout.js';
|
|
5
6
|
import { connectPowerSync, waitForIdleDataFlow } from '../../powersync/runtime.js';
|
|
6
7
|
import { syncNoteTokenIndex } from '../../powersync/tokenIndex.js';
|
|
7
8
|
import { resolveAuthIdentity } from '../../auth/userInfo.js';
|
|
8
9
|
import { addBox, listBoxes } from './repo.js';
|
|
9
|
-
function parseSyncTimeoutMs(value) {
|
|
10
|
-
if (typeof value !== 'string') {
|
|
11
|
-
return undefined;
|
|
12
|
-
}
|
|
13
|
-
const parsed = Number(value);
|
|
14
|
-
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
15
|
-
throw new DinoxError('--sync-timeout must be a positive integer');
|
|
16
|
-
}
|
|
17
|
-
return Math.trunc(parsed);
|
|
18
|
-
}
|
|
19
10
|
async function syncBeforeBoxCommand(db, offline, timeoutMs) {
|
|
20
11
|
if (offline) {
|
|
21
12
|
return;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { printYaml } from '../../utils/output.js';
|
|
2
|
+
import { sendDaemonRequest } from '../../daemon/client.js';
|
|
3
|
+
import { ensureDaemonRunning } from '../../daemon/ensure.js';
|
|
4
|
+
import { DinoxError } from '../../utils/errors.js';
|
|
5
|
+
import { loadConfig } from '../../config/store.js';
|
|
6
|
+
import { resolveConfig } from '../../config/resolve.js';
|
|
7
|
+
import { connectPowerSync } from '../../powersync/runtime.js';
|
|
8
|
+
import { parseSyncTimeoutMs } from '../../utils/syncTimeout.js';
|
|
9
|
+
import { getBacklinks, getOutlinks, getRelatedNotes } from './repo.js';
|
|
10
|
+
function parseDepth(value) {
|
|
11
|
+
if (value === undefined || value === null || value === '') {
|
|
12
|
+
return 2;
|
|
13
|
+
}
|
|
14
|
+
if (typeof value !== 'string') {
|
|
15
|
+
throw new DinoxError('--depth must be a positive integer');
|
|
16
|
+
}
|
|
17
|
+
const trimmed = value.trim();
|
|
18
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
19
|
+
throw new DinoxError('--depth must be a positive integer');
|
|
20
|
+
}
|
|
21
|
+
const parsed = Number(trimmed);
|
|
22
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 5) {
|
|
23
|
+
throw new DinoxError('--depth must be between 1 and 5');
|
|
24
|
+
}
|
|
25
|
+
return Math.trunc(parsed);
|
|
26
|
+
}
|
|
27
|
+
async function withOfflineDb(command, fn) {
|
|
28
|
+
const globals = command.optsWithGlobals?.() ?? {};
|
|
29
|
+
const offline = Boolean(globals.offline);
|
|
30
|
+
if (!offline) {
|
|
31
|
+
throw new DinoxError('withOfflineDb called without --offline');
|
|
32
|
+
}
|
|
33
|
+
const config = resolveConfig(await loadConfig());
|
|
34
|
+
const timeoutMs = parseSyncTimeoutMs(globals.syncTimeout) ?? config.sync.timeoutMs;
|
|
35
|
+
const { db } = await connectPowerSync({ config, offline: true, timeoutMs });
|
|
36
|
+
try {
|
|
37
|
+
return await fn(db);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
await db.close().catch(() => undefined);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function registerGraphCommands(program) {
|
|
44
|
+
const graph = program.command('graph').description('Query knowledge graph');
|
|
45
|
+
graph
|
|
46
|
+
.command('backlinks')
|
|
47
|
+
.description('Get notes that link to this note')
|
|
48
|
+
.argument('<id>', 'note id')
|
|
49
|
+
.action(async (id, _options, command) => {
|
|
50
|
+
const globals = command.optsWithGlobals?.() ?? {};
|
|
51
|
+
const offline = Boolean(globals.offline);
|
|
52
|
+
if (offline) {
|
|
53
|
+
const result = await withOfflineDb(command, (db) => getBacklinks(db, id));
|
|
54
|
+
printYaml(result);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
await ensureDaemonRunning();
|
|
58
|
+
const result = await sendDaemonRequest('graph.backlinks', { id });
|
|
59
|
+
printYaml(result);
|
|
60
|
+
});
|
|
61
|
+
graph
|
|
62
|
+
.command('outlinks')
|
|
63
|
+
.description('Get notes that this note links to')
|
|
64
|
+
.argument('<id>', 'note id')
|
|
65
|
+
.action(async (id, _options, command) => {
|
|
66
|
+
const globals = command.optsWithGlobals?.() ?? {};
|
|
67
|
+
const offline = Boolean(globals.offline);
|
|
68
|
+
if (offline) {
|
|
69
|
+
const result = await withOfflineDb(command, (db) => getOutlinks(db, id));
|
|
70
|
+
printYaml(result);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await ensureDaemonRunning();
|
|
74
|
+
const result = await sendDaemonRequest('graph.outlinks', { id });
|
|
75
|
+
printYaml(result);
|
|
76
|
+
});
|
|
77
|
+
graph
|
|
78
|
+
.command('related')
|
|
79
|
+
.description('Get related notes within N degrees of separation')
|
|
80
|
+
.argument('<id>', 'note id')
|
|
81
|
+
.option('--depth <n>', 'degrees of separation (1-5, default: 2)')
|
|
82
|
+
.action(async (id, options, command) => {
|
|
83
|
+
const depth = parseDepth(options.depth);
|
|
84
|
+
const globals = command.optsWithGlobals?.() ?? {};
|
|
85
|
+
const offline = Boolean(globals.offline);
|
|
86
|
+
if (offline) {
|
|
87
|
+
const result = await withOfflineDb(command, (db) => getRelatedNotes(db, id, depth));
|
|
88
|
+
printYaml(result);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
await ensureDaemonRunning();
|
|
92
|
+
const result = await sendDaemonRequest('graph.related', { id, depth });
|
|
93
|
+
printYaml(result);
|
|
94
|
+
});
|
|
95
|
+
graph.action((_options, command) => {
|
|
96
|
+
command.outputHelp();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AbstractPowerSyncDatabase } from '@powersync/common';
|
|
2
|
+
export type NoteLinkRecord = {
|
|
3
|
+
id: string;
|
|
4
|
+
src_note_id: string;
|
|
5
|
+
dst_note_id: string;
|
|
6
|
+
relation: string | null;
|
|
7
|
+
created_at: string;
|
|
8
|
+
};
|
|
9
|
+
export type LinkedNote = {
|
|
10
|
+
note_id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
relation: string | null;
|
|
13
|
+
link_created_at: string;
|
|
14
|
+
};
|
|
15
|
+
export type GraphBacklinksResult = {
|
|
16
|
+
note_id: string;
|
|
17
|
+
backlinks: LinkedNote[];
|
|
18
|
+
count: number;
|
|
19
|
+
};
|
|
20
|
+
export type GraphOutlinksResult = {
|
|
21
|
+
note_id: string;
|
|
22
|
+
outlinks: LinkedNote[];
|
|
23
|
+
count: number;
|
|
24
|
+
};
|
|
25
|
+
export type GraphRelatedResult = {
|
|
26
|
+
note_id: string;
|
|
27
|
+
related: LinkedNote[];
|
|
28
|
+
count: number;
|
|
29
|
+
depth: number;
|
|
30
|
+
};
|
|
31
|
+
export declare function getBacklinks(db: AbstractPowerSyncDatabase, noteId: string): Promise<GraphBacklinksResult>;
|
|
32
|
+
export declare function getOutlinks(db: AbstractPowerSyncDatabase, noteId: string): Promise<GraphOutlinksResult>;
|
|
33
|
+
export declare function getRelatedNotes(db: AbstractPowerSyncDatabase, noteId: string, depth?: number): Promise<GraphRelatedResult>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export async function getBacklinks(db, noteId) {
|
|
2
|
+
const rows = await db.getAll(`SELECT
|
|
3
|
+
l.id,
|
|
4
|
+
l.src_note_id,
|
|
5
|
+
l.dst_note_id,
|
|
6
|
+
l.relation,
|
|
7
|
+
l.created_at,
|
|
8
|
+
n.title
|
|
9
|
+
FROM c_note_link l
|
|
10
|
+
LEFT JOIN c_note n ON l.src_note_id = n.id
|
|
11
|
+
WHERE l.dst_note_id = ? AND l.is_del = 0 AND (n.is_del = 0 OR n.is_del IS NULL)
|
|
12
|
+
ORDER BY l.created_at DESC`, [noteId]);
|
|
13
|
+
const backlinks = rows.map((row) => ({
|
|
14
|
+
note_id: row.src_note_id,
|
|
15
|
+
title: row.title || '',
|
|
16
|
+
relation: row.relation,
|
|
17
|
+
link_created_at: row.created_at,
|
|
18
|
+
}));
|
|
19
|
+
return {
|
|
20
|
+
note_id: noteId,
|
|
21
|
+
backlinks,
|
|
22
|
+
count: backlinks.length,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export async function getOutlinks(db, noteId) {
|
|
26
|
+
const rows = await db.getAll(`SELECT
|
|
27
|
+
l.id,
|
|
28
|
+
l.src_note_id,
|
|
29
|
+
l.dst_note_id,
|
|
30
|
+
l.relation,
|
|
31
|
+
l.created_at,
|
|
32
|
+
n.title
|
|
33
|
+
FROM c_note_link l
|
|
34
|
+
LEFT JOIN c_note n ON l.dst_note_id = n.id
|
|
35
|
+
WHERE l.src_note_id = ? AND l.is_del = 0 AND (n.is_del = 0 OR n.is_del IS NULL)
|
|
36
|
+
ORDER BY l.created_at DESC`, [noteId]);
|
|
37
|
+
const outlinks = rows.map((row) => ({
|
|
38
|
+
note_id: row.dst_note_id,
|
|
39
|
+
title: row.title || '',
|
|
40
|
+
relation: row.relation,
|
|
41
|
+
link_created_at: row.created_at,
|
|
42
|
+
}));
|
|
43
|
+
return {
|
|
44
|
+
note_id: noteId,
|
|
45
|
+
outlinks,
|
|
46
|
+
count: outlinks.length,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export async function getRelatedNotes(db, noteId, depth = 2) {
|
|
50
|
+
const visited = new Set([noteId]);
|
|
51
|
+
const related = new Map();
|
|
52
|
+
async function traverse(currentId, currentDepth) {
|
|
53
|
+
if (currentDepth > depth)
|
|
54
|
+
return;
|
|
55
|
+
const [backlinks, outlinks] = await Promise.all([
|
|
56
|
+
getBacklinks(db, currentId),
|
|
57
|
+
getOutlinks(db, currentId),
|
|
58
|
+
]);
|
|
59
|
+
for (const link of [...backlinks.backlinks, ...outlinks.outlinks]) {
|
|
60
|
+
if (!visited.has(link.note_id)) {
|
|
61
|
+
visited.add(link.note_id);
|
|
62
|
+
related.set(link.note_id, link);
|
|
63
|
+
if (currentDepth < depth) {
|
|
64
|
+
await traverse(link.note_id, currentDepth + 1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
await traverse(noteId, 1);
|
|
70
|
+
return {
|
|
71
|
+
note_id: noteId,
|
|
72
|
+
related: Array.from(related.values()),
|
|
73
|
+
count: related.size,
|
|
74
|
+
depth,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from 'node:http';
|
|
2
|
+
export declare function isLoopbackHost(host: string): boolean;
|
|
3
|
+
export declare function resolveMCPAuthToken(tokenOption: unknown, envToken?: string | undefined): string | undefined;
|
|
4
|
+
export declare function isAuthorizedMCPRequest(headers: IncomingHttpHeaders, expectedToken?: string): boolean;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { DinoxError } from '../../utils/errors.js';
|
|
3
|
+
const AUTH_HEADER_RE = /^Bearer\s+(.+)$/i;
|
|
4
|
+
export function isLoopbackHost(host) {
|
|
5
|
+
const normalized = host.trim().toLowerCase();
|
|
6
|
+
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1';
|
|
7
|
+
}
|
|
8
|
+
export function resolveMCPAuthToken(tokenOption, envToken = process.env.DINOX_MCP_TOKEN) {
|
|
9
|
+
if (tokenOption !== undefined && tokenOption !== null) {
|
|
10
|
+
if (typeof tokenOption !== 'string') {
|
|
11
|
+
throw new DinoxError('--token must be a non-empty string');
|
|
12
|
+
}
|
|
13
|
+
const parsed = tokenOption.trim();
|
|
14
|
+
if (!parsed) {
|
|
15
|
+
throw new DinoxError('--token must be a non-empty string');
|
|
16
|
+
}
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
const fromEnv = envToken?.trim() ?? '';
|
|
20
|
+
return fromEnv || undefined;
|
|
21
|
+
}
|
|
22
|
+
function readHeaderTokenValue(value) {
|
|
23
|
+
if (typeof value === 'string') {
|
|
24
|
+
const parsed = value.trim();
|
|
25
|
+
return parsed || undefined;
|
|
26
|
+
}
|
|
27
|
+
if (!Array.isArray(value)) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
for (const item of value) {
|
|
31
|
+
const parsed = item.trim();
|
|
32
|
+
if (parsed) {
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
function readBearerToken(headers) {
|
|
39
|
+
const authorization = readHeaderTokenValue(headers.authorization);
|
|
40
|
+
if (!authorization) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const match = authorization.match(AUTH_HEADER_RE);
|
|
44
|
+
if (!match) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const token = match[1]?.trim() ?? '';
|
|
48
|
+
return token || undefined;
|
|
49
|
+
}
|
|
50
|
+
function readMCPToken(headers) {
|
|
51
|
+
const bearer = readBearerToken(headers);
|
|
52
|
+
if (bearer) {
|
|
53
|
+
return bearer;
|
|
54
|
+
}
|
|
55
|
+
const raw = readHeaderTokenValue(headers['x-dinox-token']);
|
|
56
|
+
return raw?.trim() || undefined;
|
|
57
|
+
}
|
|
58
|
+
function secureTokenEquals(left, right) {
|
|
59
|
+
const leftBuffer = Buffer.from(left);
|
|
60
|
+
const rightBuffer = Buffer.from(right);
|
|
61
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
65
|
+
}
|
|
66
|
+
export function isAuthorizedMCPRequest(headers, expectedToken) {
|
|
67
|
+
if (!expectedToken) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
const provided = readMCPToken(headers);
|
|
71
|
+
if (!provided) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return secureTokenEquals(provided, expectedToken);
|
|
75
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { startMCPServer } from './server.js';
|
|
2
|
+
import { mcpTools } from './tools.js';
|
|
3
|
+
import { isLoopbackHost, resolveMCPAuthToken } from './auth.js';
|
|
4
|
+
import { DinoxError } from '../../utils/errors.js';
|
|
5
|
+
function parsePort(value) {
|
|
6
|
+
if (value === undefined || value === null || value === '') {
|
|
7
|
+
return 3000;
|
|
8
|
+
}
|
|
9
|
+
if (typeof value !== 'string') {
|
|
10
|
+
throw new DinoxError('--port must be a valid port number');
|
|
11
|
+
}
|
|
12
|
+
const trimmed = value.trim();
|
|
13
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
14
|
+
throw new DinoxError('--port must be a valid port number');
|
|
15
|
+
}
|
|
16
|
+
const parsed = Number(trimmed);
|
|
17
|
+
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
|
18
|
+
throw new DinoxError('--port must be between 1 and 65535');
|
|
19
|
+
}
|
|
20
|
+
return Math.trunc(parsed);
|
|
21
|
+
}
|
|
22
|
+
function parseHost(value) {
|
|
23
|
+
if (value === undefined || value === null || value === '') {
|
|
24
|
+
return '127.0.0.1';
|
|
25
|
+
}
|
|
26
|
+
if (typeof value !== 'string') {
|
|
27
|
+
throw new DinoxError('--host must be a valid hostname or IP address');
|
|
28
|
+
}
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
if (!trimmed) {
|
|
31
|
+
throw new DinoxError('--host must be a valid hostname or IP address');
|
|
32
|
+
}
|
|
33
|
+
return trimmed;
|
|
34
|
+
}
|
|
35
|
+
export function registerMCPCommands(program) {
|
|
36
|
+
const mcp = program.command('mcp').description('MCP (Model Context Protocol) server');
|
|
37
|
+
mcp
|
|
38
|
+
.command('serve')
|
|
39
|
+
.description('Start MCP server for AI agent integration')
|
|
40
|
+
.option('--host <string>', 'bind address (default: 127.0.0.1)')
|
|
41
|
+
.option('--port <number>', 'server port (default: 3000)')
|
|
42
|
+
.option('--token <string>', 'auth token (or DINOX_MCP_TOKEN env var)')
|
|
43
|
+
.action(async (options) => {
|
|
44
|
+
const port = parsePort(options.port);
|
|
45
|
+
const host = parseHost(options.host);
|
|
46
|
+
const token = resolveMCPAuthToken(options.token);
|
|
47
|
+
const loopback = isLoopbackHost(host);
|
|
48
|
+
if (!loopback && !token) {
|
|
49
|
+
throw new DinoxError('--token (or DINOX_MCP_TOKEN) is required when --host is non-loopback');
|
|
50
|
+
}
|
|
51
|
+
console.log('Starting Dinox MCP server...');
|
|
52
|
+
console.log(`Host: ${host}`);
|
|
53
|
+
console.log(`Port: ${port}`);
|
|
54
|
+
console.log(`Auth: ${token ? 'enabled' : 'disabled (loopback only)'}`);
|
|
55
|
+
console.log('Protocol: Model Context Protocol (MCP)');
|
|
56
|
+
if (!loopback) {
|
|
57
|
+
console.log('warning: binding to a non-loopback host may expose your local data; use firewall/auth as needed');
|
|
58
|
+
}
|
|
59
|
+
console.log('');
|
|
60
|
+
console.log('Available tools:');
|
|
61
|
+
for (const tool of mcpTools) {
|
|
62
|
+
console.log(` - ${tool.name}`);
|
|
63
|
+
}
|
|
64
|
+
console.log('');
|
|
65
|
+
const { server, mcpServer } = await startMCPServer(port, host, token);
|
|
66
|
+
let shuttingDown = false;
|
|
67
|
+
const shutdown = async () => {
|
|
68
|
+
if (shuttingDown)
|
|
69
|
+
return;
|
|
70
|
+
shuttingDown = true;
|
|
71
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
72
|
+
await mcpServer.close();
|
|
73
|
+
};
|
|
74
|
+
process.on('SIGINT', () => {
|
|
75
|
+
void shutdown().finally(() => process.exit(0));
|
|
76
|
+
});
|
|
77
|
+
process.on('SIGTERM', () => {
|
|
78
|
+
void shutdown().finally(() => process.exit(0));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
mcp.action((_options, command) => {
|
|
82
|
+
command.outputHelp();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { IncomingMessage, Server } from 'node:http';
|
|
2
|
+
type MCPRequest = {
|
|
3
|
+
jsonrpc: '2.0';
|
|
4
|
+
id: string | number;
|
|
5
|
+
method: string;
|
|
6
|
+
params?: any;
|
|
7
|
+
};
|
|
8
|
+
type MCPResponse = {
|
|
9
|
+
jsonrpc: '2.0';
|
|
10
|
+
id: string | number;
|
|
11
|
+
result?: any;
|
|
12
|
+
error?: {
|
|
13
|
+
code: number;
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export declare class MCPServer {
|
|
18
|
+
private db;
|
|
19
|
+
initialize(): Promise<void>;
|
|
20
|
+
handleRequest(req: MCPRequest): Promise<MCPResponse>;
|
|
21
|
+
private handleInitialize;
|
|
22
|
+
private handleToolsList;
|
|
23
|
+
private handleToolsCall;
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export declare function startMCPServer(port?: number, host?: string, authToken?: string): Promise<{
|
|
27
|
+
server: Server<typeof IncomingMessage, typeof import("http").ServerResponse>;
|
|
28
|
+
mcpServer: MCPServer;
|
|
29
|
+
}>;
|
|
30
|
+
export {};
|