@getlore/cli 0.2.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/LICENSE +13 -0
- package/README.md +80 -0
- package/dist/cli/colors.d.ts +48 -0
- package/dist/cli/colors.js +48 -0
- package/dist/cli/commands/ask.d.ts +7 -0
- package/dist/cli/commands/ask.js +97 -0
- package/dist/cli/commands/auth.d.ts +10 -0
- package/dist/cli/commands/auth.js +484 -0
- package/dist/cli/commands/daemon.d.ts +22 -0
- package/dist/cli/commands/daemon.js +244 -0
- package/dist/cli/commands/docs.d.ts +7 -0
- package/dist/cli/commands/docs.js +188 -0
- package/dist/cli/commands/extensions.d.ts +7 -0
- package/dist/cli/commands/extensions.js +204 -0
- package/dist/cli/commands/misc.d.ts +7 -0
- package/dist/cli/commands/misc.js +172 -0
- package/dist/cli/commands/pending.d.ts +7 -0
- package/dist/cli/commands/pending.js +63 -0
- package/dist/cli/commands/projects.d.ts +7 -0
- package/dist/cli/commands/projects.js +136 -0
- package/dist/cli/commands/search.d.ts +7 -0
- package/dist/cli/commands/search.js +102 -0
- package/dist/cli/commands/skills.d.ts +24 -0
- package/dist/cli/commands/skills.js +447 -0
- package/dist/cli/commands/sources.d.ts +7 -0
- package/dist/cli/commands/sources.js +121 -0
- package/dist/cli/commands/sync.d.ts +31 -0
- package/dist/cli/commands/sync.js +768 -0
- package/dist/cli/helpers.d.ts +30 -0
- package/dist/cli/helpers.js +119 -0
- package/dist/core/auth.d.ts +62 -0
- package/dist/core/auth.js +330 -0
- package/dist/core/config.d.ts +41 -0
- package/dist/core/config.js +96 -0
- package/dist/core/data-repo.d.ts +31 -0
- package/dist/core/data-repo.js +146 -0
- package/dist/core/embedder.d.ts +22 -0
- package/dist/core/embedder.js +104 -0
- package/dist/core/git.d.ts +37 -0
- package/dist/core/git.js +140 -0
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.js +5 -0
- package/dist/core/insight-extractor.d.ts +26 -0
- package/dist/core/insight-extractor.js +114 -0
- package/dist/core/local-search.d.ts +43 -0
- package/dist/core/local-search.js +221 -0
- package/dist/core/themes.d.ts +15 -0
- package/dist/core/themes.js +77 -0
- package/dist/core/types.d.ts +177 -0
- package/dist/core/types.js +9 -0
- package/dist/core/user-settings.d.ts +15 -0
- package/dist/core/user-settings.js +42 -0
- package/dist/core/vector-store-lance.d.ts +98 -0
- package/dist/core/vector-store-lance.js +384 -0
- package/dist/core/vector-store-supabase.d.ts +89 -0
- package/dist/core/vector-store-supabase.js +295 -0
- package/dist/core/vector-store.d.ts +131 -0
- package/dist/core/vector-store.js +503 -0
- package/dist/daemon-runner.d.ts +8 -0
- package/dist/daemon-runner.js +246 -0
- package/dist/extensions/config.d.ts +22 -0
- package/dist/extensions/config.js +102 -0
- package/dist/extensions/proposals.d.ts +30 -0
- package/dist/extensions/proposals.js +178 -0
- package/dist/extensions/registry.d.ts +35 -0
- package/dist/extensions/registry.js +309 -0
- package/dist/extensions/sandbox.d.ts +16 -0
- package/dist/extensions/sandbox.js +17 -0
- package/dist/extensions/types.d.ts +114 -0
- package/dist/extensions/types.js +4 -0
- package/dist/extensions/worker.d.ts +1 -0
- package/dist/extensions/worker.js +49 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +105 -0
- package/dist/mcp/handlers/archive-project.d.ts +51 -0
- package/dist/mcp/handlers/archive-project.js +112 -0
- package/dist/mcp/handlers/get-quotes.d.ts +27 -0
- package/dist/mcp/handlers/get-quotes.js +61 -0
- package/dist/mcp/handlers/get-source.d.ts +9 -0
- package/dist/mcp/handlers/get-source.js +40 -0
- package/dist/mcp/handlers/ingest.d.ts +25 -0
- package/dist/mcp/handlers/ingest.js +305 -0
- package/dist/mcp/handlers/list-projects.d.ts +4 -0
- package/dist/mcp/handlers/list-projects.js +16 -0
- package/dist/mcp/handlers/list-sources.d.ts +11 -0
- package/dist/mcp/handlers/list-sources.js +20 -0
- package/dist/mcp/handlers/research-agent.d.ts +21 -0
- package/dist/mcp/handlers/research-agent.js +369 -0
- package/dist/mcp/handlers/research.d.ts +22 -0
- package/dist/mcp/handlers/research.js +225 -0
- package/dist/mcp/handlers/retain.d.ts +18 -0
- package/dist/mcp/handlers/retain.js +92 -0
- package/dist/mcp/handlers/search.d.ts +52 -0
- package/dist/mcp/handlers/search.js +145 -0
- package/dist/mcp/handlers/sync.d.ts +47 -0
- package/dist/mcp/handlers/sync.js +211 -0
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +268 -0
- package/dist/mcp/tools.d.ts +16 -0
- package/dist/mcp/tools.js +297 -0
- package/dist/sync/config.d.ts +26 -0
- package/dist/sync/config.js +140 -0
- package/dist/sync/discover.d.ts +51 -0
- package/dist/sync/discover.js +190 -0
- package/dist/sync/index.d.ts +11 -0
- package/dist/sync/index.js +11 -0
- package/dist/sync/process.d.ts +50 -0
- package/dist/sync/process.js +285 -0
- package/dist/sync/processors.d.ts +24 -0
- package/dist/sync/processors.js +351 -0
- package/dist/tui/browse-handlers-ask.d.ts +30 -0
- package/dist/tui/browse-handlers-ask.js +372 -0
- package/dist/tui/browse-handlers-autocomplete.d.ts +49 -0
- package/dist/tui/browse-handlers-autocomplete.js +270 -0
- package/dist/tui/browse-handlers-extensions.d.ts +18 -0
- package/dist/tui/browse-handlers-extensions.js +107 -0
- package/dist/tui/browse-handlers-pending.d.ts +22 -0
- package/dist/tui/browse-handlers-pending.js +100 -0
- package/dist/tui/browse-handlers-research.d.ts +32 -0
- package/dist/tui/browse-handlers-research.js +363 -0
- package/dist/tui/browse-handlers-tools.d.ts +42 -0
- package/dist/tui/browse-handlers-tools.js +289 -0
- package/dist/tui/browse-handlers.d.ts +239 -0
- package/dist/tui/browse-handlers.js +1944 -0
- package/dist/tui/browse-render-extensions.d.ts +14 -0
- package/dist/tui/browse-render-extensions.js +114 -0
- package/dist/tui/browse-render-tools.d.ts +18 -0
- package/dist/tui/browse-render-tools.js +259 -0
- package/dist/tui/browse-render.d.ts +51 -0
- package/dist/tui/browse-render.js +599 -0
- package/dist/tui/browse-types.d.ts +142 -0
- package/dist/tui/browse-types.js +70 -0
- package/dist/tui/browse-ui.d.ts +10 -0
- package/dist/tui/browse-ui.js +432 -0
- package/dist/tui/browse.d.ts +17 -0
- package/dist/tui/browse.js +625 -0
- package/dist/tui/markdown.d.ts +22 -0
- package/dist/tui/markdown.js +223 -0
- package/package.json +71 -0
- package/plugins/claude-code/.claude-plugin/plugin.json +10 -0
- package/plugins/claude-code/.mcp.json +6 -0
- package/plugins/claude-code/skills/lore/SKILL.md +63 -0
- package/plugins/codex/SKILL.md +36 -0
- package/plugins/codex/agents/openai.yaml +10 -0
- package/plugins/gemini/GEMINI.md +31 -0
- package/plugins/gemini/gemini-extension.json +11 -0
- package/skills/generic-agent.md +99 -0
- package/skills/openclaw.md +67 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Helper Functions
|
|
3
|
+
*
|
|
4
|
+
* Shared utilities for CLI commands.
|
|
5
|
+
*/
|
|
6
|
+
import type { SourceDocument, Quote, Theme } from '../core/types.js';
|
|
7
|
+
/**
|
|
8
|
+
* Build vector index for ingested sources
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildIndex(dataDir: string, results: Array<{
|
|
11
|
+
source: SourceDocument;
|
|
12
|
+
notes: string;
|
|
13
|
+
transcript: string;
|
|
14
|
+
insights?: {
|
|
15
|
+
summary: string;
|
|
16
|
+
themes: Theme[];
|
|
17
|
+
quotes: Quote[];
|
|
18
|
+
};
|
|
19
|
+
}>): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Save ingested sources to disk
|
|
22
|
+
*/
|
|
23
|
+
export declare function saveSourcesToDisk(sourcesDir: string, results: Array<{
|
|
24
|
+
source: SourceDocument;
|
|
25
|
+
insights?: {
|
|
26
|
+
summary: string;
|
|
27
|
+
themes: Theme[];
|
|
28
|
+
quotes: Quote[];
|
|
29
|
+
};
|
|
30
|
+
}>): Promise<void>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Helper Functions
|
|
3
|
+
*
|
|
4
|
+
* Shared utilities for CLI commands.
|
|
5
|
+
*/
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
8
|
+
import { initializeTables, storeSources, } from '../core/vector-store.js';
|
|
9
|
+
import { generateEmbeddings, createSearchableText } from '../core/embedder.js';
|
|
10
|
+
import { getExtensionRegistry } from '../extensions/registry.js';
|
|
11
|
+
/**
|
|
12
|
+
* Build vector index for ingested sources
|
|
13
|
+
*/
|
|
14
|
+
export async function buildIndex(dataDir, results) {
|
|
15
|
+
const dbPath = path.join(dataDir, 'lore.lance');
|
|
16
|
+
// Initialize tables
|
|
17
|
+
await initializeTables(dbPath);
|
|
18
|
+
// Prepare source records
|
|
19
|
+
const sourceRecords = [];
|
|
20
|
+
// Collect all texts for batch embedding (source summaries only)
|
|
21
|
+
const textsToEmbed = [];
|
|
22
|
+
for (const result of results) {
|
|
23
|
+
const { source, insights } = result;
|
|
24
|
+
const summary = insights?.summary || source.content.substring(0, 500);
|
|
25
|
+
// Add summary for source embedding
|
|
26
|
+
textsToEmbed.push({
|
|
27
|
+
id: `source_${source.id}`,
|
|
28
|
+
text: createSearchableText({ type: 'summary', text: summary, project: source.projects[0] }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// Generate embeddings in batch
|
|
32
|
+
console.log(` Generating ${textsToEmbed.length} embeddings...`);
|
|
33
|
+
const embeddings = await generateEmbeddings(textsToEmbed.map((t) => t.text), undefined, {
|
|
34
|
+
onProgress: (completed, total) => {
|
|
35
|
+
process.stdout.write(`\r Embeddings: ${completed}/${total}`);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
console.log('');
|
|
39
|
+
// Map embeddings back
|
|
40
|
+
const embeddingMap = new Map();
|
|
41
|
+
for (let i = 0; i < textsToEmbed.length; i++) {
|
|
42
|
+
embeddingMap.set(textsToEmbed[i].id, embeddings[i]);
|
|
43
|
+
}
|
|
44
|
+
// Build records
|
|
45
|
+
for (const result of results) {
|
|
46
|
+
const { source, insights } = result;
|
|
47
|
+
const summary = insights?.summary || source.content.substring(0, 500);
|
|
48
|
+
const themes = insights?.themes || [];
|
|
49
|
+
// Source record
|
|
50
|
+
sourceRecords.push({
|
|
51
|
+
source: {
|
|
52
|
+
id: source.id,
|
|
53
|
+
title: source.title,
|
|
54
|
+
source_type: source.source_type,
|
|
55
|
+
content_type: source.content_type,
|
|
56
|
+
projects: JSON.stringify(source.projects),
|
|
57
|
+
tags: JSON.stringify(source.tags),
|
|
58
|
+
created_at: source.created_at,
|
|
59
|
+
summary,
|
|
60
|
+
themes_json: JSON.stringify(themes),
|
|
61
|
+
quotes_json: JSON.stringify([]),
|
|
62
|
+
has_full_content: true,
|
|
63
|
+
vector: [],
|
|
64
|
+
},
|
|
65
|
+
vector: embeddingMap.get(`source_${source.id}`) || [],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// Store in database
|
|
69
|
+
console.log(` Storing ${sourceRecords.length} sources...`);
|
|
70
|
+
await storeSources(dbPath, sourceRecords);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Save ingested sources to disk
|
|
74
|
+
*/
|
|
75
|
+
export async function saveSourcesToDisk(sourcesDir, results) {
|
|
76
|
+
const dataDir = path.dirname(sourcesDir);
|
|
77
|
+
const dbPath = path.join(dataDir, 'lore.lance');
|
|
78
|
+
const extensionRegistry = await getExtensionRegistry({
|
|
79
|
+
logger: (message) => console.error(message),
|
|
80
|
+
});
|
|
81
|
+
for (const result of results) {
|
|
82
|
+
const sourceDir = path.join(sourcesDir, result.source.id);
|
|
83
|
+
await mkdir(sourceDir, { recursive: true });
|
|
84
|
+
// Save content
|
|
85
|
+
await writeFile(path.join(sourceDir, 'content.md'), result.source.content);
|
|
86
|
+
// Save metadata
|
|
87
|
+
const metadata = {
|
|
88
|
+
id: result.source.id,
|
|
89
|
+
title: result.source.title,
|
|
90
|
+
source_type: result.source.source_type,
|
|
91
|
+
content_type: result.source.content_type,
|
|
92
|
+
created_at: result.source.created_at,
|
|
93
|
+
imported_at: result.source.imported_at,
|
|
94
|
+
projects: result.source.projects,
|
|
95
|
+
tags: result.source.tags,
|
|
96
|
+
source_path: result.source.source_path,
|
|
97
|
+
};
|
|
98
|
+
await writeFile(path.join(sourceDir, 'metadata.json'), JSON.stringify(metadata, null, 2));
|
|
99
|
+
// Save insights if extracted
|
|
100
|
+
if (result.insights) {
|
|
101
|
+
await writeFile(path.join(sourceDir, 'insights.json'), JSON.stringify(result.insights, null, 2));
|
|
102
|
+
}
|
|
103
|
+
await extensionRegistry.runHook('onSourceCreated', {
|
|
104
|
+
id: result.source.id,
|
|
105
|
+
title: result.source.title,
|
|
106
|
+
source_type: result.source.source_type,
|
|
107
|
+
content_type: result.source.content_type,
|
|
108
|
+
created_at: result.source.created_at,
|
|
109
|
+
imported_at: result.source.imported_at,
|
|
110
|
+
projects: result.source.projects,
|
|
111
|
+
tags: result.source.tags,
|
|
112
|
+
source_path: result.source.source_path,
|
|
113
|
+
}, {
|
|
114
|
+
mode: 'cli',
|
|
115
|
+
dataDir,
|
|
116
|
+
dbPath,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lore - Supabase Auth Session Management
|
|
3
|
+
*
|
|
4
|
+
* Stores auth session in ~/.config/lore/auth.json.
|
|
5
|
+
* Uses its own unauthenticated Supabase client (publishable key only) for the auth flow,
|
|
6
|
+
* separate from the vector-store singleton.
|
|
7
|
+
*/
|
|
8
|
+
export interface AuthSession {
|
|
9
|
+
access_token: string;
|
|
10
|
+
refresh_token: string;
|
|
11
|
+
expires_at: number;
|
|
12
|
+
user: {
|
|
13
|
+
id: string;
|
|
14
|
+
email: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export declare function getAuthFilePath(): string;
|
|
18
|
+
export declare function loadAuthSession(): Promise<AuthSession | null>;
|
|
19
|
+
export declare function saveAuthSession(session: AuthSession): Promise<void>;
|
|
20
|
+
export declare function clearAuthSession(): Promise<void>;
|
|
21
|
+
export declare function sendOTP(email: string): Promise<{
|
|
22
|
+
error?: string;
|
|
23
|
+
}>;
|
|
24
|
+
export declare function verifyOTP(email: string, token: string): Promise<{
|
|
25
|
+
session?: AuthSession;
|
|
26
|
+
error?: string;
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* Extract session from a magic link URL (Supabase sends these for new signups).
|
|
30
|
+
* Parses the access_token and refresh_token from the URL fragment/query.
|
|
31
|
+
*/
|
|
32
|
+
export declare function sessionFromMagicLink(url: string): Promise<{
|
|
33
|
+
session?: AuthSession;
|
|
34
|
+
error?: string;
|
|
35
|
+
}>;
|
|
36
|
+
/**
|
|
37
|
+
* Get a valid session, auto-refreshing if near expiry (within 5 minutes).
|
|
38
|
+
* Returns null if no session or refresh fails.
|
|
39
|
+
*/
|
|
40
|
+
export declare function getValidSession(): Promise<AuthSession | null>;
|
|
41
|
+
/**
|
|
42
|
+
* Quick check: is there a valid (or refreshable) auth session?
|
|
43
|
+
*/
|
|
44
|
+
export declare function isAuthenticated(): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Start a temporary local HTTP server to catch the Supabase magic link redirect.
|
|
47
|
+
* The magic link redirects to http://localhost:3000/#access_token=...
|
|
48
|
+
* This server serves an HTML page that extracts the fragment and POSTs it back.
|
|
49
|
+
*
|
|
50
|
+
* Tries the configured redirect port (default 3000), then falls back to
|
|
51
|
+
* alternatives if that port is occupied.
|
|
52
|
+
*
|
|
53
|
+
* Returns a promise that resolves with the auth session when the callback is received,
|
|
54
|
+
* or null if it times out / all ports fail.
|
|
55
|
+
*/
|
|
56
|
+
export declare function waitForMagicLinkCallback(options: {
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
onListening?: (port: number) => void;
|
|
59
|
+
}): {
|
|
60
|
+
promise: Promise<AuthSession | null>;
|
|
61
|
+
abort: () => void;
|
|
62
|
+
};
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lore - Supabase Auth Session Management
|
|
3
|
+
*
|
|
4
|
+
* Stores auth session in ~/.config/lore/auth.json.
|
|
5
|
+
* Uses its own unauthenticated Supabase client (publishable key only) for the auth flow,
|
|
6
|
+
* separate from the vector-store singleton.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile, writeFile, mkdir, unlink } from 'fs/promises';
|
|
9
|
+
import { existsSync } from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import http from 'http';
|
|
12
|
+
import { createClient } from '@supabase/supabase-js';
|
|
13
|
+
import { getLoreConfigDir, loadLoreConfig } from './config.js';
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Paths
|
|
16
|
+
// ============================================================================
|
|
17
|
+
const AUTH_FILE = path.join(getLoreConfigDir(), 'auth.json');
|
|
18
|
+
export function getAuthFilePath() {
|
|
19
|
+
return AUTH_FILE;
|
|
20
|
+
}
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Session Persistence
|
|
23
|
+
// ============================================================================
|
|
24
|
+
export async function loadAuthSession() {
|
|
25
|
+
if (!existsSync(AUTH_FILE)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const content = await readFile(AUTH_FILE, 'utf-8');
|
|
30
|
+
return JSON.parse(content);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function saveAuthSession(session) {
|
|
37
|
+
await mkdir(getLoreConfigDir(), { recursive: true });
|
|
38
|
+
await writeFile(AUTH_FILE, JSON.stringify(session, null, 2) + '\n', { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
export async function clearAuthSession() {
|
|
41
|
+
if (existsSync(AUTH_FILE)) {
|
|
42
|
+
await unlink(AUTH_FILE);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Auth Client (unauthenticated, publishable key only)
|
|
47
|
+
// ============================================================================
|
|
48
|
+
async function getAuthClient() {
|
|
49
|
+
const config = await loadLoreConfig();
|
|
50
|
+
const url = config.supabaseUrl;
|
|
51
|
+
const key = config.supabasePublishableKey;
|
|
52
|
+
if (!url || !key) {
|
|
53
|
+
throw new Error('Supabase configuration is missing. This should not happen — please reinstall Lore.');
|
|
54
|
+
}
|
|
55
|
+
return createClient(url, key);
|
|
56
|
+
}
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// OTP Flow
|
|
59
|
+
// ============================================================================
|
|
60
|
+
export async function sendOTP(email) {
|
|
61
|
+
const client = await getAuthClient();
|
|
62
|
+
const { error } = await client.auth.signInWithOtp({ email });
|
|
63
|
+
if (error) {
|
|
64
|
+
return { error: error.message };
|
|
65
|
+
}
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
export async function verifyOTP(email, token) {
|
|
69
|
+
const client = await getAuthClient();
|
|
70
|
+
// Try 'magiclink' first (for returning users), then 'email', then 'signup' (for new users)
|
|
71
|
+
const types = ['magiclink', 'email', 'signup'];
|
|
72
|
+
for (const type of types) {
|
|
73
|
+
const { data, error } = await client.auth.verifyOtp({
|
|
74
|
+
email,
|
|
75
|
+
token,
|
|
76
|
+
type,
|
|
77
|
+
});
|
|
78
|
+
if (!error && data.session && data.user) {
|
|
79
|
+
return { session: toAuthSession(data.session, data.user, email) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// All types failed — return the last error
|
|
83
|
+
return { error: 'Token has expired or is invalid. Request a new code with \'lore login\'.' };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Extract session from a magic link URL (Supabase sends these for new signups).
|
|
87
|
+
* Parses the access_token and refresh_token from the URL fragment/query.
|
|
88
|
+
*/
|
|
89
|
+
export async function sessionFromMagicLink(url) {
|
|
90
|
+
try {
|
|
91
|
+
// Supabase magic links put tokens in the fragment: .../#access_token=...&refresh_token=...
|
|
92
|
+
const fragment = url.includes('#') ? url.split('#')[1] : url.split('?')[1];
|
|
93
|
+
if (!fragment) {
|
|
94
|
+
return { error: 'Could not parse magic link URL' };
|
|
95
|
+
}
|
|
96
|
+
const params = new URLSearchParams(fragment);
|
|
97
|
+
const accessToken = params.get('access_token');
|
|
98
|
+
const refreshToken = params.get('refresh_token');
|
|
99
|
+
if (!accessToken || !refreshToken) {
|
|
100
|
+
return { error: 'Magic link URL missing access_token or refresh_token' };
|
|
101
|
+
}
|
|
102
|
+
// Use the tokens to set the session in the Supabase client
|
|
103
|
+
const client = await getAuthClient();
|
|
104
|
+
const { data, error } = await client.auth.setSession({
|
|
105
|
+
access_token: accessToken,
|
|
106
|
+
refresh_token: refreshToken,
|
|
107
|
+
});
|
|
108
|
+
if (error) {
|
|
109
|
+
return { error: error.message };
|
|
110
|
+
}
|
|
111
|
+
if (!data.session || !data.user) {
|
|
112
|
+
return { error: 'No session returned from magic link' };
|
|
113
|
+
}
|
|
114
|
+
return { session: toAuthSession(data.session, data.user) };
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
return { error: `Failed to parse magic link: ${err}` };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// Helpers
|
|
122
|
+
// ============================================================================
|
|
123
|
+
function toAuthSession(session, user, fallbackEmail) {
|
|
124
|
+
const authSession = {
|
|
125
|
+
access_token: session.access_token,
|
|
126
|
+
refresh_token: session.refresh_token,
|
|
127
|
+
expires_at: session.expires_at || 0,
|
|
128
|
+
user: {
|
|
129
|
+
id: user.id,
|
|
130
|
+
email: user.email || fallbackEmail || '',
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
// Fire-and-forget save (caller can also save explicitly)
|
|
134
|
+
saveAuthSession(authSession);
|
|
135
|
+
return authSession;
|
|
136
|
+
}
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// Session Validation & Refresh
|
|
139
|
+
// ============================================================================
|
|
140
|
+
/**
|
|
141
|
+
* Get a valid session, auto-refreshing if near expiry (within 5 minutes).
|
|
142
|
+
* Returns null if no session or refresh fails.
|
|
143
|
+
*/
|
|
144
|
+
export async function getValidSession() {
|
|
145
|
+
const session = await loadAuthSession();
|
|
146
|
+
if (!session)
|
|
147
|
+
return null;
|
|
148
|
+
const now = Math.floor(Date.now() / 1000);
|
|
149
|
+
const bufferSeconds = 5 * 60; // 5 minutes
|
|
150
|
+
// Session still valid and not near expiry
|
|
151
|
+
if (session.expires_at > now + bufferSeconds) {
|
|
152
|
+
return session;
|
|
153
|
+
}
|
|
154
|
+
// Try to refresh
|
|
155
|
+
try {
|
|
156
|
+
const client = await getAuthClient();
|
|
157
|
+
const { data, error } = await client.auth.refreshSession({
|
|
158
|
+
refresh_token: session.refresh_token,
|
|
159
|
+
});
|
|
160
|
+
if (error || !data.session || !data.user) {
|
|
161
|
+
// Refresh failed — session is expired
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const refreshed = {
|
|
165
|
+
access_token: data.session.access_token,
|
|
166
|
+
refresh_token: data.session.refresh_token,
|
|
167
|
+
expires_at: data.session.expires_at || 0,
|
|
168
|
+
user: {
|
|
169
|
+
id: data.user.id,
|
|
170
|
+
email: data.user.email || session.user.email,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
await saveAuthSession(refreshed);
|
|
174
|
+
return refreshed;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Quick check: is there a valid (or refreshable) auth session?
|
|
182
|
+
*/
|
|
183
|
+
export async function isAuthenticated() {
|
|
184
|
+
const session = await getValidSession();
|
|
185
|
+
return session !== null;
|
|
186
|
+
}
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// Magic Link Callback Server
|
|
189
|
+
// ============================================================================
|
|
190
|
+
const CALLBACK_HTML = `<!DOCTYPE html>
|
|
191
|
+
<html><head><title>Lore Login</title>
|
|
192
|
+
<style>
|
|
193
|
+
body { font-family: system-ui, sans-serif; display: flex; justify-content: center;
|
|
194
|
+
align-items: center; min-height: 100vh; margin: 0; background: #1a1a2e; color: #e0e0e0; }
|
|
195
|
+
.card { background: #16213e; padding: 2rem 3rem; border-radius: 12px; text-align: center;
|
|
196
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.3); }
|
|
197
|
+
h1 { color: #00d4aa; margin-bottom: 0.5rem; }
|
|
198
|
+
p { color: #a0a0b0; }
|
|
199
|
+
</style></head>
|
|
200
|
+
<body><div class="card">
|
|
201
|
+
<h1 id="title">Signing you in...</h1>
|
|
202
|
+
<p id="msg">Sending credentials to Lore CLI</p>
|
|
203
|
+
</div>
|
|
204
|
+
<script>
|
|
205
|
+
const hash = window.location.hash.substring(1);
|
|
206
|
+
if (hash) {
|
|
207
|
+
fetch('/callback', { method: 'POST', headers: {'Content-Type':'application/x-www-form-urlencoded'}, body: hash })
|
|
208
|
+
.then(r => r.json())
|
|
209
|
+
.then(d => {
|
|
210
|
+
document.getElementById('title').textContent = 'Signed in!';
|
|
211
|
+
document.getElementById('msg').textContent = d.email ? 'Logged in as ' + d.email + '. You can close this tab.' : 'You can close this tab.';
|
|
212
|
+
})
|
|
213
|
+
.catch(() => {
|
|
214
|
+
document.getElementById('title').textContent = 'Error';
|
|
215
|
+
document.getElementById('msg').textContent = 'Could not complete login. Try pasting the URL into the terminal.';
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
document.getElementById('title').textContent = 'No credentials found';
|
|
219
|
+
document.getElementById('msg').textContent = 'The magic link may have expired.';
|
|
220
|
+
}
|
|
221
|
+
</script></body></html>`;
|
|
222
|
+
/**
|
|
223
|
+
* Start a temporary local HTTP server to catch the Supabase magic link redirect.
|
|
224
|
+
* The magic link redirects to http://localhost:3000/#access_token=...
|
|
225
|
+
* This server serves an HTML page that extracts the fragment and POSTs it back.
|
|
226
|
+
*
|
|
227
|
+
* Tries the configured redirect port (default 3000), then falls back to
|
|
228
|
+
* alternatives if that port is occupied.
|
|
229
|
+
*
|
|
230
|
+
* Returns a promise that resolves with the auth session when the callback is received,
|
|
231
|
+
* or null if it times out / all ports fail.
|
|
232
|
+
*/
|
|
233
|
+
export function waitForMagicLinkCallback(options) {
|
|
234
|
+
const { timeoutMs = 120_000, onListening } = options;
|
|
235
|
+
// Must match the Supabase project's "Site URL" setting.
|
|
236
|
+
// We use an uncommon port to avoid clashing with dev servers.
|
|
237
|
+
// Configure in Supabase Dashboard > Auth > URL Configuration > Site URL:
|
|
238
|
+
// http://localhost:54321
|
|
239
|
+
const portsToTry = [54321];
|
|
240
|
+
let server = null;
|
|
241
|
+
let timeout;
|
|
242
|
+
let resolved = false;
|
|
243
|
+
const promise = new Promise(async (resolve) => {
|
|
244
|
+
const finish = (session) => {
|
|
245
|
+
if (resolved)
|
|
246
|
+
return;
|
|
247
|
+
resolved = true;
|
|
248
|
+
clearTimeout(timeout);
|
|
249
|
+
server?.close();
|
|
250
|
+
resolve(session);
|
|
251
|
+
};
|
|
252
|
+
const handler = (req, res) => {
|
|
253
|
+
if (req.method === 'GET' && (req.url === '/' || req.url?.startsWith('/#'))) {
|
|
254
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
255
|
+
res.end(CALLBACK_HTML);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (req.method === 'POST' && req.url === '/callback') {
|
|
259
|
+
let body = '';
|
|
260
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
261
|
+
req.on('end', async () => {
|
|
262
|
+
try {
|
|
263
|
+
const params = new URLSearchParams(body);
|
|
264
|
+
const accessToken = params.get('access_token');
|
|
265
|
+
const refreshToken = params.get('refresh_token');
|
|
266
|
+
if (!accessToken || !refreshToken) {
|
|
267
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
268
|
+
res.end(JSON.stringify({ error: 'Missing tokens' }));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const client = await getAuthClient();
|
|
272
|
+
const { data, error } = await client.auth.setSession({
|
|
273
|
+
access_token: accessToken,
|
|
274
|
+
refresh_token: refreshToken,
|
|
275
|
+
});
|
|
276
|
+
if (error || !data.session || !data.user) {
|
|
277
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
278
|
+
res.end(JSON.stringify({ error: error?.message || 'Session failed' }));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const session = toAuthSession(data.session, data.user);
|
|
282
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
283
|
+
res.end(JSON.stringify({ ok: true, email: session.user.email }));
|
|
284
|
+
finish(session);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
288
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
res.writeHead(404);
|
|
294
|
+
res.end();
|
|
295
|
+
};
|
|
296
|
+
// Try each port until one works
|
|
297
|
+
for (const port of portsToTry) {
|
|
298
|
+
try {
|
|
299
|
+
server = await tryListen(handler, port);
|
|
300
|
+
onListening?.(port);
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Port in use, try next
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (!server) {
|
|
309
|
+
// All ports occupied — fall back to manual flow
|
|
310
|
+
finish(null);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
timeout = setTimeout(() => finish(null), timeoutMs);
|
|
314
|
+
});
|
|
315
|
+
const abort = () => {
|
|
316
|
+
if (!resolved) {
|
|
317
|
+
resolved = true;
|
|
318
|
+
clearTimeout(timeout);
|
|
319
|
+
server?.close();
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
return { promise, abort };
|
|
323
|
+
}
|
|
324
|
+
function tryListen(handler, port) {
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
const s = http.createServer(handler);
|
|
327
|
+
s.on('error', reject);
|
|
328
|
+
s.listen(port, () => resolve(s));
|
|
329
|
+
});
|
|
330
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lore - Centralized Config Loader
|
|
3
|
+
*
|
|
4
|
+
* Loads configuration from ~/.config/lore/config.json with env var overrides.
|
|
5
|
+
* Resolution order: process.env > config.json > error
|
|
6
|
+
*
|
|
7
|
+
* Service key (SUPABASE_SERVICE_KEY) is env-only and never stored in config.
|
|
8
|
+
*/
|
|
9
|
+
export interface LoreConfig {
|
|
10
|
+
version: number;
|
|
11
|
+
supabase_url?: string;
|
|
12
|
+
supabase_publishable_key?: string;
|
|
13
|
+
/** @deprecated Use supabase_publishable_key instead */
|
|
14
|
+
supabase_anon_key?: string;
|
|
15
|
+
openai_api_key?: string;
|
|
16
|
+
anthropic_api_key?: string;
|
|
17
|
+
data_dir?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ResolvedConfig {
|
|
20
|
+
supabaseUrl?: string;
|
|
21
|
+
supabasePublishableKey?: string;
|
|
22
|
+
openaiApiKey?: string;
|
|
23
|
+
anthropicApiKey?: string;
|
|
24
|
+
dataDir?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function getLoreConfigPath(): string;
|
|
27
|
+
export declare function getLoreConfigDir(): string;
|
|
28
|
+
/**
|
|
29
|
+
* Load resolved config: process.env takes precedence over config.json.
|
|
30
|
+
*/
|
|
31
|
+
export declare function loadLoreConfig(): Promise<ResolvedConfig>;
|
|
32
|
+
/**
|
|
33
|
+
* Save config to disk. Only saves non-sensitive keys.
|
|
34
|
+
* Service key (SUPABASE_SERVICE_KEY) is never stored.
|
|
35
|
+
*/
|
|
36
|
+
export declare function saveLoreConfig(config: Partial<LoreConfig>): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Bridge config values into process.env for backward compatibility.
|
|
39
|
+
* Only sets env vars that aren't already set (env takes precedence).
|
|
40
|
+
*/
|
|
41
|
+
export declare function bridgeConfigToEnv(): Promise<void>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lore - Centralized Config Loader
|
|
3
|
+
*
|
|
4
|
+
* Loads configuration from ~/.config/lore/config.json with env var overrides.
|
|
5
|
+
* Resolution order: process.env > config.json > error
|
|
6
|
+
*
|
|
7
|
+
* Service key (SUPABASE_SERVICE_KEY) is env-only and never stored in config.
|
|
8
|
+
*/
|
|
9
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
10
|
+
import { existsSync } from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Hosted Service Defaults (public, safe to ship — RLS protects data)
|
|
15
|
+
// ============================================================================
|
|
16
|
+
const DEFAULT_SUPABASE_URL = 'https://lyuykpxsntxixsdrkjya.supabase.co';
|
|
17
|
+
const DEFAULT_SUPABASE_PUBLISHABLE_KEY = 'sb_publishable_EXHW5HwqNmiiXkhZ7eYIqQ_l1xqfNpa';
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Paths
|
|
20
|
+
// ============================================================================
|
|
21
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'lore');
|
|
22
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
23
|
+
export function getLoreConfigPath() {
|
|
24
|
+
return CONFIG_FILE;
|
|
25
|
+
}
|
|
26
|
+
export function getLoreConfigDir() {
|
|
27
|
+
return CONFIG_DIR;
|
|
28
|
+
}
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Config Loading
|
|
31
|
+
// ============================================================================
|
|
32
|
+
/**
|
|
33
|
+
* Load config from disk. Returns null if config file doesn't exist.
|
|
34
|
+
*/
|
|
35
|
+
async function loadConfigFile() {
|
|
36
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const content = await readFile(CONFIG_FILE, 'utf-8');
|
|
41
|
+
return JSON.parse(content);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Load resolved config: process.env takes precedence over config.json.
|
|
49
|
+
*/
|
|
50
|
+
export async function loadLoreConfig() {
|
|
51
|
+
const file = await loadConfigFile();
|
|
52
|
+
return {
|
|
53
|
+
supabaseUrl: process.env.SUPABASE_URL || file?.supabase_url || DEFAULT_SUPABASE_URL,
|
|
54
|
+
supabasePublishableKey: process.env.SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_ANON_KEY || file?.supabase_publishable_key || file?.supabase_anon_key || DEFAULT_SUPABASE_PUBLISHABLE_KEY,
|
|
55
|
+
openaiApiKey: process.env.OPENAI_API_KEY || file?.openai_api_key,
|
|
56
|
+
anthropicApiKey: process.env.ANTHROPIC_API_KEY || file?.anthropic_api_key,
|
|
57
|
+
dataDir: process.env.LORE_DATA_DIR || file?.data_dir,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Save config to disk. Only saves non-sensitive keys.
|
|
62
|
+
* Service key (SUPABASE_SERVICE_KEY) is never stored.
|
|
63
|
+
*/
|
|
64
|
+
export async function saveLoreConfig(config) {
|
|
65
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
66
|
+
// Merge with existing config
|
|
67
|
+
const existing = await loadConfigFile();
|
|
68
|
+
const merged = {
|
|
69
|
+
version: 1,
|
|
70
|
+
...existing,
|
|
71
|
+
...config,
|
|
72
|
+
};
|
|
73
|
+
await writeFile(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Bridge config values into process.env for backward compatibility.
|
|
77
|
+
* Only sets env vars that aren't already set (env takes precedence).
|
|
78
|
+
*/
|
|
79
|
+
export async function bridgeConfigToEnv() {
|
|
80
|
+
const config = await loadLoreConfig();
|
|
81
|
+
if (config.supabaseUrl && !process.env.SUPABASE_URL) {
|
|
82
|
+
process.env.SUPABASE_URL = config.supabaseUrl;
|
|
83
|
+
}
|
|
84
|
+
if (config.supabasePublishableKey && !process.env.SUPABASE_PUBLISHABLE_KEY) {
|
|
85
|
+
process.env.SUPABASE_PUBLISHABLE_KEY = config.supabasePublishableKey;
|
|
86
|
+
}
|
|
87
|
+
if (config.openaiApiKey && !process.env.OPENAI_API_KEY) {
|
|
88
|
+
process.env.OPENAI_API_KEY = config.openaiApiKey;
|
|
89
|
+
}
|
|
90
|
+
if (config.anthropicApiKey && !process.env.ANTHROPIC_API_KEY) {
|
|
91
|
+
process.env.ANTHROPIC_API_KEY = config.anthropicApiKey;
|
|
92
|
+
}
|
|
93
|
+
if (config.dataDir && !process.env.LORE_DATA_DIR) {
|
|
94
|
+
process.env.LORE_DATA_DIR = config.dataDir;
|
|
95
|
+
}
|
|
96
|
+
}
|