scava-ai 0.0.0-stage → 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/README.md +48 -2
- package/cli/account.js +507 -0
- package/cli/check.js +250 -0
- package/cli/lib.js +252 -0
- package/cli/scava.js +287 -0
- package/package.json +32 -4
package/README.md
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Scava
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Hosting, sign-in and a database for the apps your AI builds. One command puts the app online, on its own address.
|
|
4
|
+
People open a link and sign in with Google or a code by email: no password, nothing to install. Data stays in the EU.
|
|
5
|
+
|
|
6
|
+
## Start
|
|
7
|
+
|
|
8
|
+
In the folder of your app, or tell your AI to run it:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
npx -y scava-ai setup
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The first time a browser opens: sign in to Scava and choose Allow. After that it runs without a browser. Any AI with a
|
|
15
|
+
terminal can use it: Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, Antigravity and others. You need Node.js 22
|
|
16
|
+
or newer.
|
|
17
|
+
|
|
18
|
+
Claude and ChatGPT in the browser have no terminal: add a connector with the address https://scava.ai/mcp.
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
npx -y scava-ai setup [folder] put the app online; afterwards a test run in a browser here
|
|
24
|
+
npx -y scava-ai share WHO... let people open it: someone@company.com, "@company.com", group:leads
|
|
25
|
+
npx -y scava-ai apps your apps
|
|
26
|
+
npx -y scava-ai requests change requests you accepted, ready to build
|
|
27
|
+
npx -y scava-ai test test run of the live app, plus problems from visits
|
|
28
|
+
npx -y scava-ai server the server part of the app
|
|
29
|
+
npx -y scava-ai pull APP [folder] the files of an app into a folder
|
|
30
|
+
npx -y scava-ai docs what an app can use on Scava: data, rules, files, server code
|
|
31
|
+
npx -y scava-ai whoami which account this computer is signed in as
|
|
32
|
+
npx -y scava-ai login sign in now
|
|
33
|
+
npx -y scava-ai logout end the access on this computer
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The access stays on your computer, in ~/.scava/credentials.json, and whoever works under your user there acts as you.
|
|
37
|
+
Keys for the server code of an app are set in the browser, never in a terminal.
|
|
38
|
+
|
|
39
|
+
With a one-time link from the Scava tools in your AI (no sign-in here):
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
npx -y scava-ai upload <folder> <link>
|
|
43
|
+
npx -y scava-ai test <link>
|
|
44
|
+
npx -y scava-ai pull <link> [folder]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
More at https://scava.ai/docs
|
|
48
|
+
|
|
49
|
+
Version 0.2.0.
|
package/cli/account.js
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { CMD, LOOPBACK, ScavaError, openBrowser } from './lib.js';
|
|
10
|
+
|
|
11
|
+
// Signing in on this computer, the way wrangler does it. The first command that needs an account opens the browser; the
|
|
12
|
+
// person signs in to Scava and chooses Allow; the access then stays in a file in the home folder and renews itself. It is
|
|
13
|
+
// the same sign-in every AI uses at /mcp: OAuth with PKCE and a return address on this computer, so a link someone sends
|
|
14
|
+
// to a Scava user hands nothing to the sender.
|
|
15
|
+
//
|
|
16
|
+
// An AI runs this command in a terminal that may stop it after a few seconds, while the person is still signing in. So
|
|
17
|
+
// the waiting does not happen in the command: a small program of its own listens on this computer, on its own, until the
|
|
18
|
+
// person has decided or 15 minutes are over. The command only watches for the result and can simply be run again.
|
|
19
|
+
|
|
20
|
+
const CLI = fileURLToPath(new URL('./scava.js', import.meta.url));
|
|
21
|
+
const VERSION = (() => {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
24
|
+
} catch {
|
|
25
|
+
return '0';
|
|
26
|
+
}
|
|
27
|
+
})();
|
|
28
|
+
const USER_AGENT = `scava-cli/${VERSION}`;
|
|
29
|
+
const MCP_VERSION = '2025-11-25';
|
|
30
|
+
// Any port on this computer counts as the same return address (RFC 8252).
|
|
31
|
+
const RETURN_ADDRESS = 'http://127.0.0.1/callback';
|
|
32
|
+
// As long as Scava keeps the request of the page.
|
|
33
|
+
const SIGN_IN_MS = 15 * 60_000;
|
|
34
|
+
// Below the two minutes many AI terminals give a command, so an AI hears from this command by itself.
|
|
35
|
+
const AI_WAIT_MS = 100_000;
|
|
36
|
+
const RENEW_EARLY_MS = 60_000;
|
|
37
|
+
const LOCK_STALE_MS = 30_000;
|
|
38
|
+
const NOTE = `Your access to Scava on this computer. Do not show, copy or send this file. End it with: ${CMD} logout`;
|
|
39
|
+
|
|
40
|
+
// SCAVA_HOME: for tests, which must not touch the access of the computer they run on.
|
|
41
|
+
const folder = () => process.env.SCAVA_HOME || path.join(os.homedir(), '.scava');
|
|
42
|
+
export const credentialsPath = () => path.join(folder(), 'credentials.json');
|
|
43
|
+
const originOf = (server) => new URL(server).origin;
|
|
44
|
+
const signInPath = (server) => path.join(folder(), `sign-in-${crypto.createHash('sha256').update(originOf(server)).digest('hex').slice(0, 12)}.json`);
|
|
45
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
46
|
+
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
47
|
+
const busyFile = (e) => e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'EACCES';
|
|
48
|
+
|
|
49
|
+
// --- The files --------------------------------------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
function readPrivate(file) {
|
|
52
|
+
for (let i = 0; ; i++) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (e.code === 'ENOENT' || e.code === 'ENOTDIR' || e instanceof SyntaxError) return null;
|
|
57
|
+
// Windows, for a moment, while another command replaces the file.
|
|
58
|
+
if (i < 20 && busyFile(e)) pause(50);
|
|
59
|
+
else throw e;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Many AIs run commands in a sandbox that keeps them out of the home folder. Then this is what they have to know.
|
|
65
|
+
function ensureFolder() {
|
|
66
|
+
try {
|
|
67
|
+
fs.mkdirSync(folder(), { recursive: true, mode: 0o700 });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
throw new ScavaError(`Cannot write to ${folder()} (${e.code}), where the access to Scava is kept. If an AI runs this in a sandbox, allow this command to use the home folder and the network, or run it yourself.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Only for this user, and replaced in one step: nobody reads half a file.
|
|
74
|
+
function writePrivate(file, data) {
|
|
75
|
+
ensureFolder();
|
|
76
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
77
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
78
|
+
for (let i = 0; ; i++) {
|
|
79
|
+
try {
|
|
80
|
+
fs.renameSync(tmp, file);
|
|
81
|
+
return;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (i < 20 && busyFile(e)) pause(50);
|
|
84
|
+
else {
|
|
85
|
+
fs.rmSync(tmp, { force: true });
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// One access per server: what belongs to one Scava is never sent to another.
|
|
93
|
+
const entryOf = (server) => readPrivate(credentialsPath())?.servers?.[originOf(server)] ?? null;
|
|
94
|
+
|
|
95
|
+
function setEntry(server, entry) {
|
|
96
|
+
const servers = { ...(readPrivate(credentialsPath())?.servers ?? {}) };
|
|
97
|
+
if (entry) servers[originOf(server)] = entry;
|
|
98
|
+
else delete servers[originOf(server)];
|
|
99
|
+
if (Object.keys(servers).length) writePrivate(credentialsPath(), { note: NOTE, servers });
|
|
100
|
+
else fs.rmSync(credentialsPath(), { force: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Refresh tokens are used once. Two commands at the same moment must not both spend the same one.
|
|
104
|
+
async function withLock(fn) {
|
|
105
|
+
const file = path.join(folder(), 'lock');
|
|
106
|
+
ensureFolder();
|
|
107
|
+
const end = Date.now() + LOCK_STALE_MS + 5000;
|
|
108
|
+
for (;;) {
|
|
109
|
+
try {
|
|
110
|
+
fs.closeSync(fs.openSync(file, 'wx', 0o600));
|
|
111
|
+
break;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (e.code !== 'EEXIST' && !busyFile(e)) throw e;
|
|
114
|
+
try {
|
|
115
|
+
if (Date.now() - fs.statSync(file).mtimeMs > LOCK_STALE_MS) fs.rmSync(file, { force: true });
|
|
116
|
+
} catch { /* gone in the meantime */ }
|
|
117
|
+
if (Date.now() > end) throw new ScavaError(`Another ${CMD} command is busy. Please try again.`);
|
|
118
|
+
await sleep(50);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return await fn();
|
|
123
|
+
} finally {
|
|
124
|
+
fs.rmSync(file, { force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Talking to Scava -------------------------------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
// Never along a redirect: the access goes to this server and nowhere else.
|
|
131
|
+
async function send(url, init) {
|
|
132
|
+
try {
|
|
133
|
+
return await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(60_000), headers: { 'user-agent': USER_AGENT, ...init.headers } });
|
|
134
|
+
} catch (e) {
|
|
135
|
+
const moved = /redirect/i.test(String(e?.cause?.message ?? e?.message));
|
|
136
|
+
throw new ScavaError(`Cannot reach Scava at ${new URL(url).origin}.${moved ? ' It answered with a redirect, and the access never follows one.' : ' If an AI runs this in a sandbox, allow this command to use the network.'}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const form = (fields) => ({ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: new URLSearchParams(fields).toString() });
|
|
141
|
+
const notOpen = (server) => new ScavaError(`Scava at ${originOf(server)} does not take this command yet.`);
|
|
142
|
+
|
|
143
|
+
// The access only travels encrypted, except to a Scava on this computer.
|
|
144
|
+
function checkServer(server) {
|
|
145
|
+
let u;
|
|
146
|
+
try {
|
|
147
|
+
u = new URL(server);
|
|
148
|
+
} catch {
|
|
149
|
+
throw new ScavaError(`${server} is not an address.`);
|
|
150
|
+
}
|
|
151
|
+
if (u.protocol === 'https:' || (u.protocol === 'http:' && LOOPBACK.has(u.hostname))) return;
|
|
152
|
+
throw new ScavaError(`The access to Scava only travels encrypted: ${server} has to start with https, unless it runs on this computer.`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const fresh = (e) => e.expires_at - RENEW_EARLY_MS > Date.now();
|
|
156
|
+
const toEntry = (d, known) => ({
|
|
157
|
+
client_id: known.client_id,
|
|
158
|
+
account: d.account ?? known.account ?? null,
|
|
159
|
+
access_token: d.access_token,
|
|
160
|
+
refresh_token: d.refresh_token ?? known.refresh_token,
|
|
161
|
+
expires_at: Date.now() + (Number(d.expires_in) || 3600) * 1000,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// A new access with the refresh token, or null when that ended: disconnected on the Connect page, 90 days without use,
|
|
165
|
+
// or the token was used elsewhere. Then the next command signs in again. Scava being away ends nothing.
|
|
166
|
+
async function renew(server, failedToken) {
|
|
167
|
+
return withLock(async () => {
|
|
168
|
+
const e = entryOf(server);
|
|
169
|
+
if (!e) return null;
|
|
170
|
+
if (e.access_token !== failedToken && fresh(e)) return e; // another command just did it
|
|
171
|
+
const r = await send(`${server}/oauth/token`, form({ grant_type: 'refresh_token', refresh_token: e.refresh_token, client_id: e.client_id, resource: `${server}/mcp` }));
|
|
172
|
+
const d = await r.json().catch(() => null);
|
|
173
|
+
if (r.ok && d?.access_token) {
|
|
174
|
+
const next = toEntry(d, e);
|
|
175
|
+
setEntry(server, next);
|
|
176
|
+
return next;
|
|
177
|
+
}
|
|
178
|
+
if (r.status === 400 || r.status === 401) {
|
|
179
|
+
setEntry(server, null);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
if (r.status === 404) throw notOpen(server);
|
|
183
|
+
throw new ScavaError(`Scava answered ${r.status} while renewing the access. Please try again.`);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function validEntry(server) {
|
|
188
|
+
const e = entryOf(server);
|
|
189
|
+
if (!e) return null;
|
|
190
|
+
return fresh(e) ? e : renew(server, e.access_token);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const rpc = (server, entry, method, params) => send(`${server}/mcp`, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: { authorization: `Bearer ${entry.access_token}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'mcp-protocol-version': MCP_VERSION },
|
|
196
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Every command that needs an account is one call of the tools every AI gets at /mcp: the rules stay in one place. Returns the text Scava wrote, what it attached for this tool (a one-time link) and whose account it was.
|
|
200
|
+
export async function callTool(server, name, args, signInOptions) {
|
|
201
|
+
checkServer(server);
|
|
202
|
+
let entry = (await validEntry(server)) ?? (await signIn(server, signInOptions));
|
|
203
|
+
for (let attempt = 0; ; attempt++) {
|
|
204
|
+
const res = await rpc(server, entry, 'tools/call', { name, arguments: args });
|
|
205
|
+
if (res.status === 401 && attempt < 2) {
|
|
206
|
+
entry = (await renew(server, entry.access_token)) ?? (await signIn(server, signInOptions));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (res.status === 404) throw notOpen(server);
|
|
210
|
+
const d = await res.json().catch(() => null);
|
|
211
|
+
if (!res.ok || !d?.result) throw new ScavaError(d?.error?.message || `Scava answered ${res.status}.`);
|
|
212
|
+
const text = (d.result.content ?? []).map((c) => c.text).join('\n');
|
|
213
|
+
if (d.result.isError) throw new ScavaError(text);
|
|
214
|
+
return { text, data: d.result.structuredContent ?? {}, account: entry.account };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Who this computer is signed in as, checked with Scava. Null when it is not, or no longer.
|
|
219
|
+
export async function whoAmI(server) {
|
|
220
|
+
checkServer(server);
|
|
221
|
+
let entry = await validEntry(server);
|
|
222
|
+
if (!entry) return null;
|
|
223
|
+
let res = await rpc(server, entry, 'tools/list', {});
|
|
224
|
+
if (res.status === 401) {
|
|
225
|
+
entry = await renew(server, entry.access_token);
|
|
226
|
+
if (!entry) return null;
|
|
227
|
+
res = await rpc(server, entry, 'tools/list', {});
|
|
228
|
+
}
|
|
229
|
+
if (res.status === 404) throw notOpen(server);
|
|
230
|
+
if (!res.ok) throw new ScavaError(`Scava answered ${res.status}.`);
|
|
231
|
+
return entry;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Ends the access at Scava and here. A waiting sign-in ends with it. Other servers stay.
|
|
235
|
+
export async function logout(server) {
|
|
236
|
+
checkServer(server);
|
|
237
|
+
fs.rmSync(signInPath(server), { force: true });
|
|
238
|
+
const e = entryOf(server);
|
|
239
|
+
if (!e) return { was: false };
|
|
240
|
+
let reached = false;
|
|
241
|
+
try {
|
|
242
|
+
reached = (await send(`${server}/oauth/revoke`, form({ token: e.refresh_token }))).ok;
|
|
243
|
+
} catch { /* said below */ }
|
|
244
|
+
await withLock(() => setEntry(server, null));
|
|
245
|
+
return { was: true, account: e.account, reached };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- Signing in -------------------------------------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
// The name on the page, in the mail and on the Connect page: this computer, so a person can tell their connections apart.
|
|
251
|
+
function clientName() {
|
|
252
|
+
const host = os.hostname().split('.')[0].replace(/[^\p{L}\p{N}-]/gu, '').slice(0, 40);
|
|
253
|
+
return host ? `Scava command (${host})` : 'Scava command';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function alive(pid) {
|
|
257
|
+
try {
|
|
258
|
+
process.kill(pid, 0);
|
|
259
|
+
return true;
|
|
260
|
+
} catch (e) {
|
|
261
|
+
return e.code === 'EPERM';
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// A sign-in that is still waiting on this computer, so a second command does not start another one.
|
|
266
|
+
function pending(server) {
|
|
267
|
+
const rec = readPrivate(signInPath(server));
|
|
268
|
+
if (!rec || rec.status || rec.expires_at <= Date.now()) return null;
|
|
269
|
+
return rec.pid ? (alive(rec.pid) ? rec : null) : (Date.now() - rec.created_at < 10_000 ? rec : null);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function detach(...args) {
|
|
273
|
+
spawn(process.execPath, [CLI, '__sign-in', ...args], { detached: true, stdio: 'ignore', windowsHide: true, cwd: os.homedir() }).on('error', () => {}).unref();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function startSignIn(server) {
|
|
277
|
+
const r = await send(`${server}/oauth/register`, {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
280
|
+
body: JSON.stringify({ client_name: clientName(), redirect_uris: [RETURN_ADDRESS], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none' }),
|
|
281
|
+
});
|
|
282
|
+
if (r.status === 404) throw notOpen(server);
|
|
283
|
+
const c = await r.json().catch(() => null);
|
|
284
|
+
if (!r.ok || !c?.client_id) throw new ScavaError(c?.error_description || `Scava answered ${r.status}.`);
|
|
285
|
+
const t = Date.now();
|
|
286
|
+
const rec = {
|
|
287
|
+
server,
|
|
288
|
+
client_id: c.client_id,
|
|
289
|
+
verifier: crypto.randomBytes(48).toString('base64url'),
|
|
290
|
+
state: crypto.randomBytes(24).toString('base64url'),
|
|
291
|
+
created_at: t,
|
|
292
|
+
expires_at: t + SIGN_IN_MS,
|
|
293
|
+
};
|
|
294
|
+
writePrivate(signInPath(server), rec);
|
|
295
|
+
detach(signInPath(server), 'start');
|
|
296
|
+
return rec;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// What came back from the browser, whichever way: is it the answer to this sign-in, from this server?
|
|
300
|
+
function foreignAnswer(u, rec) {
|
|
301
|
+
const a = Buffer.from(u.searchParams.get('state') ?? '');
|
|
302
|
+
const b = Buffer.from(rec.state);
|
|
303
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return 'This does not belong to the sign-in that is waiting on this computer.';
|
|
304
|
+
if (u.searchParams.get('iss') !== originOf(rec.server)) return 'This came from another server than the one this computer asked.';
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const refusal = (u) => (u.searchParams.get('error') === 'access_denied'
|
|
309
|
+
? 'You chose Cancel, so nothing was connected.'
|
|
310
|
+
: `Scava did not connect this computer: ${(u.searchParams.get('error_description') || u.searchParams.get('error')).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 200)}`);
|
|
311
|
+
|
|
312
|
+
async function complete(rec, code) {
|
|
313
|
+
const r = await send(`${rec.server}/oauth/token`, form({
|
|
314
|
+
grant_type: 'authorization_code', code, client_id: rec.client_id, redirect_uri: rec.redirect_uri, code_verifier: rec.verifier, resource: `${rec.server}/mcp`,
|
|
315
|
+
}));
|
|
316
|
+
const d = await r.json().catch(() => null);
|
|
317
|
+
if (!r.ok || !d?.access_token) throw new ScavaError(d?.error_description || `Scava answered ${r.status}.`);
|
|
318
|
+
await withLock(() => {
|
|
319
|
+
setEntry(rec.server, toEntry(d, { client_id: rec.client_id }));
|
|
320
|
+
fs.rmSync(signInPath(rec.server), { force: true });
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// A browser on another device cannot come back to this computer. The person copies the address it tried to open.
|
|
325
|
+
export async function finishWithAddress(server, raw) {
|
|
326
|
+
const rec = readPrivate(signInPath(server));
|
|
327
|
+
if (!rec?.redirect_uri || rec.status || rec.expires_at <= Date.now()) throw new ScavaError(`No sign-in is waiting on this computer. Start one with: ${CMD} login`);
|
|
328
|
+
const wrong = 'That is not the address from the browser. It starts with http://127.0.0.1 and holds code= and state=.';
|
|
329
|
+
let u;
|
|
330
|
+
try {
|
|
331
|
+
u = new URL(String(raw).trim().replace(/^["']|["']$/g, ''));
|
|
332
|
+
} catch {
|
|
333
|
+
throw new ScavaError(wrong);
|
|
334
|
+
}
|
|
335
|
+
if (u.protocol !== 'http:' || !LOOPBACK.has(u.hostname) || u.pathname !== '/callback') throw new ScavaError(wrong);
|
|
336
|
+
const foreign = foreignAnswer(u, rec);
|
|
337
|
+
if (foreign) throw new ScavaError(foreign);
|
|
338
|
+
if (u.searchParams.get('error')) throw new ScavaError(refusal(u));
|
|
339
|
+
const code = u.searchParams.get('code');
|
|
340
|
+
if (!code) throw new ScavaError(wrong);
|
|
341
|
+
await complete(rec, code);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The command watches for the result; pasted lines count too. Null when it did not come in time.
|
|
345
|
+
async function waitForAllow(server, waitMs, { log, input }) {
|
|
346
|
+
const end = Date.now() + waitMs;
|
|
347
|
+
const rl = input ? readline.createInterface({ input }) : null;
|
|
348
|
+
rl?.on('line', (line) => {
|
|
349
|
+
if (line.trim()) finishWithAddress(server, line).catch((e) => log(e.message));
|
|
350
|
+
});
|
|
351
|
+
try {
|
|
352
|
+
for (;;) {
|
|
353
|
+
const e = entryOf(server);
|
|
354
|
+
if (e) return e;
|
|
355
|
+
const rec = readPrivate(signInPath(server));
|
|
356
|
+
if (rec?.status) {
|
|
357
|
+
fs.rmSync(signInPath(server), { force: true });
|
|
358
|
+
throw new ScavaError(rec.message);
|
|
359
|
+
}
|
|
360
|
+
if (!rec) {
|
|
361
|
+
const done = entryOf(server); // finished between the two looks
|
|
362
|
+
if (done) return done;
|
|
363
|
+
throw new ScavaError(`The sign-in ended. Please run the command again.`);
|
|
364
|
+
}
|
|
365
|
+
if (Date.now() >= end) return null;
|
|
366
|
+
await sleep(150);
|
|
367
|
+
}
|
|
368
|
+
} finally {
|
|
369
|
+
rl?.close();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function signIn(server, { browser = true, waitMs, reopen = false, log = console.log, input = process.stdin } = {}) {
|
|
374
|
+
checkServer(server);
|
|
375
|
+
const atTerminal = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
376
|
+
const wait = waitMs ?? (atTerminal ? SIGN_IN_MS : AI_WAIT_MS);
|
|
377
|
+
let created = false;
|
|
378
|
+
let rec = await withLock(async () => {
|
|
379
|
+
const known = pending(server);
|
|
380
|
+
if (known) return known;
|
|
381
|
+
created = true;
|
|
382
|
+
return startSignIn(server);
|
|
383
|
+
});
|
|
384
|
+
for (const end = Date.now() + 15_000; !rec?.url; rec = readPrivate(signInPath(server))) {
|
|
385
|
+
const done = entryOf(server); // another command finished it in the meantime
|
|
386
|
+
if (done) return done;
|
|
387
|
+
if (rec?.status) throw new ScavaError(rec.message);
|
|
388
|
+
if (Date.now() > end) throw new ScavaError('Could not start the sign-in on this computer. Please run the command again.');
|
|
389
|
+
await sleep(100);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const canOpen = browser && !process.env.SSH_CONNECTION && !process.env.SSH_TTY && (process.platform === 'win32' || process.platform === 'darwin' || process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
393
|
+
if (canOpen && (created || reopen)) openBrowser(rec.url);
|
|
394
|
+
log(`Scava needs a sign-in on this computer, once. ${canOpen ? 'A browser window opens for it. If not, open this address:' : 'Open this address in a browser, on any device:'}`);
|
|
395
|
+
log('');
|
|
396
|
+
log(` ${rec.url}`);
|
|
397
|
+
log('');
|
|
398
|
+
log('Sign in and choose Allow. This continues by itself.');
|
|
399
|
+
const copy = 'On another device the browser then cannot reach 127.0.0.1. That is normal:';
|
|
400
|
+
if (atTerminal) log(`${copy} copy the whole address from its address bar and paste it here.`);
|
|
401
|
+
else {
|
|
402
|
+
log('');
|
|
403
|
+
log(`If you are an AI running this: ask the person to sign in in the browser and choose Allow. This command waits ${Math.round(wait / 1000)} seconds. If it ends or your terminal stops it, run it again once they have allowed it: the sign-in keeps waiting for ${Math.ceil((rec.expires_at - Date.now()) / 60_000)} minutes.`);
|
|
404
|
+
log(`${copy} ask for the whole address from its address bar, then run: ${CMD} login --paste "<address>"`);
|
|
405
|
+
}
|
|
406
|
+
log('');
|
|
407
|
+
|
|
408
|
+
const entry = await waitForAllow(server, wait, { log, input });
|
|
409
|
+
if (!entry) throw new ScavaError(`Not signed in yet: the sign-in is waiting in the browser. Once the person has chosen Allow, run this command again.`);
|
|
410
|
+
log(`Signed in to Scava as ${entry.account ?? 'your account'}. The access stays on this computer, in ${credentialsPath()}. End it with: ${CMD} logout`);
|
|
411
|
+
log('');
|
|
412
|
+
return entry;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// --- The small program that waits -------------------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
418
|
+
const pageOf = (title, text) => `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} · Scava</title>
|
|
419
|
+
<style>body{margin:0;min-height:100vh;display:grid;place-items:center;padding:1rem;box-sizing:border-box;background:#131518;color:#f2f3f5;font:16px/1.5 system-ui,sans-serif}main{max-width:26rem}h1{font-size:1.4rem;margin:0 0 .5rem}p{margin:0;color:#a9afb8}</style>
|
|
420
|
+
<main><h1>${esc(title)}</h1><p>${esc(text)}</p></main></html>`;
|
|
421
|
+
|
|
422
|
+
// Started by the command, and started once more by itself, so it is nobody's child: a terminal that ends the command
|
|
423
|
+
// and everything below it does not end the waiting. It holds no connection to the terminal. It ends with the sign-in.
|
|
424
|
+
export async function signInHelper(file, step) {
|
|
425
|
+
if (step === 'start') {
|
|
426
|
+
detach(file);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const rec = readPrivate(file);
|
|
430
|
+
if (!rec) return;
|
|
431
|
+
const fail = (status, message) => {
|
|
432
|
+
const now = readPrivate(file);
|
|
433
|
+
if (now) writePrivate(file, { ...now, status, message });
|
|
434
|
+
};
|
|
435
|
+
const answer = (s, status, title, text, then) => {
|
|
436
|
+
s.writeHead(status, {
|
|
437
|
+
'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', connection: 'close',
|
|
438
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'",
|
|
439
|
+
});
|
|
440
|
+
s.end(pageOf(title, text), then);
|
|
441
|
+
};
|
|
442
|
+
const finished = () => setTimeout(() => process.exit(0), 100);
|
|
443
|
+
let current = null;
|
|
444
|
+
let busy = false;
|
|
445
|
+
|
|
446
|
+
const server = http.createServer(async (q, s) => {
|
|
447
|
+
let u = null;
|
|
448
|
+
try {
|
|
449
|
+
u = new URL(q.url, 'http://127.0.0.1');
|
|
450
|
+
} catch { /* nothing this program sent anyone to */ }
|
|
451
|
+
if (q.method !== 'GET' || u?.pathname !== '/callback' || !current) {
|
|
452
|
+
answer(s, 404, 'Nothing here', 'This page belongs to the sign-in of the Scava command.');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
// Something else knocking does not end the waiting.
|
|
456
|
+
const foreign = foreignAnswer(u, current);
|
|
457
|
+
if (foreign) {
|
|
458
|
+
answer(s, 400, 'Not connected', foreign);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (busy) {
|
|
462
|
+
answer(s, 409, 'One moment', 'This sign-in is being finished already.');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
busy = true;
|
|
466
|
+
if (u.searchParams.get('error')) {
|
|
467
|
+
fail('refused', refusal(u));
|
|
468
|
+
answer(s, 200, 'Not connected', 'Nothing was connected. You can close this window.', finished);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
await complete(current, u.searchParams.get('code') ?? '');
|
|
473
|
+
answer(s, 200, 'Connected', 'You can close this window and go back to your terminal or your AI.', finished);
|
|
474
|
+
} catch (e) {
|
|
475
|
+
fail('failed', e.message);
|
|
476
|
+
answer(s, 200, 'Not connected', e.message, finished);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
await new Promise((resolve, reject) => {
|
|
482
|
+
server.once('error', reject);
|
|
483
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
484
|
+
});
|
|
485
|
+
const redirect = `http://127.0.0.1:${server.address().port}/callback`;
|
|
486
|
+
const challenge = crypto.createHash('sha256').update(rec.verifier).digest('base64url');
|
|
487
|
+
const q = new URLSearchParams({
|
|
488
|
+
response_type: 'code', client_id: rec.client_id, redirect_uri: redirect, state: rec.state,
|
|
489
|
+
code_challenge: challenge, code_challenge_method: 'S256', scope: 'scava', resource: `${rec.server}/mcp`,
|
|
490
|
+
});
|
|
491
|
+
current = { ...rec, redirect_uri: redirect, url: `${rec.server}/oauth/authorize?${q}`, pid: process.pid };
|
|
492
|
+
writePrivate(file, current);
|
|
493
|
+
} catch (e) {
|
|
494
|
+
fail('failed', `Could not wait for the browser on this computer: ${e.message}`);
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Over when the time is up, or when it was finished another way (a pasted address, logout, a newer sign-in).
|
|
499
|
+
setInterval(() => {
|
|
500
|
+
const now = readPrivate(file);
|
|
501
|
+
if (!now || now.pid !== process.pid || now.status) process.exit(0);
|
|
502
|
+
if (now.expires_at <= Date.now()) {
|
|
503
|
+
fs.rmSync(file, { force: true });
|
|
504
|
+
process.exit(0);
|
|
505
|
+
}
|
|
506
|
+
}, 1000);
|
|
507
|
+
}
|