@ophan/cli 0.0.1
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/README.md +82 -0
- package/dist/auth.js +239 -0
- package/dist/index.js +245 -0
- package/dist/sync.js +255 -0
- package/dist/sync.test.js +288 -0
- package/dist/test-utils.js +161 -0
- package/dist/watch.js +247 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @ophan/cli
|
|
2
|
+
|
|
3
|
+
Command-line interface for analyzing codebases and managing Ophan databases.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
### Repository Analysis
|
|
8
|
+
```bash
|
|
9
|
+
# Analyze a repo (creates .ophan/index.db)
|
|
10
|
+
npx ophan analyze --path /path/to/repo
|
|
11
|
+
|
|
12
|
+
# Or as dev dependency
|
|
13
|
+
npm install --save-dev ophan
|
|
14
|
+
npx ophan analyze
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Commands
|
|
18
|
+
- `ophan analyze` — Scan repo, extract functions, analyze with Claude, store by content hash
|
|
19
|
+
- `ophan sync` — Push/pull analysis to/from Supabase (requires auth)
|
|
20
|
+
- `ophan gc` — Garbage collect orphaned analysis entries (manual only, never automatic, 30-day grace period)
|
|
21
|
+
|
|
22
|
+
## How Analysis Works
|
|
23
|
+
|
|
24
|
+
### Initial Run
|
|
25
|
+
1. Discovers all TypeScript/JavaScript source files
|
|
26
|
+
2. Extracts functions, computes SHA256 content hash for each
|
|
27
|
+
3. Checks local DB — skips functions with existing analysis for that hash
|
|
28
|
+
4. Sends new functions to Claude for analysis
|
|
29
|
+
5. Stores results in `function_analysis` (keyed by content_hash)
|
|
30
|
+
6. Updates `file_functions` with file path, function name, content_hash, mtime
|
|
31
|
+
|
|
32
|
+
### Incremental Updates
|
|
33
|
+
1. Checks `file_mtime` in `file_functions` — skips entirely unchanged files (no parsing needed)
|
|
34
|
+
2. Re-parses changed files, re-hashes functions
|
|
35
|
+
3. Only analyzes functions with new/unknown hashes
|
|
36
|
+
4. Updates `file_functions` mappings
|
|
37
|
+
|
|
38
|
+
### Garbage Collection
|
|
39
|
+
`ophan gc` is manual only — never runs automatically. This protects against branch switching scenarios (e.g. function deleted on feature branch but still exists on main).
|
|
40
|
+
|
|
41
|
+
How it works:
|
|
42
|
+
1. Scans codebase, computes all current hashes
|
|
43
|
+
2. Compares to stored `function_analysis` entries
|
|
44
|
+
3. Only deletes entries not seen in grace period (default 30 days, configurable)
|
|
45
|
+
4. Updates `last_referenced_at` in Supabase for synced entries
|
|
46
|
+
5. If synced, GC'd entries can be re-downloaded on next sync instead of re-analyzed
|
|
47
|
+
|
|
48
|
+
`file_functions` is ephemeral — rebuilt on every scan. `function_analysis` is persistent until explicit GC.
|
|
49
|
+
|
|
50
|
+
### Sync
|
|
51
|
+
`ophan sync` pushes/pulls `function_analysis` rows to/from Supabase:
|
|
52
|
+
- Insert-only (content-addressed = immutable, no conflicts)
|
|
53
|
+
- Free users: scoped to `user_id`
|
|
54
|
+
- Team users: scoped to `team_id` (new members pull existing analysis)
|
|
55
|
+
|
|
56
|
+
## Design Principles
|
|
57
|
+
|
|
58
|
+
### Local-First
|
|
59
|
+
All analysis stored in per-repo `.ophan/index.db` (gitignored). No cloud required for core functionality. Supabase sync is optional.
|
|
60
|
+
|
|
61
|
+
### Dev Dependency Model
|
|
62
|
+
Add to `devDependencies` — one engineer installs, whole team gets CLI on `npm install`. Bottom-up adoption path.
|
|
63
|
+
|
|
64
|
+
### Non-Invasive
|
|
65
|
+
Database in hidden `.ophan/` directory, gitignored. Does not modify source code or project configuration.
|
|
66
|
+
|
|
67
|
+
### Content-Addressed
|
|
68
|
+
Analysis keyed by function content hash, not file path. Branch-agnostic, merge-friendly, deduplication built in.
|
|
69
|
+
|
|
70
|
+
## Output
|
|
71
|
+
|
|
72
|
+
Creates `.ophan/index.db` containing:
|
|
73
|
+
- `function_analysis` — content_hash, analysis JSON, model version, timestamp
|
|
74
|
+
- `file_functions` — file path, function name, content_hash, file mtime
|
|
75
|
+
|
|
76
|
+
## CI/CD Integration
|
|
77
|
+
|
|
78
|
+
### GitHub Action (Planned)
|
|
79
|
+
Run `ophan analyze` on PRs:
|
|
80
|
+
- Comment with new function documentation
|
|
81
|
+
- Fail if new security warnings introduced
|
|
82
|
+
- Show data flow changes in PR review
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.readCredentials = readCredentials;
|
|
37
|
+
exports.saveCredentials = saveCredentials;
|
|
38
|
+
exports.deleteCredentials = deleteCredentials;
|
|
39
|
+
exports.login = login;
|
|
40
|
+
exports.getAuthenticatedClient = getAuthenticatedClient;
|
|
41
|
+
const supabase_js_1 = require("@supabase/supabase-js");
|
|
42
|
+
const http = __importStar(require("http"));
|
|
43
|
+
const net = __importStar(require("net"));
|
|
44
|
+
const fs = __importStar(require("fs"));
|
|
45
|
+
const path = __importStar(require("path"));
|
|
46
|
+
const os = __importStar(require("os"));
|
|
47
|
+
const CREDENTIALS_DIR = path.join(os.homedir(), ".ophan");
|
|
48
|
+
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
|
|
49
|
+
function readCredentials() {
|
|
50
|
+
try {
|
|
51
|
+
const data = fs.readFileSync(CREDENTIALS_FILE, "utf8");
|
|
52
|
+
return JSON.parse(data);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function saveCredentials(creds) {
|
|
59
|
+
fs.mkdirSync(CREDENTIALS_DIR, { recursive: true });
|
|
60
|
+
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), "utf8");
|
|
61
|
+
}
|
|
62
|
+
function deleteCredentials() {
|
|
63
|
+
try {
|
|
64
|
+
fs.unlinkSync(CREDENTIALS_FILE);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Already gone
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function findAvailablePort() {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const server = net.createServer();
|
|
73
|
+
server.listen(0, () => {
|
|
74
|
+
const addr = server.address();
|
|
75
|
+
if (!addr || typeof addr === "string") {
|
|
76
|
+
server.close();
|
|
77
|
+
return reject(new Error("Could not find available port"));
|
|
78
|
+
}
|
|
79
|
+
const port = addr.port;
|
|
80
|
+
server.close(() => resolve(port));
|
|
81
|
+
});
|
|
82
|
+
server.on("error", reject);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
86
|
+
<html>
|
|
87
|
+
<head><title>Ophan CLI</title>
|
|
88
|
+
<style>
|
|
89
|
+
body { font-family: -apple-system, system-ui, sans-serif; display: flex;
|
|
90
|
+
justify-content: center; align-items: center; min-height: 100vh;
|
|
91
|
+
margin: 0; background: #0a0a0a; color: #fafafa; }
|
|
92
|
+
.card { text-align: center; padding: 3rem; }
|
|
93
|
+
h1 { color: #2dd4bf; margin-bottom: 0.5rem; }
|
|
94
|
+
p { color: #a3a3a3; }
|
|
95
|
+
</style>
|
|
96
|
+
</head>
|
|
97
|
+
<body>
|
|
98
|
+
<div class="card">
|
|
99
|
+
<h1>Logged in!</h1>
|
|
100
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
101
|
+
</div>
|
|
102
|
+
</body>
|
|
103
|
+
</html>`;
|
|
104
|
+
/**
|
|
105
|
+
* Browser-based login flow.
|
|
106
|
+
* 1. Start local HTTP server
|
|
107
|
+
* 2. Open browser to webapp /auth/cli?port=PORT
|
|
108
|
+
* 3. Wait for callback with tokens
|
|
109
|
+
* 4. Store credentials and return
|
|
110
|
+
*/
|
|
111
|
+
async function login(webappUrl, timeoutMs = 120000) {
|
|
112
|
+
const port = await findAvailablePort();
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const timeout = setTimeout(() => {
|
|
115
|
+
server.close();
|
|
116
|
+
reject(new Error("Login timed out. Please try again with `ophan login`."));
|
|
117
|
+
}, timeoutMs);
|
|
118
|
+
const server = http.createServer(async (req, res) => {
|
|
119
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
120
|
+
if (url.pathname !== "/callback") {
|
|
121
|
+
res.writeHead(404, { Connection: "close" });
|
|
122
|
+
res.end("Not found");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const tokenHash = url.searchParams.get("token_hash");
|
|
126
|
+
const supabaseUrl = url.searchParams.get("supabase_url");
|
|
127
|
+
const supabaseAnonKey = url.searchParams.get("supabase_anon_key");
|
|
128
|
+
if (!tokenHash || !supabaseUrl || !supabaseAnonKey) {
|
|
129
|
+
res.writeHead(400, { Connection: "close" });
|
|
130
|
+
res.end("Missing required parameters");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Exchange token_hash for an independent session via verifyOtp
|
|
134
|
+
const supabase = (0, supabase_js_1.createClient)(supabaseUrl, supabaseAnonKey, {
|
|
135
|
+
auth: {
|
|
136
|
+
autoRefreshToken: false,
|
|
137
|
+
persistSession: false,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
const { data, error: otpError } = await supabase.auth.verifyOtp({
|
|
141
|
+
token_hash: tokenHash,
|
|
142
|
+
type: "magiclink",
|
|
143
|
+
});
|
|
144
|
+
if (otpError || !data.session) {
|
|
145
|
+
const msg = otpError?.message || "No session returned";
|
|
146
|
+
res.writeHead(400, {
|
|
147
|
+
"Content-Type": "text/html",
|
|
148
|
+
Connection: "close",
|
|
149
|
+
});
|
|
150
|
+
res.end(`<html><body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0a0a0a;color:#fafafa"><div style="text-align:center"><h1 style="color:#ef4444">Login failed</h1><p style="color:#a3a3a3">${msg}</p></div></body></html>`);
|
|
151
|
+
clearTimeout(timeout);
|
|
152
|
+
server.closeAllConnections();
|
|
153
|
+
server.close(() => reject(new Error(msg)));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const creds = {
|
|
157
|
+
api_url: supabaseUrl,
|
|
158
|
+
api_key: supabaseAnonKey,
|
|
159
|
+
access_token: data.session.access_token,
|
|
160
|
+
refresh_token: data.session.refresh_token,
|
|
161
|
+
user_id: data.session.user.id,
|
|
162
|
+
email: data.session.user.email || "",
|
|
163
|
+
expires_at: data.session.expires_at || 0,
|
|
164
|
+
};
|
|
165
|
+
saveCredentials(creds);
|
|
166
|
+
// Connection: close tells browser to not keep-alive, so server.close() resolves immediately
|
|
167
|
+
res.writeHead(200, { "Content-Type": "text/html", Connection: "close" });
|
|
168
|
+
res.end(SUCCESS_HTML);
|
|
169
|
+
clearTimeout(timeout);
|
|
170
|
+
server.closeAllConnections();
|
|
171
|
+
server.close(() => resolve(creds));
|
|
172
|
+
});
|
|
173
|
+
server.listen(port, async () => {
|
|
174
|
+
const authUrl = `${webappUrl}/auth/cli?port=${port}`;
|
|
175
|
+
console.log(`\n Opening browser to sign in...`);
|
|
176
|
+
console.log(` If it doesn't open, visit: ${authUrl}\n`);
|
|
177
|
+
try {
|
|
178
|
+
const open = (await Promise.resolve().then(() => __importStar(require("open")))).default;
|
|
179
|
+
await open(authUrl);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Browser open failed — user can still visit the URL manually
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
server.on("error", (err) => {
|
|
186
|
+
clearTimeout(timeout);
|
|
187
|
+
reject(err);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Get an authenticated Supabase client from stored credentials.
|
|
193
|
+
* Only refreshes tokens when they're actually expired — avoids consuming
|
|
194
|
+
* the refresh token unnecessarily (which would invalidate it via rotation).
|
|
195
|
+
*/
|
|
196
|
+
async function getAuthenticatedClient() {
|
|
197
|
+
const creds = readCredentials();
|
|
198
|
+
if (!creds) {
|
|
199
|
+
throw new Error("Not logged in. Run `ophan login` first.");
|
|
200
|
+
}
|
|
201
|
+
const supabase = (0, supabase_js_1.createClient)(creds.api_url, creds.api_key, {
|
|
202
|
+
auth: {
|
|
203
|
+
autoRefreshToken: false,
|
|
204
|
+
persistSession: false,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const now = Math.floor(Date.now() / 1000);
|
|
208
|
+
const BUFFER_SECONDS = 60;
|
|
209
|
+
if (creds.expires_at && creds.expires_at > now + BUFFER_SECONDS) {
|
|
210
|
+
// Access token still valid — set session without triggering refresh.
|
|
211
|
+
// This does NOT consume the refresh token.
|
|
212
|
+
const { error } = await supabase.auth.setSession({
|
|
213
|
+
access_token: creds.access_token,
|
|
214
|
+
refresh_token: creds.refresh_token,
|
|
215
|
+
});
|
|
216
|
+
if (!error) {
|
|
217
|
+
return { supabase, userId: creds.user_id };
|
|
218
|
+
}
|
|
219
|
+
// If setSession failed despite token appearing valid (e.g., revoked server-side),
|
|
220
|
+
// fall through to refresh attempt below.
|
|
221
|
+
}
|
|
222
|
+
// Access token expired (or missing expires_at) — explicitly refresh
|
|
223
|
+
const { data, error } = await supabase.auth.refreshSession({
|
|
224
|
+
refresh_token: creds.refresh_token,
|
|
225
|
+
});
|
|
226
|
+
if (error || !data.session) {
|
|
227
|
+
throw new Error(`Session expired or invalid. Run \`ophan login\` again.\n ${error?.message || "No session returned"}`);
|
|
228
|
+
}
|
|
229
|
+
// Persist the new tokens so next invocation can use the fresh access_token
|
|
230
|
+
// without consuming the refresh token again
|
|
231
|
+
const updated = {
|
|
232
|
+
...creds,
|
|
233
|
+
access_token: data.session.access_token,
|
|
234
|
+
refresh_token: data.session.refresh_token,
|
|
235
|
+
expires_at: data.session.expires_at || 0,
|
|
236
|
+
};
|
|
237
|
+
saveCredentials(updated);
|
|
238
|
+
return { supabase, userId: creds.user_id };
|
|
239
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// @ophan/cli — Command-line orchestration layer
|
|
4
|
+
//
|
|
5
|
+
// Architecture:
|
|
6
|
+
// The CLI is a thin orchestration layer over @ophan/core. It owns the user-facing
|
|
7
|
+
// commands (analyze, gc, sync) and progress reporting, but delegates all analysis,
|
|
8
|
+
// parsing, hashing, and DB operations to the core package.
|
|
9
|
+
//
|
|
10
|
+
// The CLI is IDE-agnostic — it works the same whether the user has VS Code, JetBrains,
|
|
11
|
+
// or no IDE at all. It creates .ophan/index.db which any IDE extension can read.
|
|
12
|
+
//
|
|
13
|
+
// Language support is driven by core's extractFunctions() — when core gains Python/Go/Java
|
|
14
|
+
// parsers, the CLI automatically supports those languages with no changes needed here.
|
|
15
|
+
// The file glob pattern and language detection live in core, not in the CLI.
|
|
16
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
19
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
20
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(o, k2, desc);
|
|
23
|
+
}) : (function(o, m, k, k2) {
|
|
24
|
+
if (k2 === undefined) k2 = k;
|
|
25
|
+
o[k2] = m[k];
|
|
26
|
+
}));
|
|
27
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
28
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
29
|
+
}) : function(o, v) {
|
|
30
|
+
o["default"] = v;
|
|
31
|
+
});
|
|
32
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
33
|
+
var ownKeys = function(o) {
|
|
34
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
35
|
+
var ar = [];
|
|
36
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
37
|
+
return ar;
|
|
38
|
+
};
|
|
39
|
+
return ownKeys(o);
|
|
40
|
+
};
|
|
41
|
+
return function (mod) {
|
|
42
|
+
if (mod && mod.__esModule) return mod;
|
|
43
|
+
var result = {};
|
|
44
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
45
|
+
__setModuleDefault(result, mod);
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
49
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
require("dotenv/config");
|
|
51
|
+
const commander_1 = require("commander");
|
|
52
|
+
const core_1 = require("@ophan/core");
|
|
53
|
+
const path = __importStar(require("path"));
|
|
54
|
+
const auth_1 = require("./auth");
|
|
55
|
+
const sync_1 = require("./sync");
|
|
56
|
+
const watch_1 = require("./watch");
|
|
57
|
+
const program = new commander_1.Command();
|
|
58
|
+
program
|
|
59
|
+
.name("ophan")
|
|
60
|
+
.description("AI-powered code security analysis")
|
|
61
|
+
.version("0.0.1");
|
|
62
|
+
/**
|
|
63
|
+
* Try to create a pull function for cloud sync.
|
|
64
|
+
* Returns undefined if user isn't logged in or repo doesn't exist in Supabase.
|
|
65
|
+
* Silent — never fails the analyze command.
|
|
66
|
+
*/
|
|
67
|
+
async function createPullFn(rootPath) {
|
|
68
|
+
const creds = (0, auth_1.readCredentials)();
|
|
69
|
+
if (!creds)
|
|
70
|
+
return undefined;
|
|
71
|
+
try {
|
|
72
|
+
const { supabase, userId } = await (0, auth_1.getAuthenticatedClient)();
|
|
73
|
+
const repoName = path.basename(rootPath);
|
|
74
|
+
const { data: repo } = await supabase
|
|
75
|
+
.from("repos")
|
|
76
|
+
.select("id")
|
|
77
|
+
.eq("user_id", userId)
|
|
78
|
+
.eq("name", repoName)
|
|
79
|
+
.single();
|
|
80
|
+
if (!repo)
|
|
81
|
+
return undefined;
|
|
82
|
+
return async (hashes) => {
|
|
83
|
+
await (0, sync_1.pullFromSupabase)(rootPath, supabase, userId, repo.id, hashes, (step) => console.log(` ${step}`));
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function runAnalyze(rootPath) {
|
|
91
|
+
console.log("🔮 Ophan analyzing...\n");
|
|
92
|
+
const pullFn = await createPullFn(rootPath);
|
|
93
|
+
const result = await (0, core_1.analyzeRepository)(rootPath, (current, total, file) => {
|
|
94
|
+
if (process.stdout.isTTY) {
|
|
95
|
+
process.stdout.clearLine(0);
|
|
96
|
+
process.stdout.cursorTo(0);
|
|
97
|
+
}
|
|
98
|
+
process.stdout.write(` [${current}/${total}] ${file}\n`);
|
|
99
|
+
}, pullFn);
|
|
100
|
+
console.log(`\n✅ Done! ${result.analyzed} analyzed, ${result.skipped} cached` +
|
|
101
|
+
(result.pulled ? ` (${result.pulled} from cloud)` : "") +
|
|
102
|
+
` across ${result.files} files` +
|
|
103
|
+
(result.skippedSize ? ` (${result.skippedSize} files skipped — too large)` : ""));
|
|
104
|
+
console.log(` Database: .ophan/index.db`);
|
|
105
|
+
}
|
|
106
|
+
program
|
|
107
|
+
.command("analyze")
|
|
108
|
+
.description("Analyze repository for security issues and documentation")
|
|
109
|
+
.option("-p, --path <path>", "Path to repository", process.cwd())
|
|
110
|
+
.action(async (options) => {
|
|
111
|
+
await runAnalyze(path.resolve(options.path));
|
|
112
|
+
});
|
|
113
|
+
program
|
|
114
|
+
.command("gc")
|
|
115
|
+
.description("Remove orphaned analysis entries (manual, safe for branch switching)")
|
|
116
|
+
.option("-p, --path <path>", "Path to repository", process.cwd())
|
|
117
|
+
.option("-d, --days <days>", "Grace period in days", "30")
|
|
118
|
+
.action(async (options) => {
|
|
119
|
+
const rootPath = path.resolve(options.path);
|
|
120
|
+
const maxAgeDays = parseInt(options.days, 10);
|
|
121
|
+
const dbPath = path.join(rootPath, ".ophan", "index.db");
|
|
122
|
+
console.log("🧹 Refreshing file index...\n");
|
|
123
|
+
await (0, core_1.refreshFileIndex)(rootPath, (current, total, file) => {
|
|
124
|
+
if (process.stdout.isTTY) {
|
|
125
|
+
process.stdout.clearLine(0);
|
|
126
|
+
process.stdout.cursorTo(0);
|
|
127
|
+
}
|
|
128
|
+
process.stdout.write(` [${current}/${total}] ${file}\n`);
|
|
129
|
+
});
|
|
130
|
+
console.log("\n\n🗑️ Cleaning orphaned entries...");
|
|
131
|
+
const result = (0, core_1.gcAnalysis)(dbPath, maxAgeDays);
|
|
132
|
+
console.log(`\n✅ Removed ${result.deleted} orphaned entries (grace period: ${maxAgeDays} days)`);
|
|
133
|
+
});
|
|
134
|
+
// ========== AUTH ==========
|
|
135
|
+
const DEFAULT_WEBAPP_URL = "https://app.ophan.dev";
|
|
136
|
+
program
|
|
137
|
+
.command("login")
|
|
138
|
+
.description("Sign in to ophan.dev via browser")
|
|
139
|
+
.option("--webapp-url <url>", "Webapp URL (default: https://app.ophan.dev)", process.env.OPHAN_WEBAPP_URL || DEFAULT_WEBAPP_URL)
|
|
140
|
+
.action(async (options) => {
|
|
141
|
+
const existing = (0, auth_1.readCredentials)();
|
|
142
|
+
if (existing) {
|
|
143
|
+
// Validate the stored session is still good
|
|
144
|
+
try {
|
|
145
|
+
await (0, auth_1.getAuthenticatedClient)();
|
|
146
|
+
console.log(` Already logged in as ${existing.email}`);
|
|
147
|
+
console.log(` Run \`ophan logout\` first to switch accounts.`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Session expired/invalid — delete stale credentials and re-login
|
|
152
|
+
console.log(" Previous session expired. Re-authenticating...");
|
|
153
|
+
(0, auth_1.deleteCredentials)();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const creds = await (0, auth_1.login)(options.webappUrl);
|
|
158
|
+
console.log(`\n✅ Logged in as ${creds.email}`);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
console.error(`\n❌ Login failed: ${err.message}`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
program
|
|
166
|
+
.command("logout")
|
|
167
|
+
.description("Sign out and remove stored credentials")
|
|
168
|
+
.action(() => {
|
|
169
|
+
(0, auth_1.deleteCredentials)();
|
|
170
|
+
console.log("✅ Logged out. Credentials removed.");
|
|
171
|
+
});
|
|
172
|
+
// ========== SYNC ==========
|
|
173
|
+
program
|
|
174
|
+
.command("sync")
|
|
175
|
+
.description("Sync analysis to ophan.dev")
|
|
176
|
+
.option("-p, --path <path>", "Path to repository", process.cwd())
|
|
177
|
+
.action(async (options) => {
|
|
178
|
+
const rootPath = path.resolve(options.path);
|
|
179
|
+
try {
|
|
180
|
+
const { supabase, userId } = await (0, auth_1.getAuthenticatedClient)();
|
|
181
|
+
console.log("☁️ Syncing to ophan.dev...\n");
|
|
182
|
+
const result = await (0, sync_1.syncToSupabase)(rootPath, supabase, userId, (step) => {
|
|
183
|
+
console.log(` ${step}`);
|
|
184
|
+
});
|
|
185
|
+
console.log(`\n✅ Sync complete: ${result.pushed} analysis entries pushed, ` +
|
|
186
|
+
`${result.locations} file locations synced` +
|
|
187
|
+
(result.gcProcessed > 0
|
|
188
|
+
? `, ${result.gcProcessed} GC deletions applied`
|
|
189
|
+
: ""));
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
console.error(`\n❌ Sync failed: ${err.message}`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
// ========== WATCH ==========
|
|
197
|
+
program
|
|
198
|
+
.command("watch")
|
|
199
|
+
.description("Watch for file changes and auto-analyze")
|
|
200
|
+
.option("-p, --path <path>", "Path to repository", process.cwd())
|
|
201
|
+
.option("--json", "Output structured JSON events (for IDE integration)")
|
|
202
|
+
.option("--sync", "Auto-sync to cloud after each analysis batch")
|
|
203
|
+
.action(async (options) => {
|
|
204
|
+
const rootPath = path.resolve(options.path);
|
|
205
|
+
const pullFn = await createPullFn(rootPath);
|
|
206
|
+
let syncFn;
|
|
207
|
+
if (options.sync) {
|
|
208
|
+
const creds = (0, auth_1.readCredentials)();
|
|
209
|
+
if (creds) {
|
|
210
|
+
try {
|
|
211
|
+
const { supabase, userId } = await (0, auth_1.getAuthenticatedClient)();
|
|
212
|
+
syncFn = () => (0, sync_1.syncToSupabase)(rootPath, supabase, userId);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
if (options.json) {
|
|
216
|
+
process.stdout.write(JSON.stringify({ event: "sync_warning", message: "Session expired — sync disabled. Run ophan login." }) + "\n");
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
console.warn(" ⚠️ Session expired — sync disabled. Run `ophan login` to re-authenticate.");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else if (options.json) {
|
|
224
|
+
process.stdout.write(JSON.stringify({ event: "sync_warning", message: "Not logged in — sync disabled. Run ophan login." }) + "\n");
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
console.warn(" ⚠️ Not logged in — sync disabled. Run `ophan login` to enable cloud sync.");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
await (0, watch_1.startWatch)({
|
|
231
|
+
rootPath,
|
|
232
|
+
pullFn,
|
|
233
|
+
syncFn,
|
|
234
|
+
json: options.json,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
// Keep "init" as alias for "analyze" for backwards compat
|
|
238
|
+
program
|
|
239
|
+
.command("init")
|
|
240
|
+
.description("Alias for analyze")
|
|
241
|
+
.option("-p, --path <path>", "Path to repository", process.cwd())
|
|
242
|
+
.action(async (options) => {
|
|
243
|
+
await runAnalyze(path.resolve(options.path));
|
|
244
|
+
});
|
|
245
|
+
program.parse();
|