@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
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.createTempDb = createTempDb;
|
|
40
|
+
exports.createMockSupabase = createMockSupabase;
|
|
41
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const os = __importStar(require("os"));
|
|
45
|
+
const test_utils_1 = require("@ophan/core/test-utils");
|
|
46
|
+
/**
|
|
47
|
+
* Creates a temp directory with a real .ophan/index.db file.
|
|
48
|
+
* Sync functions take rootPath and construct dbPath internally,
|
|
49
|
+
* so we need an actual file on disk.
|
|
50
|
+
*/
|
|
51
|
+
function createTempDb(setup) {
|
|
52
|
+
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ophan-test-"));
|
|
53
|
+
const ophanDir = path.join(rootPath, ".ophan");
|
|
54
|
+
fs.mkdirSync(ophanDir, { recursive: true });
|
|
55
|
+
const dbPath = path.join(ophanDir, "index.db");
|
|
56
|
+
// Create a real file-based DB with the current schema
|
|
57
|
+
const memDb = (0, test_utils_1.createTestDb)();
|
|
58
|
+
// Serialize the in-memory DB to a file
|
|
59
|
+
const db = new better_sqlite3_1.default(dbPath);
|
|
60
|
+
db.exec(memDb.prepare("SELECT sql FROM sqlite_master WHERE type='table'")
|
|
61
|
+
.all()
|
|
62
|
+
.map((row) => row.sql + ";")
|
|
63
|
+
.join("\n"));
|
|
64
|
+
// Run setup callback if provided
|
|
65
|
+
if (setup)
|
|
66
|
+
setup(db);
|
|
67
|
+
db.close();
|
|
68
|
+
memDb.close();
|
|
69
|
+
return {
|
|
70
|
+
dbPath,
|
|
71
|
+
rootPath,
|
|
72
|
+
cleanup: () => {
|
|
73
|
+
try {
|
|
74
|
+
fs.rmSync(rootPath, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// best-effort cleanup
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Creates a mock Supabase client that records calls and returns controlled responses.
|
|
84
|
+
*
|
|
85
|
+
* Usage:
|
|
86
|
+
* const mock = createMockSupabase({
|
|
87
|
+
* 'repos.upsert': { data: [{ id: 'repo-1' }], error: null },
|
|
88
|
+
* 'function_analysis.select': { data: [], error: null },
|
|
89
|
+
* })
|
|
90
|
+
* await syncToSupabase(rootPath, mock.client, userId)
|
|
91
|
+
* expect(mock.calls).toContainEqual(expect.objectContaining({ table: 'repos', method: 'upsert' }))
|
|
92
|
+
*/
|
|
93
|
+
function createMockSupabase(responses = {}) {
|
|
94
|
+
const calls = [];
|
|
95
|
+
function createChain(table) {
|
|
96
|
+
let primaryMethod = "";
|
|
97
|
+
const resolve = () => {
|
|
98
|
+
const key = `${table}.${primaryMethod}`;
|
|
99
|
+
return responses[key] ?? { data: [], error: null };
|
|
100
|
+
};
|
|
101
|
+
const chain = {};
|
|
102
|
+
const handler = {
|
|
103
|
+
get(_, prop) {
|
|
104
|
+
// Primary operations
|
|
105
|
+
if (["select", "insert", "upsert", "update", "delete"].includes(prop)) {
|
|
106
|
+
return (...args) => {
|
|
107
|
+
primaryMethod = prop;
|
|
108
|
+
calls.push({ table, method: prop, args });
|
|
109
|
+
return new Proxy(chain, handler);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// Filter/modifier methods — chainable
|
|
113
|
+
if ([
|
|
114
|
+
"eq",
|
|
115
|
+
"in",
|
|
116
|
+
"neq",
|
|
117
|
+
"gt",
|
|
118
|
+
"lt",
|
|
119
|
+
"gte",
|
|
120
|
+
"lte",
|
|
121
|
+
"like",
|
|
122
|
+
"ilike",
|
|
123
|
+
"contains",
|
|
124
|
+
"order",
|
|
125
|
+
"limit",
|
|
126
|
+
"range",
|
|
127
|
+
"or",
|
|
128
|
+
"not",
|
|
129
|
+
"is",
|
|
130
|
+
"head",
|
|
131
|
+
].includes(prop)) {
|
|
132
|
+
return (...args) => {
|
|
133
|
+
calls.push({ table, method: prop, args });
|
|
134
|
+
return new Proxy(chain, handler);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// Terminal methods
|
|
138
|
+
if (prop === "single" || prop === "maybeSingle") {
|
|
139
|
+
return (...args) => {
|
|
140
|
+
calls.push({ table, method: prop, args });
|
|
141
|
+
return Promise.resolve(resolve());
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// Thenable — allows `await supabase.from(...).select(...).eq(...)`
|
|
145
|
+
if (prop === "then") {
|
|
146
|
+
const result = resolve();
|
|
147
|
+
return Promise.resolve(result).then.bind(Promise.resolve(result));
|
|
148
|
+
}
|
|
149
|
+
return new Proxy(chain, handler);
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
return new Proxy(chain, handler);
|
|
153
|
+
}
|
|
154
|
+
const client = {
|
|
155
|
+
from: (table) => {
|
|
156
|
+
calls.push({ table, method: "from", args: [table] });
|
|
157
|
+
return createChain(table);
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
return { client: client, calls };
|
|
161
|
+
}
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
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.startWatch = startWatch;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const core_1 = require("@ophan/core");
|
|
40
|
+
function emit(event) {
|
|
41
|
+
process.stdout.write(JSON.stringify(event) + "\n");
|
|
42
|
+
}
|
|
43
|
+
async function startWatch(options) {
|
|
44
|
+
const { rootPath, pullFn, syncFn, json } = options;
|
|
45
|
+
const dbPath = path.join(rootPath, ".ophan", "index.db");
|
|
46
|
+
const lockPath = path.join(rootPath, ".ophan", "watch.lock");
|
|
47
|
+
// Check for existing watcher
|
|
48
|
+
if (fs.existsSync(lockPath)) {
|
|
49
|
+
try {
|
|
50
|
+
const pid = parseInt(fs.readFileSync(lockPath, "utf-8").trim());
|
|
51
|
+
process.kill(pid, 0); // Check if process is alive (signal 0 = no-op)
|
|
52
|
+
if (json)
|
|
53
|
+
emit({ event: "error", message: `Another watcher is running (PID ${pid})` });
|
|
54
|
+
else
|
|
55
|
+
console.error(`Another watcher is already running (PID ${pid}). Kill it or remove .ophan/watch.lock`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Process is dead — stale lock file, continue
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Write lock file
|
|
63
|
+
fs.mkdirSync(path.join(rootPath, ".ophan"), { recursive: true });
|
|
64
|
+
fs.writeFileSync(lockPath, String(process.pid));
|
|
65
|
+
const removeLock = () => {
|
|
66
|
+
try {
|
|
67
|
+
fs.unlinkSync(lockPath);
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
};
|
|
71
|
+
process.on("exit", removeLock);
|
|
72
|
+
// Phase 1: Initial full scan (reuses analyzeRepository which opens/closes its own DB)
|
|
73
|
+
if (!json)
|
|
74
|
+
console.log("🔮 Ophan watching...\n");
|
|
75
|
+
if (!json)
|
|
76
|
+
console.log(" Running initial scan...");
|
|
77
|
+
const scanResult = await (0, core_1.analyzeRepository)(rootPath, (current, total, file) => {
|
|
78
|
+
if (!json && process.stdout.isTTY) {
|
|
79
|
+
process.stdout.clearLine(0);
|
|
80
|
+
process.stdout.cursorTo(0);
|
|
81
|
+
process.stdout.write(` [${current}/${total}] ${file}`);
|
|
82
|
+
}
|
|
83
|
+
}, pullFn);
|
|
84
|
+
if (json) {
|
|
85
|
+
emit({
|
|
86
|
+
event: "scan_complete",
|
|
87
|
+
files: scanResult.files,
|
|
88
|
+
analyzed: scanResult.analyzed,
|
|
89
|
+
cached: scanResult.skipped,
|
|
90
|
+
pulled: scanResult.pulled,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
console.log(`\n Initial scan: ${scanResult.analyzed} analyzed, ${scanResult.skipped} cached` +
|
|
95
|
+
(scanResult.pulled ? ` (${scanResult.pulled} from cloud)` : "") +
|
|
96
|
+
` across ${scanResult.files} files`);
|
|
97
|
+
}
|
|
98
|
+
// Sync after initial scan if anything was analyzed
|
|
99
|
+
if (syncFn && scanResult.analyzed > 0) {
|
|
100
|
+
await runSync(syncFn, json);
|
|
101
|
+
}
|
|
102
|
+
// Phase 2: Open DB for incremental watching
|
|
103
|
+
const db = (0, core_1.initDb)(dbPath);
|
|
104
|
+
const extSet = new Set((0, core_1.getSupportedExtensions)().map((e) => e.toLowerCase()));
|
|
105
|
+
const DEBOUNCE_MS = 5000;
|
|
106
|
+
const SYNC_DEBOUNCE_MS = 10000;
|
|
107
|
+
// Per-file debounce timers
|
|
108
|
+
const timers = new Map();
|
|
109
|
+
// Sync debounce — batch rapid analyses into one sync
|
|
110
|
+
let syncTimer = null;
|
|
111
|
+
let pendingSync = false;
|
|
112
|
+
function scheduleSyncAfterAnalysis() {
|
|
113
|
+
if (!syncFn)
|
|
114
|
+
return;
|
|
115
|
+
pendingSync = true;
|
|
116
|
+
if (syncTimer)
|
|
117
|
+
clearTimeout(syncTimer);
|
|
118
|
+
syncTimer = setTimeout(async () => {
|
|
119
|
+
syncTimer = null;
|
|
120
|
+
if (pendingSync) {
|
|
121
|
+
pendingSync = false;
|
|
122
|
+
await runSync(syncFn, json);
|
|
123
|
+
}
|
|
124
|
+
}, SYNC_DEBOUNCE_MS);
|
|
125
|
+
}
|
|
126
|
+
// FIFO analysis queue
|
|
127
|
+
let analyzing = false;
|
|
128
|
+
const queue = [];
|
|
129
|
+
async function processQueue() {
|
|
130
|
+
if (analyzing || queue.length === 0)
|
|
131
|
+
return;
|
|
132
|
+
analyzing = true;
|
|
133
|
+
while (queue.length > 0) {
|
|
134
|
+
const file = queue.shift();
|
|
135
|
+
const relPath = path.relative(rootPath, file);
|
|
136
|
+
if (json)
|
|
137
|
+
emit({ event: "analyzing", file: relPath });
|
|
138
|
+
else
|
|
139
|
+
console.log(` Analyzing ${relPath}...`);
|
|
140
|
+
const start = Date.now();
|
|
141
|
+
try {
|
|
142
|
+
const result = await (0, core_1.analyzeFiles)(db, rootPath, [file], { pullFn });
|
|
143
|
+
const duration = Date.now() - start;
|
|
144
|
+
if (json) {
|
|
145
|
+
emit({
|
|
146
|
+
event: "analyzed",
|
|
147
|
+
file: relPath,
|
|
148
|
+
analyzed: result.analyzed,
|
|
149
|
+
cached: result.skipped,
|
|
150
|
+
pulled: result.pulled,
|
|
151
|
+
duration_ms: duration,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
else if (result.analyzed > 0 || result.skipped > 0) {
|
|
155
|
+
console.log(` ✅ ${relPath}: ${result.analyzed} analyzed, ${result.skipped} cached (${duration}ms)`);
|
|
156
|
+
}
|
|
157
|
+
// Schedule sync after analysis
|
|
158
|
+
if (result.analyzed > 0) {
|
|
159
|
+
scheduleSyncAfterAnalysis();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
if (json) {
|
|
164
|
+
emit({ event: "error", file: relPath, message: err.message });
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
console.error(` ❌ ${relPath}: ${err.message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
analyzing = false;
|
|
172
|
+
}
|
|
173
|
+
const IGNORE_SEGMENTS = ["node_modules", ".ophan", "__pycache__", ".venv", "venv", ".tox", ".eggs", "dist"];
|
|
174
|
+
function onFileChange(filename) {
|
|
175
|
+
const ext = path.extname(filename).toLowerCase();
|
|
176
|
+
if (!extSet.has(ext))
|
|
177
|
+
return;
|
|
178
|
+
const absPath = path.isAbsolute(filename)
|
|
179
|
+
? filename
|
|
180
|
+
: path.resolve(rootPath, filename);
|
|
181
|
+
// Skip ignored directories
|
|
182
|
+
if (IGNORE_SEGMENTS.some((seg) => absPath.includes(`/${seg}/`) || absPath.includes(`\\${seg}\\`)))
|
|
183
|
+
return;
|
|
184
|
+
// Skip if file was deleted
|
|
185
|
+
if (!fs.existsSync(absPath))
|
|
186
|
+
return;
|
|
187
|
+
// Reset per-file debounce
|
|
188
|
+
const existing = timers.get(absPath);
|
|
189
|
+
if (existing)
|
|
190
|
+
clearTimeout(existing);
|
|
191
|
+
timers.set(absPath, setTimeout(() => {
|
|
192
|
+
timers.delete(absPath);
|
|
193
|
+
if (!queue.includes(absPath)) {
|
|
194
|
+
queue.push(absPath);
|
|
195
|
+
}
|
|
196
|
+
processQueue();
|
|
197
|
+
}, DEBOUNCE_MS));
|
|
198
|
+
}
|
|
199
|
+
// Start recursive file watcher (macOS + Windows native, Linux may need chokidar)
|
|
200
|
+
const watcher = fs.watch(rootPath, { recursive: true }, (_eventType, filename) => {
|
|
201
|
+
if (!filename)
|
|
202
|
+
return;
|
|
203
|
+
onFileChange(filename);
|
|
204
|
+
});
|
|
205
|
+
watcher.on("error", (err) => {
|
|
206
|
+
if (json)
|
|
207
|
+
emit({ event: "error", message: `Watcher error: ${err.message}` });
|
|
208
|
+
else
|
|
209
|
+
console.error(` Watcher error: ${err.message}`);
|
|
210
|
+
});
|
|
211
|
+
if (!json)
|
|
212
|
+
console.log("\n Watching for changes... (Ctrl+C to stop)\n");
|
|
213
|
+
// Graceful shutdown
|
|
214
|
+
function shutdown() {
|
|
215
|
+
if (!json)
|
|
216
|
+
console.log("\n Shutting down...");
|
|
217
|
+
watcher.close();
|
|
218
|
+
for (const timer of timers.values())
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
timers.clear();
|
|
221
|
+
if (syncTimer)
|
|
222
|
+
clearTimeout(syncTimer);
|
|
223
|
+
db.close();
|
|
224
|
+
process.exit(0);
|
|
225
|
+
}
|
|
226
|
+
process.on("SIGINT", shutdown);
|
|
227
|
+
process.on("SIGTERM", shutdown);
|
|
228
|
+
}
|
|
229
|
+
async function runSync(syncFn, json) {
|
|
230
|
+
try {
|
|
231
|
+
const result = await syncFn();
|
|
232
|
+
if (json) {
|
|
233
|
+
emit({ event: "synced", pushed: result.pushed, locations: result.locations });
|
|
234
|
+
}
|
|
235
|
+
else if (result.pushed > 0) {
|
|
236
|
+
console.log(` ☁️ Synced: ${result.pushed} entries pushed`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
if (json) {
|
|
241
|
+
emit({ event: "sync_error", message: err.message });
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
console.error(` ☁️ Sync failed: ${err.message}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ophan/cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"files": ["dist"],
|
|
5
|
+
"bin": {
|
|
6
|
+
"ophan": "./dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"dev": "tsx src/index.ts",
|
|
11
|
+
"test": "vitest run"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@ophan/core": "workspace:*",
|
|
15
|
+
"@supabase/supabase-js": "^2.49.4",
|
|
16
|
+
"better-sqlite3": "^11.9.1",
|
|
17
|
+
"commander": "^14.0.2",
|
|
18
|
+
"dotenv": "^16.4.7",
|
|
19
|
+
"open": "^10.1.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
23
|
+
"tsx": "^4.21.0",
|
|
24
|
+
"typescript": "^5.9.3",
|
|
25
|
+
"vitest": "^3.0.5"
|
|
26
|
+
}
|
|
27
|
+
}
|