scava-ai 0.0.0-stage → 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/README.md +48 -2
- package/cli/account.js +507 -0
- package/cli/check.js +250 -0
- package/cli/lib.js +195 -0
- package/cli/scava.js +285 -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 deploy
|
|
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 deploy [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.1.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
|
+
}
|
package/cli/check.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Test run: opens a published app once in Chrome or Edge without a window, with exactly the rules it has
|
|
2
|
+
// online, and collects what goes wrong: errors in the code, calls Scava blocks, files that are missing,
|
|
3
|
+
// data calls that fail, a page that stays empty. Every problem comes with a hint for the agent.
|
|
4
|
+
// Runs on the creator's computer (no extra packages, DevTools protocol over Node's own WebSocket).
|
|
5
|
+
// Scava never judges what the app does, only whether it runs under Scava's rules.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const BROWSERS = [
|
|
12
|
+
process.env.SCAVA_CHROME,
|
|
13
|
+
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
|
14
|
+
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
|
15
|
+
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
|
16
|
+
'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
|
|
17
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
18
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
19
|
+
'/usr/bin/google-chrome',
|
|
20
|
+
'/usr/bin/google-chrome-stable',
|
|
21
|
+
'/usr/bin/chromium',
|
|
22
|
+
'/usr/bin/chromium-browser',
|
|
23
|
+
'/usr/bin/microsoft-edge',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export const findBrowser = () => BROWSERS.find((p) => p && fs.existsSync(p)) || null;
|
|
27
|
+
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
29
|
+
|
|
30
|
+
// What an agent should do about each kind of problem.
|
|
31
|
+
const HINTS = {
|
|
32
|
+
error: 'Fix this error in the app code.',
|
|
33
|
+
blockedConnect: 'Apps on Scava can only talk to their own address. Save and load data with scava.db, or remove this call.',
|
|
34
|
+
blockedImage: 'Images must be part of the app: put the file into the folder and refer to it with a relative path.',
|
|
35
|
+
blockedScript: 'Scripts may only come from the app itself or from cdnjs, jsDelivr, unpkg, esm.sh or the Tailwind CDN. Put the file into the folder.',
|
|
36
|
+
blockedStyle: 'Stylesheets and fonts may only come from the app itself or from the same CDNs and Google Fonts. Put the file into the folder.',
|
|
37
|
+
blockedFrame: 'Apps cannot embed other websites. Link to them instead.',
|
|
38
|
+
blockedCode: 'Do not run code from text (eval, new Function, setTimeout with a string). Write it as normal code.',
|
|
39
|
+
blockedOther: 'Scava blocked this. Keep everything the app needs inside its own folder.',
|
|
40
|
+
missing: 'The app refers to a file that is not in the uploaded folder. Check the path (relative to the page) and publish again.',
|
|
41
|
+
data: 'A scava.db call was refused. Check the rules in scava.json and the size of the entry.',
|
|
42
|
+
empty: 'The page shows nothing after loading. Usually a script failed or a build refers to wrong paths (use relative paths, e.g. base: "./" in Vite).',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function blockedHint(directive) {
|
|
46
|
+
const d = String(directive || '');
|
|
47
|
+
if (d.startsWith('connect')) return HINTS.blockedConnect;
|
|
48
|
+
if (d.startsWith('img') || d.startsWith('media')) return HINTS.blockedImage;
|
|
49
|
+
if (d.startsWith('script')) return HINTS.blockedScript;
|
|
50
|
+
if (d.startsWith('style') || d.startsWith('font')) return HINTS.blockedStyle;
|
|
51
|
+
if (d.startsWith('frame') || d.startsWith('child')) return HINTS.blockedFrame;
|
|
52
|
+
if (d.includes('trusted-types') || d.includes('unsafe-eval')) return HINTS.blockedCode;
|
|
53
|
+
return HINTS.blockedOther;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const trim = (s, n = 300) => {
|
|
57
|
+
const t = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
58
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Opens `url` (a one-time link that signs the browser in as the app's creator) and returns the problems.
|
|
62
|
+
// Returns { ran: false, reason } when no browser is installed.
|
|
63
|
+
// Every question to the browser has a deadline (`answer`): a browser that goes away or a page that never stops working
|
|
64
|
+
// (an endless loop) ends the test run with a reason, and the command waiting for it goes on.
|
|
65
|
+
export async function testRun(url, { wait = 3500, timeout = 30000, answer = 15000, browser = findBrowser() } = {}) {
|
|
66
|
+
if (!browser) return { ran: false, reason: 'No Chrome or Edge found on this computer, so the test run was skipped.' };
|
|
67
|
+
const appOrigin = new URL(url).origin;
|
|
68
|
+
const silent = `the browser did not answer for ${Math.round(answer / 1000)} seconds. A script of the app may keep the page busy (an endless loop), or the app loads very slowly`;
|
|
69
|
+
const gone = 'the browser closed during the test run';
|
|
70
|
+
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'scava-check-'));
|
|
71
|
+
// Port 0: the browser takes a free port and writes it into its profile. A port of our own choosing can be taken, and two
|
|
72
|
+
// test runs at the same time could end up in one browser, where the first to finish closes it under the other.
|
|
73
|
+
const proc = spawn(browser, ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', '--disable-extensions', 'about:blank'], { stdio: 'ignore' });
|
|
74
|
+
const problems = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const add = (p) => {
|
|
77
|
+
const key = `${p.kind}|${p.message}|${p.source || ''}|${p.line || 0}`;
|
|
78
|
+
if (seen.has(key) || problems.length >= 50) return;
|
|
79
|
+
seen.add(key);
|
|
80
|
+
problems.push(p);
|
|
81
|
+
};
|
|
82
|
+
let ws;
|
|
83
|
+
try {
|
|
84
|
+
let target;
|
|
85
|
+
const end = Date.now() + 10000;
|
|
86
|
+
while (!target && Date.now() < end) {
|
|
87
|
+
try {
|
|
88
|
+
const port = Number(fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').split('\n')[0]);
|
|
89
|
+
target = (await (await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(2000) })).json()).find((t) => t.type === 'page');
|
|
90
|
+
} catch {
|
|
91
|
+
/* not up yet */
|
|
92
|
+
}
|
|
93
|
+
if (!target) await sleep(200);
|
|
94
|
+
}
|
|
95
|
+
if (!target) return { ran: false, reason: 'The browser did not start, so the test run was skipped.' };
|
|
96
|
+
ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
97
|
+
await new Promise((res, rej) => {
|
|
98
|
+
const timer = setTimeout(() => rej(new Error(silent)), answer);
|
|
99
|
+
ws.onopen = () => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
res();
|
|
102
|
+
};
|
|
103
|
+
ws.onerror = () => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
rej(new Error('the browser refused the connection'));
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
let id = 0;
|
|
109
|
+
const pending = new Map();
|
|
110
|
+
const requests = new Map();
|
|
111
|
+
let loaded = false;
|
|
112
|
+
// A browser that goes away ends every open question at once.
|
|
113
|
+
ws.onclose = () => {
|
|
114
|
+
for (const call of pending.values()) {
|
|
115
|
+
clearTimeout(call.timer);
|
|
116
|
+
call.rej(new Error(gone));
|
|
117
|
+
}
|
|
118
|
+
pending.clear();
|
|
119
|
+
};
|
|
120
|
+
ws.onmessage = (m) => {
|
|
121
|
+
const msg = JSON.parse(m.data);
|
|
122
|
+
if (msg.id && pending.has(msg.id)) {
|
|
123
|
+
const call = pending.get(msg.id);
|
|
124
|
+
pending.delete(msg.id);
|
|
125
|
+
clearTimeout(call.timer);
|
|
126
|
+
call.res(msg);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const p = msg.params || {};
|
|
130
|
+
switch (msg.method) {
|
|
131
|
+
case 'Page.loadEventFired':
|
|
132
|
+
loaded = true;
|
|
133
|
+
break;
|
|
134
|
+
case 'Runtime.exceptionThrown': {
|
|
135
|
+
const d = p.exceptionDetails || {};
|
|
136
|
+
const text = d.exception?.description?.split('\n')[0] || d.text || 'Error';
|
|
137
|
+
add({ kind: 'error', message: trim(text), source: shortSource(d.url, appOrigin), line: (d.lineNumber ?? -1) + 1, hint: HINTS.error });
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case 'Runtime.consoleAPICalled':
|
|
141
|
+
if (p.type === 'error') {
|
|
142
|
+
const text = (p.args || []).map((a) => a.value ?? a.description ?? '').join(' ');
|
|
143
|
+
// Messages from the browser about blocked content arrive as Log entries; this is the app's own.
|
|
144
|
+
if (text) add({ kind: 'console', message: trim(text), source: '', line: 0, hint: 'The app logged an error. Check whether something failed.' });
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
case 'Log.entryAdded': {
|
|
148
|
+
const e = p.entry || {};
|
|
149
|
+
if (e.level !== 'error') break;
|
|
150
|
+
const text = e.text || '';
|
|
151
|
+
if (/Content Security Policy|Trusted ?Type|evaluate a string/i.test(text)) {
|
|
152
|
+
// e.g. Refused to connect to 'https://api.example.com/x' because it violates ... directive: "connect-src 'self'".
|
|
153
|
+
const directive = (text.match(/directive:?\s*"([^"]+)"/i) || [])[1] || '';
|
|
154
|
+
const blocked = (text.match(/'(https?:[^']+)'/) || [])[1] || e.url || '';
|
|
155
|
+
const fromText = /unsafe-eval|evaluate a string|Trusted ?Type/i.test(text);
|
|
156
|
+
add({
|
|
157
|
+
kind: 'blocked',
|
|
158
|
+
message: trim(blocked ? `Blocked: ${shortSource(blocked, appOrigin)}` : text, 200),
|
|
159
|
+
source: fromText ? 'code from text' : directive.split(' ')[0] || '',
|
|
160
|
+
line: 0,
|
|
161
|
+
hint: fromText ? HINTS.blockedCode : blockedHint(directive),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case 'Network.requestWillBeSent':
|
|
167
|
+
requests.set(p.requestId, p.request?.url || '');
|
|
168
|
+
break;
|
|
169
|
+
case 'Network.responseReceived': {
|
|
170
|
+
const r = p.response || {};
|
|
171
|
+
const u = r.url || requests.get(p.requestId) || '';
|
|
172
|
+
if (r.status >= 400 && u.startsWith(appOrigin)) {
|
|
173
|
+
const rel = shortSource(u, appOrigin);
|
|
174
|
+
if (rel.startsWith('/_scava/api/data')) add({ kind: 'data', message: trim(`${r.status} on ${rel.split('?')[0]}`), source: '', line: 0, hint: HINTS.data });
|
|
175
|
+
// The browser asks for /favicon.ico on its own; that is not something the app refers to.
|
|
176
|
+
else if (!rel.startsWith('/_scava/') && r.status === 404 && rel.split('?')[0] !== '/favicon.ico') {
|
|
177
|
+
add({ kind: 'missing', message: `Not found: ${rel.split('?')[0]}`, source: '', line: 0, hint: HINTS.missing });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
default:
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const send = (method, params = {}) =>
|
|
186
|
+
new Promise((res, rej) => {
|
|
187
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
188
|
+
rej(new Error(gone));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const i = ++id;
|
|
192
|
+
const timer = setTimeout(() => {
|
|
193
|
+
pending.delete(i);
|
|
194
|
+
rej(new Error(silent));
|
|
195
|
+
}, answer);
|
|
196
|
+
pending.set(i, { res, rej, timer });
|
|
197
|
+
ws.send(JSON.stringify({ id: i, method, params }));
|
|
198
|
+
});
|
|
199
|
+
await Promise.all([send('Page.enable'), send('Runtime.enable'), send('Log.enable'), send('Network.enable')]);
|
|
200
|
+
await send('Page.navigate', { url });
|
|
201
|
+
const until = Date.now() + timeout;
|
|
202
|
+
while (!loaded && Date.now() < until) await sleep(100);
|
|
203
|
+
await sleep(wait); // scripts that start after loading, first data calls
|
|
204
|
+
const empty = await send('Runtime.evaluate', {
|
|
205
|
+
expression: `location.origin === ${JSON.stringify(appOrigin)} && document.body && !document.body.innerText.trim() && !document.querySelector('img,svg,canvas,video,iframe,input,button')`,
|
|
206
|
+
returnByValue: true,
|
|
207
|
+
});
|
|
208
|
+
if (empty.result?.result?.value === true) add({ kind: 'empty', message: 'The page stays empty after loading', source: '', line: 0, hint: HINTS.empty });
|
|
209
|
+
const where = await send('Runtime.evaluate', { expression: 'location.origin + location.pathname', returnByValue: true });
|
|
210
|
+
const landed = String(where.result?.result?.value || '');
|
|
211
|
+
if (!landed.startsWith(appOrigin)) {
|
|
212
|
+
return { ran: false, reason: `The test run could not open the app (ended at ${trim(landed, 120)}).` };
|
|
213
|
+
}
|
|
214
|
+
return { ran: true, problems };
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return { ran: false, reason: `The test run failed: ${trim(err.message, 200)}` };
|
|
217
|
+
} finally {
|
|
218
|
+
try {
|
|
219
|
+
ws?.close();
|
|
220
|
+
} catch {
|
|
221
|
+
/* closed */
|
|
222
|
+
}
|
|
223
|
+
proc.kill();
|
|
224
|
+
for (let i = 0; i < 10; i++) {
|
|
225
|
+
try {
|
|
226
|
+
fs.rmSync(profile, { recursive: true, force: true });
|
|
227
|
+
break;
|
|
228
|
+
} catch {
|
|
229
|
+
await sleep(200);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function shortSource(u, origin) {
|
|
236
|
+
if (!u) return '';
|
|
237
|
+
return String(u).startsWith(origin) ? String(u).slice(origin.length) || '/' : trim(String(u).replace(/[?#].*$/, ''), 160);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Text for the agent and the terminal.
|
|
241
|
+
export function describe(result) {
|
|
242
|
+
if (!result.ran) return result.reason;
|
|
243
|
+
if (!result.problems.length) return 'Test run: the app loaded without errors, blocked calls or missing files.';
|
|
244
|
+
const lines = [`Test run found ${result.problems.length} problem${result.problems.length === 1 ? '' : 's'}:`];
|
|
245
|
+
for (const p of result.problems) {
|
|
246
|
+
const at = p.source && p.kind === 'error' ? ` (${p.source}${p.line > 0 ? `:${p.line}` : ''})` : '';
|
|
247
|
+
lines.push(`- [${p.kind}] ${p.message}${at}`, ` ${p.hint}`);
|
|
248
|
+
}
|
|
249
|
+
return lines.join('\n');
|
|
250
|
+
}
|
package/cli/lib.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
// The small tool on a person's computer (npm package "scava"): one command for every AI with a terminal, like wrangler.
|
|
6
|
+
// This file holds what needs no account: reading a folder, carrying out a one-time link from Scava.
|
|
7
|
+
// Signing in and the access on this computer are in account.js.
|
|
8
|
+
|
|
9
|
+
// Online Scava. Locally: --server http://localhost:8787 or SCAVA_SERVER.
|
|
10
|
+
export const DEFAULT_SERVER = 'https://scava.ai';
|
|
11
|
+
// How this tool is called, in everything it tells a person or an AI to run next.
|
|
12
|
+
export const CMD = process.env.SCAVA_CLI_COMMAND || 'npx -y scava-ai';
|
|
13
|
+
const MAX_BYTES = 25 * 1024 * 1024;
|
|
14
|
+
const MAX_FILES = 2000;
|
|
15
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', '.next', '.cache', '.vscode', '.idea', '__pycache__']);
|
|
16
|
+
const BUILD_DIRS = ['dist', 'build', 'out', 'public', 'www'];
|
|
17
|
+
export const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
18
|
+
|
|
19
|
+
export class ScavaError extends Error {
|
|
20
|
+
constructor(message, data = null) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.data = data;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function serverUrl(explicit) {
|
|
27
|
+
return (explicit || process.env.SCAVA_SERVER || DEFAULT_SERVER).replace(/\/+$/, '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The default browser. On Windows through rundll32: an address with & would end the command in cmd. A computer without
|
|
31
|
+
// a browser is no error, the address is always printed as well.
|
|
32
|
+
export function openBrowser(url) {
|
|
33
|
+
const [cmd, args] =
|
|
34
|
+
process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
|
|
35
|
+
try {
|
|
36
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
|
|
37
|
+
} catch {
|
|
38
|
+
/* the address is printed as well */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readFolder(dir) {
|
|
43
|
+
const root = path.resolve(dir || '.');
|
|
44
|
+
let st;
|
|
45
|
+
try {
|
|
46
|
+
st = fs.statSync(root);
|
|
47
|
+
} catch {
|
|
48
|
+
throw new ScavaError(`${root} does not exist.`);
|
|
49
|
+
}
|
|
50
|
+
if (!st.isDirectory()) throw new ScavaError(`${root} is not a folder.`);
|
|
51
|
+
const files = [];
|
|
52
|
+
let total = 0;
|
|
53
|
+
(function walk(d, rel) {
|
|
54
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
55
|
+
if (e.name.startsWith('.') && e.name !== '.well-known') continue;
|
|
56
|
+
const p = rel ? `${rel}/${e.name}` : e.name;
|
|
57
|
+
if (e.isDirectory()) {
|
|
58
|
+
if (!SKIP_DIRS.has(e.name)) walk(path.join(d, e.name), p);
|
|
59
|
+
} else if (e.isFile()) {
|
|
60
|
+
const buf = fs.readFileSync(path.join(d, e.name));
|
|
61
|
+
total += buf.length;
|
|
62
|
+
if (total > MAX_BYTES) throw new ScavaError('The folder is larger than 25 MB.');
|
|
63
|
+
files.push({ path: p, data: buf.toString('base64') });
|
|
64
|
+
if (files.length > MAX_FILES) throw new ScavaError('The folder has more than 2,000 files.');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
})(root, '');
|
|
68
|
+
if (!files.some((f) => f.path === 'index.html')) {
|
|
69
|
+
const built = BUILD_DIRS.find((d) => fs.existsSync(path.join(root, d, 'index.html')));
|
|
70
|
+
const hint = built
|
|
71
|
+
? ` There is one in ${built}/. If that is your app: ${CMD} deploy ${built}`
|
|
72
|
+
: fs.existsSync(path.join(root, 'package.json'))
|
|
73
|
+
? ' This looks like a project that needs a build step. Build it and publish the output folder (often dist/ or build/).'
|
|
74
|
+
: '';
|
|
75
|
+
throw new ScavaError(`There is no index.html in ${root}.${hint}`);
|
|
76
|
+
}
|
|
77
|
+
return { root, files, total };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Name of a new app: from scava.json, else the title of index.html, else the folder.
|
|
81
|
+
export function guessName(root) {
|
|
82
|
+
try {
|
|
83
|
+
const c = JSON.parse(fs.readFileSync(path.join(root, 'scava.json'), 'utf8'));
|
|
84
|
+
if (typeof c.name === 'string' && c.name.trim()) return c.name.trim();
|
|
85
|
+
} catch { /* no scava.json */ }
|
|
86
|
+
try {
|
|
87
|
+
const m = fs.readFileSync(path.join(root, 'index.html'), 'utf8').match(/<title>([^<]{1,80})<\/title>/i);
|
|
88
|
+
if (m && m[1].trim()) return m[1].trim();
|
|
89
|
+
} catch { /* no title */ }
|
|
90
|
+
const base = path.basename(root);
|
|
91
|
+
return BUILD_DIRS.includes(base.toLowerCase()) ? path.basename(path.dirname(root)) : base;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A path from the server, safe to write below the folder: no absolute paths, no "..", no drive letters, no names
|
|
95
|
+
// Windows reserves. The server checked them on upload already; this is the second line on the builder's computer.
|
|
96
|
+
function safeRelative(p) {
|
|
97
|
+
if (typeof p !== 'string' || !p || p.length > 300 || p.startsWith('/') || p.includes('\\')) return null;
|
|
98
|
+
const parts = p.split('/');
|
|
99
|
+
for (const part of parts) {
|
|
100
|
+
if (!part || part === '.' || part === '..' || /[\x00-\x1f<>:"|?*]/.test(part) || /[. ]$/.test(part)) return null;
|
|
101
|
+
if (/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i.test(part)) return null;
|
|
102
|
+
}
|
|
103
|
+
return parts;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The app a folder belongs to (written after publishing or pulling).
|
|
107
|
+
export function appOfFolder(dir = '.') {
|
|
108
|
+
const file = path.join(path.resolve(dir), '.scava-app');
|
|
109
|
+
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim() : '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- One-time links -------------------------------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
// A link an agent passes on. Files only ever go to Scava itself, or to this computer when someone runs Scava locally:
|
|
115
|
+
// a text the agent read cannot talk it into sending a folder somewhere else with this command.
|
|
116
|
+
export function parseLink(raw, server) {
|
|
117
|
+
let u;
|
|
118
|
+
try {
|
|
119
|
+
u = new URL(String(raw));
|
|
120
|
+
} catch {
|
|
121
|
+
throw new ScavaError('That is not a Scava link. Ask your AI for a new one.');
|
|
122
|
+
}
|
|
123
|
+
const own = new URL(serverUrl(server)).origin;
|
|
124
|
+
const here = u.protocol === 'http:' && LOOPBACK.has(u.hostname);
|
|
125
|
+
if (u.origin !== own && !here) throw new ScavaError(`This link does not lead to Scava (${own}), so nothing was sent.`);
|
|
126
|
+
if (!/^\/up\/[A-Za-z0-9_-]{20,100}$/.test(u.pathname)) throw new ScavaError('That is not a Scava link. Ask your AI for a new one.');
|
|
127
|
+
return u;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function useLink(u, body) {
|
|
131
|
+
let r;
|
|
132
|
+
try {
|
|
133
|
+
r = await fetch(u, { method: 'POST', redirect: 'error', headers: { 'content-type': 'application/json', 'x-scava': '1' }, body: JSON.stringify(body ?? {}) });
|
|
134
|
+
} catch {
|
|
135
|
+
throw new ScavaError(`Cannot reach Scava at ${u.origin}.`);
|
|
136
|
+
}
|
|
137
|
+
const text = await r.text();
|
|
138
|
+
let data = null;
|
|
139
|
+
try {
|
|
140
|
+
data = text ? JSON.parse(text) : null;
|
|
141
|
+
} catch {
|
|
142
|
+
data = { error: text.slice(0, 200) };
|
|
143
|
+
}
|
|
144
|
+
if (!r.ok) throw new ScavaError(data?.error || `${r.status} ${r.statusText}`, data);
|
|
145
|
+
return data;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Uploads a folder as a new version. The folder remembers its app in .scava-app, for the next time. `folder`: when the
|
|
149
|
+
// caller has read it already. The link is checked first: nothing is read for a link that leads somewhere else.
|
|
150
|
+
export async function uploadFolder(link, dir, { server, folder } = {}) {
|
|
151
|
+
const u = parseLink(link, server);
|
|
152
|
+
const { root, files } = folder ?? readFolder(dir);
|
|
153
|
+
const r = await useLink(u, { files, app: appOfFolder(root) || undefined, name: guessName(root) });
|
|
154
|
+
try {
|
|
155
|
+
fs.writeFileSync(path.join(root, '.scava-app'), `${r.slug}\n`);
|
|
156
|
+
} catch { /* read-only folder: the app's name is printed anyway */ }
|
|
157
|
+
return r;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// What a test run needs: a link that opens the live app once, and what browsers reported so far.
|
|
161
|
+
export const testLink = (link, { server } = {}) => useLink(parseLink(link, server));
|
|
162
|
+
|
|
163
|
+
// The files of an app's live version into a folder. The folder is new or empty, or already belongs to the same app
|
|
164
|
+
// (then the files of the version overwrite what is there; other files stay). It remembers the app.
|
|
165
|
+
export async function pullLink(link, dir, { server } = {}) {
|
|
166
|
+
const src = await useLink(parseLink(link, server));
|
|
167
|
+
const root = path.resolve(dir || src.slug);
|
|
168
|
+
const linkFile = path.join(root, '.scava-app');
|
|
169
|
+
if (fs.existsSync(root)) {
|
|
170
|
+
if (!fs.statSync(root).isDirectory()) throw new ScavaError(`${root} is a file, not a folder.`);
|
|
171
|
+
const mine = fs.existsSync(linkFile) && fs.readFileSync(linkFile, 'utf8').trim() === src.slug;
|
|
172
|
+
if (!mine && fs.readdirSync(root).length) {
|
|
173
|
+
throw new ScavaError(`${root} is not empty and belongs to no app or another one. Pull into a new folder, or into the folder of ${src.slug}.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const files = src.files.map((f) => {
|
|
177
|
+
const parts = safeRelative(f.path);
|
|
178
|
+
if (!parts) throw new ScavaError(`Scava sent a file with a path this computer will not write: ${String(f.path).slice(0, 120)}`);
|
|
179
|
+
return { target: path.join(root, ...parts), bytes: Buffer.from(f.data, 'base64') };
|
|
180
|
+
});
|
|
181
|
+
for (const f of files) {
|
|
182
|
+
fs.mkdirSync(path.dirname(f.target), { recursive: true });
|
|
183
|
+
fs.writeFileSync(f.target, f.bytes);
|
|
184
|
+
}
|
|
185
|
+
fs.writeFileSync(linkFile, `${src.slug}\n`);
|
|
186
|
+
return { slug: src.slug, name: src.name, version: src.version, dir: root, fileCount: files.length };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Why something was refused, each problem with its file and line.
|
|
190
|
+
export function formatProblems(err) {
|
|
191
|
+
const lines = [err.message];
|
|
192
|
+
for (const p of err.data?.problems || []) lines.push(` x ${p.file ? `${p.file}${p.line ? `:${p.line}` : ''}: ` : ''}${p.message}`);
|
|
193
|
+
for (const w of err.data?.warnings || []) lines.push(` ! ${w.file ? `${w.file}${w.line ? `:${w.line}` : ''}: ` : ''}${w.message}`);
|
|
194
|
+
return lines.join('\n');
|
|
195
|
+
}
|
package/cli/scava.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { CMD, serverUrl, openBrowser, formatProblems, ScavaError, appOfFolder, readFolder, uploadFolder, testLink, pullLink } from './lib.js';
|
|
4
|
+
import { callTool, whoAmI, logout, signIn, finishWithAddress, signInHelper, credentialsPath } from './account.js';
|
|
5
|
+
import { testRun, describe as describeRun } from './check.js';
|
|
6
|
+
|
|
7
|
+
// "scava dev" needs Scava's own server code. That is only in the repo, never in the npm package: there the command is
|
|
8
|
+
// not listed and says so.
|
|
9
|
+
const HAS_DEV = fs.existsSync(new URL('./dev.js', import.meta.url));
|
|
10
|
+
const DEV_HELP = `${CMD} dev [folder] [--port 8788] [--no-browser]
|
|
11
|
+
The app with its server code on this computer, before it goes online: published again on every save, secrets
|
|
12
|
+
from .env, type "run <schedule>" to run a schedule.
|
|
13
|
+
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
const HELP = `Scava: hosting, sign-in and a database for the apps your AI builds. One command, for you and for any AI with a terminal.
|
|
17
|
+
|
|
18
|
+
${CMD} deploy [folder]
|
|
19
|
+
Puts the app in this folder online, on its own address. A new app the first time, a new version after that.
|
|
20
|
+
The folder needs an index.html; for a build, deploy the output folder. Afterwards it opens the app once in a
|
|
21
|
+
browser here and lists what went wrong.
|
|
22
|
+
--name NAME --message TEXT --resolves 1,2 (change requests this version builds) --app APP --no-check
|
|
23
|
+
|
|
24
|
+
${CMD} share WHO...
|
|
25
|
+
Lets people open the app: someone@company.com, "@company.com", group:leads. --remove WHO takes them out.
|
|
26
|
+
Without WHO: who can open it now.
|
|
27
|
+
${CMD} apps
|
|
28
|
+
Your apps, the apps you co-edit, the apps shared with you.
|
|
29
|
+
${CMD} requests
|
|
30
|
+
Change requests you accepted, ready to build. --done: the ones already built.
|
|
31
|
+
${CMD} requests done ID
|
|
32
|
+
Marks one as done (deploy --resolves does that too). --note TEXT
|
|
33
|
+
${CMD} test
|
|
34
|
+
Opens the live app once in a browser here, lists what goes wrong and what visitors reported.
|
|
35
|
+
${CMD} server
|
|
36
|
+
The server part of the app: state, usage, secrets, schedules, outside services.
|
|
37
|
+
${CMD} pull APP [folder]
|
|
38
|
+
The files of an app into a folder, to work on it here.
|
|
39
|
+
${CMD} docs
|
|
40
|
+
What an app can use on Scava: data, rules, files, server code.
|
|
41
|
+
${CMD} secret set NAME [--host HOST]
|
|
42
|
+
Where to set a key: the browser, so its value never passes through a terminal an AI can read.
|
|
43
|
+
|
|
44
|
+
Inside a folder you deployed or pulled the app is known. Elsewhere add --app APP.
|
|
45
|
+
|
|
46
|
+
The first command that needs your account opens a browser: sign in to Scava and choose Allow. After that it runs
|
|
47
|
+
without a browser. The access stays on this computer, and whoever works under your user here acts as you.
|
|
48
|
+
|
|
49
|
+
${CMD} whoami
|
|
50
|
+
Which account this computer is signed in as.
|
|
51
|
+
${CMD} login
|
|
52
|
+
Signs in now. --no-browser prints the address, --wait SECONDS, --paste ADDRESS (from a browser on another device).
|
|
53
|
+
${CMD} logout
|
|
54
|
+
Ends the access on this computer.
|
|
55
|
+
|
|
56
|
+
${HAS_DEV ? DEV_HELP : ''}With a one-time link from the Scava tools in your AI (no sign-in here):
|
|
57
|
+
|
|
58
|
+
${CMD} upload <folder> <link> [--no-check]
|
|
59
|
+
${CMD} test <link>
|
|
60
|
+
${CMD} pull <link> [folder]
|
|
61
|
+
|
|
62
|
+
Claude or ChatGPT in the browser have no terminal: add a connector with the address https://scava.ai/mcp.
|
|
63
|
+
All of it: https://scava.ai/docs
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
// Options without a value, and options that may come several times. Everything else takes the next word as its value.
|
|
67
|
+
const FLAGS = new Set(['no-check', 'no-browser', 'done', 'help', 'version']);
|
|
68
|
+
const LISTS = new Set(['remove']);
|
|
69
|
+
|
|
70
|
+
function parse(argv) {
|
|
71
|
+
const pos = [];
|
|
72
|
+
const opts = {};
|
|
73
|
+
for (let i = 0; i < argv.length; i++) {
|
|
74
|
+
const a = argv[i];
|
|
75
|
+
if (!a.startsWith('--')) {
|
|
76
|
+
pos.push(a);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const eq = a.indexOf('=');
|
|
80
|
+
const k = eq === -1 ? a.slice(2) : a.slice(2, eq);
|
|
81
|
+
let v = eq === -1 ? undefined : a.slice(eq + 1);
|
|
82
|
+
if (v === undefined && !FLAGS.has(k) && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) v = argv[++i];
|
|
83
|
+
v ??= true;
|
|
84
|
+
if (LISTS.has(k)) (opts[k] ||= []).push(v);
|
|
85
|
+
else opts[k] = v;
|
|
86
|
+
}
|
|
87
|
+
return { pos, opts };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const text = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
91
|
+
const isLink = (s) => /^https?:\/\//i.test(s ?? '');
|
|
92
|
+
|
|
93
|
+
async function main() {
|
|
94
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
95
|
+
// The small program that waits for the browser (account.js), started by the commands below.
|
|
96
|
+
if (cmd === '__sign-in') {
|
|
97
|
+
await signInHelper(rest[0], rest[1]);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const { pos, opts } = parse(rest);
|
|
101
|
+
// "deploy --help" is a question, not a deploy.
|
|
102
|
+
if (opts.help || rest.includes('-h')) {
|
|
103
|
+
console.log(HELP);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const server = serverUrl(text(opts.server));
|
|
107
|
+
const signInOptions = {
|
|
108
|
+
browser: !opts['no-browser'],
|
|
109
|
+
...(opts.wait !== undefined && Number.isFinite(Number(opts.wait)) ? { waitMs: Math.max(0, Number(opts.wait)) * 1000 } : {}),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Which account a command acts for, once. Under one user on a computer that may not be who you think.
|
|
113
|
+
let named = false;
|
|
114
|
+
const tool = async (name, args) => {
|
|
115
|
+
const r = await callTool(server, name, args, signInOptions);
|
|
116
|
+
if (!named && r.account) console.log(`Account: ${r.account}`);
|
|
117
|
+
named = true;
|
|
118
|
+
return r;
|
|
119
|
+
};
|
|
120
|
+
const appHere = () => {
|
|
121
|
+
const app = text(opts.app) || appOfFolder('.');
|
|
122
|
+
if (!app) throw new ScavaError(`Which app? This folder was not deployed. Add --app APP (${CMD} apps lists them), or run this in the folder of the app.`);
|
|
123
|
+
return app;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
switch (cmd) {
|
|
127
|
+
case 'deploy':
|
|
128
|
+
case 'publish': {
|
|
129
|
+
// Before anything else: is there an app here at all? Nobody signs in for nothing.
|
|
130
|
+
const folder = readFolder(pos[0] || '.');
|
|
131
|
+
const remembered = appOfFolder(folder.root);
|
|
132
|
+
const app = text(opts.app) || remembered || undefined;
|
|
133
|
+
const resolves = text(opts.resolves)?.split(',').map(Number).filter(Number.isInteger);
|
|
134
|
+
let asked;
|
|
135
|
+
try {
|
|
136
|
+
// No path of this computer leaves it: the answer is a one-time link for this upload.
|
|
137
|
+
asked = await tool('publish_app', { path: '.', app, name: text(opts.name), message: text(opts.message), resolves });
|
|
138
|
+
} catch (e) {
|
|
139
|
+
if (remembered && !text(opts.app)) e.message += `\nThis folder remembers the app ${remembered} (in the file .scava-app). If it belongs to another account, delete that file to start a new app.`;
|
|
140
|
+
throw e;
|
|
141
|
+
}
|
|
142
|
+
const r = await uploadFolder(asked.data.link, folder.root, { server, folder });
|
|
143
|
+
for (const line of r.lines) console.log(line);
|
|
144
|
+
if (!opts['no-check'] && r.check_url) {
|
|
145
|
+
console.log('');
|
|
146
|
+
console.log(describeRun(await testRun(r.check_url)));
|
|
147
|
+
}
|
|
148
|
+
console.log('');
|
|
149
|
+
console.log('Next:');
|
|
150
|
+
console.log(` ${CMD} share someone@company.com "@company.com" let people open it`);
|
|
151
|
+
console.log(` ${CMD} requests change requests you accepted, ready to build`);
|
|
152
|
+
console.log(` ${CMD} docs what an app can use on Scava: data, rules, files, server code`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
case 'share': {
|
|
156
|
+
const r = await tool('share_app', { app: appHere(), add: pos, remove: opts.remove?.filter((x) => typeof x === 'string') });
|
|
157
|
+
console.log(r.text);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case 'apps':
|
|
161
|
+
console.log((await tool('list_apps', {})).text);
|
|
162
|
+
return;
|
|
163
|
+
case 'requests': {
|
|
164
|
+
if (pos[0] === 'done') {
|
|
165
|
+
const id = Number(pos[1]);
|
|
166
|
+
if (!Number.isInteger(id)) throw new ScavaError(`Usage: ${CMD} requests done ID [--note TEXT]`);
|
|
167
|
+
console.log((await tool('update_change_request', { id, status: 'done', note: text(opts.note) })).text);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
console.log((await tool('list_change_requests', { app: text(opts.app), status: opts.done ? 'done' : 'accepted' })).text);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
case 'server':
|
|
174
|
+
console.log((await tool('get_server_info', { app: appHere() })).text);
|
|
175
|
+
return;
|
|
176
|
+
case 'test': {
|
|
177
|
+
const link = isLink(pos[0]) ? pos[0] : (await tool('get_app_problems', { app: pos[0] || appHere(), test_run: true })).data.link;
|
|
178
|
+
const r = await testLink(link, { server });
|
|
179
|
+
console.log(describeRun(await testRun(r.url)));
|
|
180
|
+
console.log('');
|
|
181
|
+
console.log(r.problems);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
case 'pull': {
|
|
185
|
+
const [first, folder] = pos;
|
|
186
|
+
if (!first) throw new ScavaError(`Usage: ${CMD} pull APP [folder]. ${CMD} apps lists your apps.`);
|
|
187
|
+
const link = isLink(first) ? first : (await tool('pull_app', { app: first, path: '.' })).data.link;
|
|
188
|
+
const r = await pullLink(link, folder, { server });
|
|
189
|
+
console.log(`Pulled "${r.name}" v${r.version} (${r.fileCount} file${r.fileCount === 1 ? '' : 's'}) into ${r.dir}`);
|
|
190
|
+
console.log(`Change the files there, then run ${CMD} deploy in that folder: it goes to the same app.`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
case 'upload': {
|
|
194
|
+
const [folder, link] = pos;
|
|
195
|
+
if (!folder || !link) throw new ScavaError(`Usage: ${CMD} upload <folder> <link>. Your AI gets the link from publish_app. Without a link: ${CMD} deploy`);
|
|
196
|
+
const r = await uploadFolder(link, folder, { server });
|
|
197
|
+
for (const line of r.lines) console.log(line);
|
|
198
|
+
if (opts['no-check'] || !r.check_url) return;
|
|
199
|
+
console.log('');
|
|
200
|
+
console.log(describeRun(await testRun(r.check_url)));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
case 'login': {
|
|
204
|
+
if (typeof opts.paste === 'string') {
|
|
205
|
+
await finishWithAddress(server, opts.paste);
|
|
206
|
+
const me = await whoAmI(server);
|
|
207
|
+
console.log(`Signed in to Scava as ${me?.account ?? 'your account'}. The access stays on this computer, in ${credentialsPath()}.`);
|
|
208
|
+
console.log(`Next: ${CMD} deploy`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const me = await whoAmI(server);
|
|
212
|
+
if (me) {
|
|
213
|
+
console.log(`Already signed in to Scava as ${me.account ?? 'your account'}. To use another account: ${CMD} logout`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
await signIn(server, { ...signInOptions, reopen: true });
|
|
217
|
+
console.log(`Next: ${CMD} deploy`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
case 'whoami': {
|
|
221
|
+
const me = await whoAmI(server);
|
|
222
|
+
if (!me) {
|
|
223
|
+
console.log(`Not signed in to Scava at ${server}. The next command that needs your account opens the browser.`);
|
|
224
|
+
process.exitCode = 1;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
console.log(`Signed in to Scava as ${me.account ?? 'your account'} (${server}). The access is in ${credentialsPath()}. End it with: ${CMD} logout`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
case 'logout': {
|
|
231
|
+
const r = await logout(server);
|
|
232
|
+
if (!r.was) console.log('This computer was not signed in.');
|
|
233
|
+
else if (r.reached) console.log(`Signed out ${r.account ?? 'your account'} on this computer. The connection at Scava has ended too.`);
|
|
234
|
+
else console.log(`Removed the access of ${r.account ?? 'your account'} from this computer. Scava could not be reached, so disconnect it on the Connect page as well: ${server}/#/connect`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
case 'secret': {
|
|
238
|
+
const [action, name] = pos;
|
|
239
|
+
if (action !== 'set') throw new ScavaError(`Usage: ${CMD} secret set NAME [--host api.example.com] [--app APP]`);
|
|
240
|
+
const slug = text(opts.app) || appOfFolder('.');
|
|
241
|
+
const page = `${server}/#/${slug ? `apps/${encodeURIComponent(slug)}` : ''}`;
|
|
242
|
+
console.log('Keys are set in the browser, so the value never passes through a terminal an AI can read.');
|
|
243
|
+
console.log(`Open ${page} and go to "Server code"${slug ? '' : ' of your app'}.`);
|
|
244
|
+
if (name) console.log(`Name: ${name}${text(opts.host) ? `, only for: ${opts.host}` : ''}`);
|
|
245
|
+
if (!opts['no-browser']) openBrowser(page);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
case 'dev': {
|
|
249
|
+
let dev;
|
|
250
|
+
try {
|
|
251
|
+
({ dev } = await import('./dev.js'));
|
|
252
|
+
} catch (e) {
|
|
253
|
+
if (e?.code !== 'ERR_MODULE_NOT_FOUND') throw e;
|
|
254
|
+
throw new ScavaError('scava dev is not part of the scava package. Test the app online instead: deploying runs a test run.');
|
|
255
|
+
}
|
|
256
|
+
await dev({ dir: pos[0] || '.', port: Number(opts.port) || 8788, browser: !opts['no-browser'], email: text(opts.email) });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
case 'docs': {
|
|
260
|
+
const r = await fetch(`${server}/llms.txt`).catch(() => null);
|
|
261
|
+
if (!r?.ok) throw new ScavaError(`Cannot reach Scava at ${server}`);
|
|
262
|
+
console.log(await r.text());
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
case undefined:
|
|
266
|
+
case 'help':
|
|
267
|
+
case '--help':
|
|
268
|
+
case '-h':
|
|
269
|
+
console.log(HELP);
|
|
270
|
+
return;
|
|
271
|
+
case '--version':
|
|
272
|
+
case '-v':
|
|
273
|
+
console.log(JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
|
|
274
|
+
return;
|
|
275
|
+
default:
|
|
276
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
277
|
+
console.log(HELP);
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
main().catch((e) => {
|
|
283
|
+
console.error(e instanceof ScavaError ? formatProblems(e) : e.message);
|
|
284
|
+
process.exitCode = 1;
|
|
285
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scava-ai",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Hosting, login and a database for the internal tools your AI agent builds, with sharing and change requests. One command: npx -y scava-ai deploy.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"scava": "cli/scava.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cli/",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://scava.ai",
|
|
17
|
+
"license": "UNLICENSED",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mcp",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"codex",
|
|
22
|
+
"cursor",
|
|
23
|
+
"agents",
|
|
24
|
+
"hosting",
|
|
25
|
+
"deploy",
|
|
26
|
+
"login",
|
|
27
|
+
"authentication",
|
|
28
|
+
"database",
|
|
29
|
+
"permissions",
|
|
30
|
+
"internal-tools",
|
|
31
|
+
"vibe-coding",
|
|
32
|
+
"sharing"
|
|
33
|
+
]
|
|
34
|
+
}
|