scava-ai 0.1.0 → 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 +3 -3
- package/cli/account.js +507 -507
- package/cli/check.js +1 -1
- package/cli/lib.js +252 -195
- package/cli/scava.js +287 -285
- package/package.json +2 -2
package/cli/check.js
CHANGED
|
@@ -39,7 +39,7 @@ const HINTS = {
|
|
|
39
39
|
blockedOther: 'Scava blocked this. Keep everything the app needs inside its own folder.',
|
|
40
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
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
|
|
42
|
+
empty: 'The page shows nothing after loading. Usually a script failed, or the page loads files that are not in the uploaded folder: publish the output folder of the build (for example dist) as it is.',
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
function blockedHint(directive) {
|
package/cli/lib.js
CHANGED
|
@@ -1,195 +1,252 @@
|
|
|
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}
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
let
|
|
132
|
-
try {
|
|
133
|
-
|
|
134
|
-
} catch {
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
} catch {
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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} setup ${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. Build tools empty their output folder on every
|
|
107
|
+
// build (Vite everything but .git), so a folder inside a project, such as its dist folder, is remembered in the project
|
|
108
|
+
// folder: its .scava-app holds the folder's path and the app (07.11.2026: a rebuilt dist made a second app). A folder
|
|
109
|
+
// that is a project itself, or lies in none, keeps the plain file in itself, as before.
|
|
110
|
+
const PROJECT_MARKS = ['package.json', '.git'];
|
|
111
|
+
const isProject = (dir) => PROJECT_MARKS.some((m) => fs.existsSync(path.join(dir, m)));
|
|
112
|
+
const samePath = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
113
|
+
|
|
114
|
+
// The nearest project folder above `root`, at most three levels up and below the home folder; null when `root` is a
|
|
115
|
+
// project itself or there is none.
|
|
116
|
+
function projectAbove(root) {
|
|
117
|
+
if (isProject(root)) return null;
|
|
118
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
119
|
+
let dir = root;
|
|
120
|
+
for (let i = 0; i < 3; i++) {
|
|
121
|
+
const up = path.dirname(dir);
|
|
122
|
+
if (up === dir || (home && samePath(up, path.resolve(home)))) return null;
|
|
123
|
+
if (isProject(up)) return up;
|
|
124
|
+
dir = up;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// What a .scava-app holds: { '.': app } for the folder it lies in, the paths of folders below it for theirs.
|
|
130
|
+
function readLinks(file) {
|
|
131
|
+
let text;
|
|
132
|
+
try {
|
|
133
|
+
text = fs.readFileSync(file, 'utf8').trim();
|
|
134
|
+
} catch {
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
if (!text.startsWith('{')) return text ? { '.': text } : {};
|
|
138
|
+
try {
|
|
139
|
+
const links = JSON.parse(text);
|
|
140
|
+
return links && typeof links === 'object' && !Array.isArray(links) ? links : {};
|
|
141
|
+
} catch {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const keyIn = (project, root) => path.relative(project, root).split(path.sep).join('/');
|
|
147
|
+
|
|
148
|
+
export function appOfFolder(dir = '.') {
|
|
149
|
+
const root = path.resolve(dir);
|
|
150
|
+
const own = readLinks(path.join(root, '.scava-app'))['.'];
|
|
151
|
+
if (typeof own === 'string' && own) return own;
|
|
152
|
+
const project = projectAbove(root);
|
|
153
|
+
const app = project ? readLinks(path.join(project, '.scava-app'))[keyIn(project, root)] : '';
|
|
154
|
+
return typeof app === 'string' ? app : '';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Remembers the app of a folder and returns the file it is in: the project's for a folder inside a project, else the
|
|
158
|
+
// folder's own. Other entries of that file stay.
|
|
159
|
+
export function rememberApp(dir, slug) {
|
|
160
|
+
const root = path.resolve(dir);
|
|
161
|
+
const project = projectAbove(root);
|
|
162
|
+
const file = path.join(project ?? root, '.scava-app');
|
|
163
|
+
const links = readLinks(file);
|
|
164
|
+
links[project ? keyIn(project, root) : '.'] = slug;
|
|
165
|
+
const keys = Object.keys(links);
|
|
166
|
+
fs.writeFileSync(file, keys.length === 1 && keys[0] === '.' ? `${slug}\n` : `${JSON.stringify(links, null, 2)}\n`);
|
|
167
|
+
return file;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- One-time links -------------------------------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
// A link an agent passes on. Files only ever go to Scava itself, or to this computer when someone runs Scava locally:
|
|
173
|
+
// a text the agent read cannot talk it into sending a folder somewhere else with this command.
|
|
174
|
+
export function parseLink(raw, server) {
|
|
175
|
+
let u;
|
|
176
|
+
try {
|
|
177
|
+
u = new URL(String(raw));
|
|
178
|
+
} catch {
|
|
179
|
+
throw new ScavaError('That is not a Scava link. Ask your AI for a new one.');
|
|
180
|
+
}
|
|
181
|
+
const own = new URL(serverUrl(server)).origin;
|
|
182
|
+
const here = u.protocol === 'http:' && LOOPBACK.has(u.hostname);
|
|
183
|
+
if (u.origin !== own && !here) throw new ScavaError(`This link does not lead to Scava (${own}), so nothing was sent.`);
|
|
184
|
+
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.');
|
|
185
|
+
return u;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function useLink(u, body) {
|
|
189
|
+
let r;
|
|
190
|
+
try {
|
|
191
|
+
r = await fetch(u, { method: 'POST', redirect: 'error', headers: { 'content-type': 'application/json', 'x-scava': '1' }, body: JSON.stringify(body ?? {}) });
|
|
192
|
+
} catch {
|
|
193
|
+
throw new ScavaError(`Cannot reach Scava at ${u.origin}.`);
|
|
194
|
+
}
|
|
195
|
+
const text = await r.text();
|
|
196
|
+
let data = null;
|
|
197
|
+
try {
|
|
198
|
+
data = text ? JSON.parse(text) : null;
|
|
199
|
+
} catch {
|
|
200
|
+
data = { error: text.slice(0, 200) };
|
|
201
|
+
}
|
|
202
|
+
if (!r.ok) throw new ScavaError(data?.error || `${r.status} ${r.statusText}`, data);
|
|
203
|
+
return data;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Uploads a folder as a new version. The folder remembers its app in .scava-app, for the next time. `folder`: when the
|
|
207
|
+
// caller has read it already. The link is checked first: nothing is read for a link that leads somewhere else.
|
|
208
|
+
export async function uploadFolder(link, dir, { server, folder } = {}) {
|
|
209
|
+
const u = parseLink(link, server);
|
|
210
|
+
const { root, files } = folder ?? readFolder(dir);
|
|
211
|
+
const r = await useLink(u, { files, app: appOfFolder(root) || undefined, name: guessName(root) });
|
|
212
|
+
try {
|
|
213
|
+
rememberApp(root, r.slug);
|
|
214
|
+
} catch { /* read-only folder: the app's name is printed anyway */ }
|
|
215
|
+
return r;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// What a test run needs: a link that opens the live app once, and what browsers reported so far.
|
|
219
|
+
export const testLink = (link, { server } = {}) => useLink(parseLink(link, server));
|
|
220
|
+
|
|
221
|
+
// The files of an app's live version into a folder. The folder is new or empty, or already belongs to the same app
|
|
222
|
+
// (then the files of the version overwrite what is there; other files stay). It remembers the app.
|
|
223
|
+
export async function pullLink(link, dir, { server } = {}) {
|
|
224
|
+
const src = await useLink(parseLink(link, server));
|
|
225
|
+
const root = path.resolve(dir || src.slug);
|
|
226
|
+
if (fs.existsSync(root)) {
|
|
227
|
+
if (!fs.statSync(root).isDirectory()) throw new ScavaError(`${root} is a file, not a folder.`);
|
|
228
|
+
const mine = appOfFolder(root) === src.slug;
|
|
229
|
+
if (!mine && fs.readdirSync(root).length) {
|
|
230
|
+
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}.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const files = src.files.map((f) => {
|
|
234
|
+
const parts = safeRelative(f.path);
|
|
235
|
+
if (!parts) throw new ScavaError(`Scava sent a file with a path this computer will not write: ${String(f.path).slice(0, 120)}`);
|
|
236
|
+
return { target: path.join(root, ...parts), bytes: Buffer.from(f.data, 'base64') };
|
|
237
|
+
});
|
|
238
|
+
for (const f of files) {
|
|
239
|
+
fs.mkdirSync(path.dirname(f.target), { recursive: true });
|
|
240
|
+
fs.writeFileSync(f.target, f.bytes);
|
|
241
|
+
}
|
|
242
|
+
rememberApp(root, src.slug);
|
|
243
|
+
return { slug: src.slug, name: src.name, version: src.version, dir: root, fileCount: files.length };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Why something was refused, each problem with its file and line.
|
|
247
|
+
export function formatProblems(err) {
|
|
248
|
+
const lines = [err.message];
|
|
249
|
+
for (const p of err.data?.problems || []) lines.push(` x ${p.file ? `${p.file}${p.line ? `:${p.line}` : ''}: ` : ''}${p.message}`);
|
|
250
|
+
for (const w of err.data?.warnings || []) lines.push(` ! ${w.file ? `${w.file}${w.line ? `:${w.line}` : ''}: ` : ''}${w.message}`);
|
|
251
|
+
return lines.join('\n');
|
|
252
|
+
}
|