@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 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();