@robot-resources/cli-core 0.1.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/auth.mjs ADDED
@@ -0,0 +1,255 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+
4
+ const SUPABASE_URL = 'https://tbnliojrqmcagojtvqpe.supabase.co';
5
+ const SUPABASE_ANON_KEY =
6
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRibmxpb2pycW1jYWdvanR2cXBlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMyNjIxNzAsImV4cCI6MjA4ODgzODE3MH0.GKlpbVFgBbcV0OwxFZuOb-LfqtOu95ZiR33KNOONPI0';
7
+
8
+ const PREFERRED_PORT = 54321;
9
+
10
+ /**
11
+ * Generate PKCE code verifier + challenge
12
+ */
13
+ function generatePKCE() {
14
+ const verifier = randomBytes(32)
15
+ .toString('base64url')
16
+ .slice(0, 64);
17
+ const challenge = createHash('sha256')
18
+ .update(verifier)
19
+ .digest('base64url');
20
+ return { verifier, challenge };
21
+ }
22
+
23
+ /**
24
+ * Build the Supabase OAuth URL for GitHub login
25
+ */
26
+ export function buildAuthUrl(codeChallenge, callbackUrl) {
27
+ const params = new URLSearchParams({
28
+ provider: 'github',
29
+ redirect_to: callbackUrl,
30
+ flow_type: 'pkce',
31
+ code_challenge: codeChallenge,
32
+ code_challenge_method: 'S256',
33
+ });
34
+ return `${SUPABASE_URL}/auth/v1/authorize?${params}`;
35
+ }
36
+
37
+ const MAX_BODY = 8192;
38
+
39
+ /**
40
+ * Create callback server. Returns { server, resultPromise, nonce }.
41
+ * resultPromise resolves with { type: 'code', code } or { type: 'token', access_token }.
42
+ * The nonce protects /receive-token from cross-origin requests.
43
+ */
44
+ function createCallbackServer() {
45
+ let resolveResult, rejectResult;
46
+ const resultPromise = new Promise((resolve, reject) => {
47
+ resolveResult = resolve;
48
+ rejectResult = reject;
49
+ });
50
+
51
+ const nonce = randomBytes(16).toString('hex');
52
+ const tokenPath = `/receive-token/${nonce}`;
53
+
54
+ const timeout = setTimeout(() => {
55
+ server.close();
56
+ rejectResult(new Error('Login timed out after 120 seconds'));
57
+ }, 120_000);
58
+
59
+ const server = createServer((req, res) => {
60
+ const port = server.address()?.port ?? PREFERRED_PORT;
61
+ const url = new URL(req.url, `http://localhost:${port}`);
62
+
63
+ // Handle token/debug info posted from browser (nonce-protected)
64
+ if (url.pathname === tokenPath && req.method === 'POST') {
65
+ let body = '';
66
+ req.on('data', (chunk) => {
67
+ body += chunk;
68
+ if (body.length > MAX_BODY) {
69
+ req.destroy();
70
+ clearTimeout(timeout);
71
+ server.close();
72
+ rejectResult(new Error('Request body too large'));
73
+ }
74
+ });
75
+ req.on('end', () => {
76
+ res.writeHead(200);
77
+ res.end('ok');
78
+ clearTimeout(timeout);
79
+ server.close();
80
+
81
+ if (body.startsWith('NO_FRAGMENT:')) {
82
+ rejectResult(new Error('No tokens received from browser redirect'));
83
+ } else {
84
+ const params = new URLSearchParams(body);
85
+ const accessToken = params.get('access_token');
86
+ if (accessToken) {
87
+ resolveResult({ type: 'token', access_token: accessToken });
88
+ } else {
89
+ rejectResult(new Error('Unexpected callback data'));
90
+ }
91
+ }
92
+ });
93
+ return;
94
+ }
95
+
96
+ if (url.pathname === '/callback') {
97
+ const code = url.searchParams.get('code');
98
+
99
+ if (code) {
100
+ res.writeHead(200, { 'Content-Type': 'text/html' });
101
+ res.end(`
102
+ <html>
103
+ <body style="background:#0a0a0a;color:#ff6600;font-family:monospace;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
104
+ <div style="text-align:center">
105
+ <h1>&#9632; Robot Resources</h1>
106
+ <p style="color:#00ff41">Login successful. You can close this tab.</p>
107
+ </div>
108
+ </body>
109
+ </html>
110
+ `);
111
+ clearTimeout(timeout);
112
+ server.close();
113
+ resolveResult({ type: 'code', code });
114
+ } else {
115
+ // No code in query params — serve page that captures the full URL
116
+ // (fragment tokens, errors, etc.) and sends it back via nonce-protected endpoint
117
+ res.writeHead(200, { 'Content-Type': 'text/html' });
118
+ res.end(`
119
+ <html>
120
+ <body style="background:#0a0a0a;color:#ff6600;font-family:monospace;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
121
+ <div style="text-align:center">
122
+ <h1>&#9632; Robot Resources</h1>
123
+ <p id="msg">Completing login...</p>
124
+ </div>
125
+ </body>
126
+ <script>
127
+ const fullUrl = window.location.href;
128
+ const hash = window.location.hash.substring(1);
129
+ const payload = hash || 'NO_FRAGMENT:' + fullUrl;
130
+ fetch('${tokenPath}', { method: 'POST', body: payload })
131
+ .then(() => {
132
+ if (hash && hash.includes('access_token')) {
133
+ document.getElementById('msg').style.color = '#00ff41';
134
+ document.getElementById('msg').textContent = 'Login successful. You can close this tab.';
135
+ } else {
136
+ document.getElementById('msg').style.color = '#ffaa00';
137
+ document.getElementById('msg').textContent = 'Something went wrong. Check terminal.';
138
+ }
139
+ });
140
+ </script>
141
+ </html>
142
+ `);
143
+ }
144
+ return;
145
+ }
146
+
147
+ // Reject all other paths
148
+ res.writeHead(404);
149
+ res.end();
150
+ });
151
+
152
+ server.on('error', (err) => {
153
+ clearTimeout(timeout);
154
+ rejectResult(new Error(`Could not start callback server: ${err.message}`));
155
+ });
156
+
157
+ return { server, resultPromise, nonce };
158
+ }
159
+
160
+ /**
161
+ * Exchange the auth code + PKCE verifier for a Supabase session.
162
+ */
163
+ async function exchangeCodeForSession(code, codeVerifier) {
164
+ const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=pkce`, {
165
+ method: 'POST',
166
+ headers: {
167
+ 'Content-Type': 'application/json',
168
+ apikey: SUPABASE_ANON_KEY,
169
+ },
170
+ body: JSON.stringify({
171
+ auth_code: code,
172
+ code_verifier: codeVerifier,
173
+ }),
174
+ });
175
+
176
+ if (!res.ok) {
177
+ const body = await res.text();
178
+ throw new Error(`Token exchange failed (${res.status}): ${body}`);
179
+ }
180
+
181
+ return res.json();
182
+ }
183
+
184
+ /**
185
+ * Try to listen on the preferred port, fall back to OS-assigned port.
186
+ */
187
+ function listenWithFallback(server) {
188
+ return new Promise((resolve, reject) => {
189
+ server.once('error', (err) => {
190
+ if (err.code === 'EADDRINUSE') {
191
+ // Preferred port busy — let OS assign one
192
+ server.listen(0, '127.0.0.1', () => resolve(server.address().port));
193
+ } else {
194
+ reject(err);
195
+ }
196
+ });
197
+ server.listen(PREFERRED_PORT, '127.0.0.1', () => resolve(server.address().port));
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Full OAuth flow with PKCE + implicit fallback.
203
+ * Returns { access_token, refresh_token, user }.
204
+ */
205
+ export async function authenticate() {
206
+ const { verifier, challenge } = generatePKCE();
207
+
208
+ // Create server and wait for it to be listening
209
+ const { server, resultPromise } = createCallbackServer();
210
+
211
+ const port = await listenWithFallback(server);
212
+ const callbackUrl = `http://localhost:${port}/callback`;
213
+ const authUrl = buildAuthUrl(challenge, callbackUrl);
214
+
215
+ console.log(`\n Auth URL: ${authUrl}\n`);
216
+
217
+ // Open browser
218
+ const { exec } = await import('node:child_process');
219
+ const openCmd =
220
+ process.platform === 'darwin' ? 'open' :
221
+ process.platform === 'win32' ? 'start' :
222
+ 'xdg-open';
223
+
224
+ exec(`${openCmd} "${authUrl}"`);
225
+
226
+ console.log(' Waiting for GitHub authorization...\n');
227
+
228
+ // Wait for the callback
229
+ const result = await resultPromise;
230
+
231
+ if (result.type === 'code') {
232
+ // PKCE flow — exchange code for session
233
+ const session = await exchangeCodeForSession(result.code, verifier);
234
+ return {
235
+ access_token: session.access_token,
236
+ refresh_token: session.refresh_token,
237
+ user: session.user,
238
+ };
239
+ } else {
240
+ // Implicit flow fallback — we have the access_token directly
241
+ const res = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
242
+ headers: {
243
+ apikey: SUPABASE_ANON_KEY,
244
+ Authorization: `Bearer ${result.access_token}`,
245
+ },
246
+ });
247
+ if (!res.ok) throw new Error('Failed to fetch user info');
248
+ const user = await res.json();
249
+ return {
250
+ access_token: result.access_token,
251
+ refresh_token: null,
252
+ user,
253
+ };
254
+ }
255
+ }
package/config.mjs ADDED
@@ -0,0 +1,55 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ const CONFIG_DIR = join(homedir(), '.robot-resources');
6
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
+
8
+ export function getConfigPath() {
9
+ return CONFIG_FILE;
10
+ }
11
+
12
+ export function getConfigDir() {
13
+ return CONFIG_DIR;
14
+ }
15
+
16
+ export function readConfig() {
17
+ try {
18
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ export function writeConfig(data) {
25
+ mkdirSync(CONFIG_DIR, { recursive: true });
26
+ const existing = readConfig();
27
+ const merged = { ...existing, ...data };
28
+ writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
29
+ return merged;
30
+ }
31
+
32
+ export function readProviderKeys() {
33
+ const config = readConfig();
34
+ return config.provider_keys || {};
35
+ }
36
+
37
+ export function writeProviderKeys(keys) {
38
+ mkdirSync(CONFIG_DIR, { recursive: true });
39
+ const existing = readConfig();
40
+ const existingProviderKeys = existing.provider_keys || {};
41
+ const merged = {
42
+ ...existing,
43
+ provider_keys: { ...existingProviderKeys, ...keys },
44
+ };
45
+ writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
46
+ return merged;
47
+ }
48
+
49
+ export function clearConfig() {
50
+ try {
51
+ writeFileSync(CONFIG_FILE, '{}\n', { mode: 0o600 });
52
+ } catch {
53
+ // config file doesn't exist, that's fine
54
+ }
55
+ }
package/index.mjs ADDED
@@ -0,0 +1,14 @@
1
+ export { authenticate, buildAuthUrl } from './auth.mjs';
2
+ export { readConfig, writeConfig, clearConfig, getConfigPath, getConfigDir } from './config.mjs';
3
+ export { login, createApiKey } from './login.mjs';
4
+ export {
5
+ findPython,
6
+ ensureVenv,
7
+ installRouter,
8
+ isRouterInstalled,
9
+ getVenvPython,
10
+ getVenvPip,
11
+ getVenvPythonPath,
12
+ MANAGED_VENV_DIR,
13
+ ROUTER_PIP_PACKAGE,
14
+ } from './python-bridge.mjs';
package/login.mjs ADDED
@@ -0,0 +1,54 @@
1
+ import { authenticate } from './auth.mjs';
2
+ import { writeConfig, getConfigPath } from './config.mjs';
3
+
4
+ const PLATFORM_URL = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
5
+
6
+ /**
7
+ * Generate an API key via the platform API.
8
+ */
9
+ export async function createApiKey(accessToken) {
10
+ const res = await fetch(`${PLATFORM_URL}/v1/keys`, {
11
+ method: 'POST',
12
+ headers: {
13
+ 'Content-Type': 'application/json',
14
+ Authorization: `Bearer ${accessToken}`,
15
+ },
16
+ body: JSON.stringify({ name: `cli-${new Date().toISOString().slice(0, 10)}` }),
17
+ });
18
+
19
+ if (!res.ok) {
20
+ const body = await res.text();
21
+ throw new Error(`Failed to create API key (${res.status}): ${body}`);
22
+ }
23
+
24
+ const { data } = await res.json();
25
+ return data;
26
+ }
27
+
28
+ export async function login() {
29
+ console.log('\n ██ Robot Resources — Login\n');
30
+ console.log(' Opening GitHub in your browser...');
31
+
32
+ try {
33
+ const { access_token, user } = await authenticate();
34
+
35
+ console.log(` ✓ Authenticated as ${user.user_metadata?.user_name || user.email}`);
36
+ console.log(' Generating API key...');
37
+
38
+ const key = await createApiKey(access_token);
39
+
40
+ writeConfig({
41
+ api_key: key.key,
42
+ key_id: key.id,
43
+ key_name: key.name,
44
+ user_email: user.email,
45
+ user_name: user.user_metadata?.user_name || null,
46
+ });
47
+
48
+ console.log(` ✓ API key saved to ${getConfigPath()}`);
49
+ console.log(`\n You're all set. Router and Scraper will pick up the key automatically.\n`);
50
+ } catch (err) {
51
+ console.error(`\n ✗ Login failed: ${err.message}\n`);
52
+ process.exit(1);
53
+ }
54
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@robot-resources/cli-core",
3
+ "version": "0.1.0",
4
+ "description": "Shared CLI utilities — auth, config, login (internal)",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs",
9
+ "./auth.mjs": "./auth.mjs",
10
+ "./config.mjs": "./config.mjs",
11
+ "./login.mjs": "./login.mjs",
12
+ "./python-bridge.mjs": "./python-bridge.mjs"
13
+ },
14
+ "scripts": {
15
+ "test": "vitest",
16
+ "test:run": "vitest run"
17
+ },
18
+ "files": [
19
+ "*.mjs"
20
+ ],
21
+ "devDependencies": {
22
+ "vitest": "^1.2.0"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ }
30
+ }
@@ -0,0 +1,115 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const VENV_DIR = join(homedir(), '.robot-resources', '.venv');
7
+ const ROUTER_PACKAGE = 'robot-resources-router';
8
+
9
+ const PYTHON_CANDIDATES = [
10
+ 'python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3', 'python',
11
+ ];
12
+
13
+ /**
14
+ * Find a Python 3.10+ binary on the system.
15
+ * Returns { bin, version } or null if not found.
16
+ */
17
+ export function findPython() {
18
+ for (const bin of PYTHON_CANDIDATES) {
19
+ try {
20
+ const output = execSync(`${bin} --version 2>&1`, { encoding: 'utf-8' }).trim();
21
+ const match = output.match(/Python (\d+)\.(\d+)/);
22
+ if (match) {
23
+ const [, major, minor] = match.map(Number);
24
+ if (major === 3 && minor >= 10) {
25
+ return { bin, version: `${major}.${minor}` };
26
+ }
27
+ }
28
+ } catch {
29
+ // binary not found, try next
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+
35
+ /**
36
+ * Get the Python binary path inside the managed venv.
37
+ */
38
+ export function getVenvPython() {
39
+ return process.platform === 'win32'
40
+ ? join(VENV_DIR, 'Scripts', 'python.exe')
41
+ : join(VENV_DIR, 'bin', 'python3');
42
+ }
43
+
44
+ /**
45
+ * Get the pip binary path inside the managed venv.
46
+ */
47
+ export function getVenvPip() {
48
+ return process.platform === 'win32'
49
+ ? join(VENV_DIR, 'Scripts', 'pip.exe')
50
+ : join(VENV_DIR, 'bin', 'pip3');
51
+ }
52
+
53
+ /**
54
+ * Create a Python venv at ~/.robot-resources/.venv if it doesn't exist.
55
+ * Validates an existing venv and recreates if broken.
56
+ * Returns the path to the venv Python binary.
57
+ *
58
+ * @param {string} pythonBin — system Python binary to use for venv creation
59
+ */
60
+ export function ensureVenv(pythonBin) {
61
+ const venvPython = getVenvPython();
62
+
63
+ if (existsSync(venvPython)) {
64
+ try {
65
+ execSync(`"${venvPython}" --version`, { stdio: 'pipe' });
66
+ return venvPython;
67
+ } catch {
68
+ // Broken venv — recreate below
69
+ }
70
+ }
71
+
72
+ mkdirSync(join(homedir(), '.robot-resources'), { recursive: true });
73
+ execSync(`"${pythonBin}" -m venv "${VENV_DIR}"`, { stdio: 'pipe' });
74
+ return venvPython;
75
+ }
76
+
77
+ /**
78
+ * Install (or upgrade) the router Python package into the managed venv.
79
+ *
80
+ * @param {object} [options]
81
+ * @param {'pipe'|'inherit'} [options.stdio='pipe'] — controls install output visibility
82
+ * @param {number} [options.timeout=120000] — pip install timeout in ms
83
+ */
84
+ export function installRouter({ stdio = 'pipe', timeout = 120_000 } = {}) {
85
+ const pip = getVenvPip();
86
+ execSync(`"${pip}" install --upgrade "${ROUTER_PACKAGE}"`, { stdio, timeout });
87
+ }
88
+
89
+ /**
90
+ * Check if the router Python package is importable in the managed venv.
91
+ */
92
+ export function isRouterInstalled() {
93
+ const venvPython = getVenvPython();
94
+ if (!existsSync(venvPython)) return false;
95
+
96
+ try {
97
+ execSync(`"${venvPython}" -c "import robot_resources"`, { stdio: 'pipe' });
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Get the managed venv's Python path (convenience alias for service registration, etc.).
106
+ */
107
+ export function getVenvPythonPath() {
108
+ return getVenvPython();
109
+ }
110
+
111
+ /** Expose the venv directory path for advanced use cases. */
112
+ export const MANAGED_VENV_DIR = VENV_DIR;
113
+
114
+ /** Expose the router pip package name. */
115
+ export const ROUTER_PIP_PACKAGE = ROUTER_PACKAGE;
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['test/**/*.test.mjs'],
8
+ },
9
+ });