@scrapeatlas/cli 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/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@scrapeatlas/cli",
3
+ "version": "0.1.0",
4
+ "description": "Official ScrapeAtlas CLI — discover and call social data API endpoints from your terminal.",
5
+ "type": "module",
6
+ "bin": {
7
+ "scrapeatlas": "bin/scrapeatlas.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "catalog.json",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "license": "UNLICENSED",
19
+ "homepage": "https://scrapeatlas.com/docs/",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/OnlineDopamine/social-scraper.git",
23
+ "directory": "packages/cli"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "registry": "https://registry.npmjs.org/"
28
+ },
29
+ "keywords": [
30
+ "scrapeatlas",
31
+ "cli",
32
+ "social-media",
33
+ "scraping",
34
+ "api"
35
+ ]
36
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,320 @@
1
+ import { lstat, readFile, writeFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { readConfig, saveConfig, secretInput } from './config.mjs';
5
+ import { CliError, origin, request } from './http.mjs';
6
+ import { convert, kebab, valid } from './schema.mjs';
7
+
8
+ const catalog = JSON.parse(await readFile(new URL('../catalog.json', import.meta.url), 'utf8'));
9
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
10
+ const globalOptions = {
11
+ help: { type: 'boolean', short: 'h' },
12
+ version: { type: 'boolean', short: 'v' },
13
+ pretty: { type: 'boolean' },
14
+ json: { type: 'boolean' },
15
+ envelope: { type: 'boolean' },
16
+ output: { type: 'string' },
17
+ 'api-key': { type: 'string' },
18
+ 'base-url': { type: 'string' },
19
+ timeout: { type: 'string' },
20
+ input: { type: 'string' },
21
+ stdin: { type: 'boolean' },
22
+ };
23
+ const generalHelp = `ScrapeAtlas CLI ${pkg.version}
24
+
25
+ Usage: scrapeatlas <platform> <action> [--parameters]
26
+ scrapeatlas call <operationId> --input '{"parameter":"value"}'
27
+ scrapeatlas list [platform] [--json]
28
+ scrapeatlas auth login [--stdin]
29
+ scrapeatlas auth status | auth logout
30
+
31
+ Explore: scrapeatlas bluesky profile --help
32
+ Global options:
33
+ --api-key <key> Override SCRAPEATLAS_API_KEY / stored login
34
+ --base-url <origin> Override SCRAPEATLAS_BASE_URL / stored origin
35
+ --input <json> Request object; @file reads JSON from a file
36
+ --pretty Indented JSON (default: compact JSON)
37
+ --envelope Include httpStatus, requestId and retryAfter around body
38
+ --output <file> Save JSON to a new file (never overwrite)
39
+ --timeout <ms> Total deadline, 1..120000 ms (default: 90000)
40
+ --help, -h Show help for a command
41
+ --version, -v Print version
42
+
43
+ One request per command. Pagination is explicit. Inspect status and coverage.
44
+ Data goes to stdout; diagnostics go to stderr. Exit: 0 usable, 1 API/network, 2 usage.
45
+ Get a customer API key from https://scrapeatlas.com/dashboard/keys/.
46
+ `;
47
+ function parse(args, operation) {
48
+ const options = { ...globalOptions };
49
+ const names = new Map();
50
+ for (const [name, schema] of Object.entries(operation?.inputSchema.properties ?? {})) {
51
+ for (const flag of new Set([name, kebab(name)])) {
52
+ if (Object.hasOwn(globalOptions, flag) || (names.has(flag) && names.get(flag) !== name))
53
+ continue;
54
+ names.set(flag, name);
55
+ options[flag] = { type: schema.type === 'boolean' ? 'boolean' : 'string' };
56
+ if (schema.type === 'boolean') {
57
+ options[`no-${flag}`] = { type: 'boolean' };
58
+ names.set(`no-${flag}`, name);
59
+ }
60
+ }
61
+ }
62
+ let parsed;
63
+ try {
64
+ parsed = parseArgs({ args, options, allowPositionals: true, tokens: true });
65
+ } catch {
66
+ throw new CliError(
67
+ 'invalid_arguments',
68
+ 'Unknown option or missing value. Use --help; use --input for JSON and boolean values.'
69
+ );
70
+ }
71
+ const seen = new Set();
72
+ for (const token of parsed.tokens.filter((item) => item.kind === 'option')) {
73
+ const key = names.get(token.name) ?? token.name;
74
+ if (seen.has(key)) throw new CliError('duplicate_option', 'Duplicate options are not allowed.');
75
+ seen.add(key);
76
+ }
77
+ return { ...parsed, names };
78
+ }
79
+ function help(operation) {
80
+ return (
81
+ `scrapeatlas ${operation.platform} ${operation.action}\n${operation.method} ${operation.path}\n\n${operation.description}\n\nParameters:\n` +
82
+ Object.entries(operation.inputSchema.properties)
83
+ .map(
84
+ ([name, schema]) =>
85
+ ` --${kebab(name)} <${schema.type ?? 'json'}>${operation.inputSchema.required.includes(name) ? ' (required)' : ''}${schema.enum ? ` [${schema.enum.join(', ')}]` : ''}\n ${schema.description ?? ''}`
86
+ )
87
+ .join('\n') +
88
+ '\n\nCamelCase and snake_case API parameter names also work. Use --input for nested objects, arrays, or explicit booleans. Runtime refinements are validated by the gateway.\n\n' +
89
+ generalHelp
90
+ );
91
+ }
92
+ async function inputObject(value) {
93
+ if (!value) return {};
94
+ let text = value;
95
+ if (value.startsWith('@')) {
96
+ const handle = await import('node:fs/promises').then((fs) => fs.open(value.slice(1), 'r'));
97
+ try {
98
+ if ((await handle.stat()).size > 65536)
99
+ throw new CliError('input_too_large', 'Request input exceeds 64 KiB.');
100
+ const buffer = Buffer.alloc(65537);
101
+ let size = 0;
102
+ while (size < buffer.length) {
103
+ const { bytesRead } = await handle.read(buffer, size, buffer.length - size, null);
104
+ if (!bytesRead) break;
105
+ size += bytesRead;
106
+ }
107
+ if (size > 65536) throw new CliError('input_too_large', 'Request input exceeds 64 KiB.');
108
+ text = buffer.subarray(0, size).toString('utf8');
109
+ } finally {
110
+ await handle.close();
111
+ }
112
+ }
113
+ if (Buffer.byteLength(text) > 65536)
114
+ throw new CliError('input_too_large', 'Request input exceeds 64 KiB.');
115
+ try {
116
+ const value = JSON.parse(text);
117
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error();
118
+ return value;
119
+ } catch {
120
+ throw new CliError('invalid_input', '--input must contain a JSON object.');
121
+ }
122
+ }
123
+ function apiKey(value) {
124
+ if (typeof value !== 'string' || !value.trim())
125
+ throw new CliError('missing_key', 'Run scrapeatlas auth login or set SCRAPEATLAS_API_KEY.');
126
+ if (!/^[\x21-\x7e]{1,8192}$/.test(value))
127
+ throw new CliError('invalid_key', 'API key must be a nonempty token without whitespace.');
128
+ return value;
129
+ }
130
+ async function emit(value, options) {
131
+ const text = JSON.stringify(value, null, options.pretty ? 2 : undefined) + '\n';
132
+ if (options.output) {
133
+ await writeFile(resolve(options.output), text, { flag: 'wx', mode: 0o600 });
134
+ process.stdout.write(resolve(options.output) + '\n');
135
+ } else process.stdout.write(text);
136
+ }
137
+ export async function run(args) {
138
+ try {
139
+ // Identify positionals with a permissive first pass; strict parsing follows with endpoint options.
140
+ const rough = parseArgs({
141
+ args,
142
+ strict: false,
143
+ allowPositionals: true,
144
+ options: globalOptions,
145
+ });
146
+ const words = rough.positionals;
147
+ const operation =
148
+ words[0] === 'call'
149
+ ? catalog.find((entry) => entry.operation === words[1])
150
+ : catalog.find((entry) => entry.platform === words[0] && entry.action === words[1]);
151
+ const { values: options, positionals, names } = parse(args, operation);
152
+ if (options.output) {
153
+ try {
154
+ await lstat(resolve(options.output));
155
+ throw new CliError('output_exists', 'Output file already exists; choose a new path.');
156
+ } catch (error) {
157
+ if (error.code !== 'ENOENT') throw error;
158
+ }
159
+ }
160
+ if (options.version) {
161
+ process.stdout.write(pkg.version + '\n');
162
+ return 0;
163
+ }
164
+ if (options.help || !positionals.length) {
165
+ process.stdout.write(operation ? help(operation) : generalHelp);
166
+ return 0;
167
+ }
168
+ if (positionals[0] === 'list') {
169
+ if (positionals.length > 2)
170
+ throw new CliError('invalid_arguments', 'Usage: scrapeatlas list [platform]');
171
+ const entries = catalog.filter(
172
+ (entry) => !positionals[1] || entry.platform === positionals[1]
173
+ );
174
+ if (!entries.length)
175
+ throw new CliError('unknown_platform', 'Unknown platform. Run scrapeatlas list.');
176
+ if (options.json || options.output || options.pretty) await emit(entries, options);
177
+ else
178
+ process.stdout.write(
179
+ positionals[1]
180
+ ? entries
181
+ .map((entry) => `${entry.platform} ${entry.action} ${entry.method} ${entry.path}`)
182
+ .join('\n') + '\n'
183
+ : [...new Set(entries.map((entry) => entry.platform))]
184
+ .map(
185
+ (platform) =>
186
+ `${platform} (${entries.filter((entry) => entry.platform === platform).length} commands)`
187
+ )
188
+ .join('\n') + '\n'
189
+ );
190
+ return 0;
191
+ }
192
+ if (!operation && positionals[0] !== 'auth')
193
+ throw new CliError('unknown_command', 'Unknown command. Run scrapeatlas list or --help.');
194
+ if (positionals.length !== 2)
195
+ throw new CliError(
196
+ 'invalid_arguments',
197
+ 'Expected exactly a platform and action, or call and operation ID.'
198
+ );
199
+ const config = await readConfig();
200
+ const base = origin(
201
+ options['base-url'] ||
202
+ process.env.SCRAPEATLAS_BASE_URL ||
203
+ config.baseUrl ||
204
+ 'https://api.scrapeatlas.com'
205
+ );
206
+ const timeout = Number(options.timeout ?? 90000);
207
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 120000)
208
+ throw new CliError('invalid_timeout', 'Timeout must be 1..120000 milliseconds.');
209
+ const explicitKey = options['api-key'] || process.env.SCRAPEATLAS_API_KEY;
210
+ // Saved credentials belong to the origin at which they were verified.
211
+ const savedKey =
212
+ (config.baseUrl || 'https://api.scrapeatlas.com') === base ? config.apiKey : undefined;
213
+ if (positionals[0] === 'auth') {
214
+ if (positionals[1] === 'logout') {
215
+ await saveConfig({});
216
+ await emit(
217
+ {
218
+ authenticated: false,
219
+ storedKeyRemoved: true,
220
+ environmentKeyPresent: Boolean(process.env.SCRAPEATLAS_API_KEY),
221
+ },
222
+ options
223
+ );
224
+ return 0;
225
+ }
226
+ if (positionals[1] === 'status') {
227
+ await emit(
228
+ {
229
+ authenticated: Boolean(explicitKey || savedKey),
230
+ source: options['api-key']
231
+ ? 'flag'
232
+ : process.env.SCRAPEATLAS_API_KEY
233
+ ? 'environment'
234
+ : savedKey
235
+ ? 'config'
236
+ : null,
237
+ baseUrl: base,
238
+ verified: false,
239
+ },
240
+ options
241
+ );
242
+ return 0;
243
+ }
244
+ if (positionals[1] !== 'login')
245
+ throw new CliError('unknown_command', 'Use auth login, auth status, or auth logout.');
246
+ const key = apiKey(
247
+ options.stdin ? await secretInput(true) : explicitKey || (await secretInput(false))
248
+ );
249
+ const result = await request(
250
+ base,
251
+ key,
252
+ { method: 'GET', path: '/v1/auth/verify' },
253
+ {},
254
+ Math.min(timeout, 10000)
255
+ );
256
+ if (result.status !== 200 || result.body?.valid !== true)
257
+ throw new CliError(
258
+ 'authentication_failed',
259
+ 'Gateway did not verify the key; nothing was saved.',
260
+ 1
261
+ );
262
+ await saveConfig({ apiKey: key, baseUrl: base });
263
+ await emit({ authenticated: true, verified: true, baseUrl: base }, options);
264
+ return 0;
265
+ }
266
+ const input = await inputObject(options.input);
267
+ for (const [flag, name] of names)
268
+ if (options[flag] !== undefined) {
269
+ if (Object.hasOwn(input, name))
270
+ throw new CliError('duplicate_parameter', 'A parameter was supplied more than once.');
271
+ input[name] = flag.startsWith('no-')
272
+ ? false
273
+ : convert(operation.inputSchema.properties[name], String(options[flag]));
274
+ }
275
+ if (!valid(operation.inputSchema, input))
276
+ throw new CliError(
277
+ 'invalid_parameters',
278
+ 'Parameters do not match this endpoint. Use --help or list <platform> --json for its schema.'
279
+ );
280
+ if (Buffer.byteLength(JSON.stringify(input)) > 65536)
281
+ throw new CliError('input_too_large', 'Request input exceeds 64 KiB.');
282
+ const result = await request(base, apiKey(explicitKey || savedKey), operation, input, timeout);
283
+ const failed =
284
+ result.status >= 400 ||
285
+ result.body?.success === false ||
286
+ ['failed', 'challenged'].includes(result.body?.status);
287
+ await emit(
288
+ options.envelope
289
+ ? {
290
+ httpStatus: result.status,
291
+ body: result.body,
292
+ ...(result.requestId ? { requestId: result.requestId } : {}),
293
+ ...(result.retryAfter ? { retryAfter: result.retryAfter } : {}),
294
+ }
295
+ : result.body,
296
+ options
297
+ );
298
+ if (failed)
299
+ process.stderr.write(
300
+ JSON.stringify({
301
+ error: true,
302
+ code: `HTTP_${result.status}`,
303
+ message: 'Gateway returned a failure; inspect the response body.',
304
+ }) + '\n'
305
+ );
306
+ return failed ? 1 : 0;
307
+ } catch (error) {
308
+ const known = error instanceof CliError;
309
+ process.stderr.write(
310
+ JSON.stringify({
311
+ error: true,
312
+ code: known ? error.code : 'local_error',
313
+ message: known
314
+ ? error.message
315
+ : 'CLI operation failed. Check file permissions and configuration; output files must not already exist.',
316
+ }) + '\n'
317
+ );
318
+ return known ? error.exitCode : 2;
319
+ }
320
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,91 @@
1
+ import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { CliError } from './http.mjs';
6
+
7
+ export function configDirectory() {
8
+ return resolve(
9
+ process.env.SCRAPEATLAS_CONFIG_DIR ||
10
+ join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'scrapeatlas')
11
+ );
12
+ }
13
+ export async function readConfig() {
14
+ try {
15
+ const value = JSON.parse(await readFile(join(configDirectory(), 'config.json'), 'utf8'));
16
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error();
17
+ return value;
18
+ } catch (error) {
19
+ if (error.code === 'ENOENT') return {};
20
+ throw new CliError(
21
+ 'config_error',
22
+ 'Cannot read CLI config. Repair or remove config.json in the ScrapeAtlas config directory.'
23
+ );
24
+ }
25
+ }
26
+ export async function saveConfig(config) {
27
+ const directory = configDirectory();
28
+ await mkdir(directory, { recursive: true, mode: 0o700 });
29
+ if (!(await lstat(directory)).isDirectory())
30
+ throw new CliError('config_error', 'Config directory must be a real directory.');
31
+ await chmod(directory, 0o700);
32
+ const temporary = join(directory, `.config-${randomUUID()}.tmp`);
33
+ try {
34
+ await writeFile(temporary, JSON.stringify(config) + '\n', { mode: 0o600, flag: 'wx' });
35
+ await rename(temporary, join(directory, 'config.json'));
36
+ } finally {
37
+ await rm(temporary, { force: true });
38
+ }
39
+ }
40
+ export async function secretInput(fromStdin) {
41
+ if (fromStdin) {
42
+ const chunks = [];
43
+ let size = 0;
44
+ for await (const chunk of process.stdin) {
45
+ size += chunk.length;
46
+ if (size > 8192) throw new CliError('invalid_key', 'API key input is too large.');
47
+ chunks.push(chunk);
48
+ }
49
+ return Buffer.concat(chunks).toString('utf8').trim();
50
+ }
51
+ if (!process.stdin.isTTY)
52
+ throw new CliError('missing_key', 'Use SCRAPEATLAS_API_KEY or auth login --stdin.');
53
+ process.stderr.write('ScrapeAtlas API key (hidden): ');
54
+ const previous = process.stdin.isRaw;
55
+ process.stdin.setRawMode(true);
56
+ process.stdin.resume();
57
+ try {
58
+ return await new Promise((resolve, reject) => {
59
+ let value = '';
60
+ function onData(chunk) {
61
+ for (const character of chunk.toString('utf8')) {
62
+ if (character === '\r' || character === '\n') {
63
+ cleanup();
64
+ resolve(value.trim());
65
+ return;
66
+ }
67
+ if (character === '\u0003' || character === '\u0004') {
68
+ cleanup();
69
+ reject(new CliError('cancelled', 'Login cancelled.', 130));
70
+ return;
71
+ }
72
+ if (character === '\u007f' || character === '\b') value = value.slice(0, -1);
73
+ else if (character >= ' ') value += character;
74
+ if (value.length > 8192) {
75
+ cleanup();
76
+ reject(new CliError('invalid_key', 'API key input is too large.'));
77
+ return;
78
+ }
79
+ }
80
+ }
81
+ function cleanup() {
82
+ process.stdin.off('data', onData);
83
+ }
84
+ process.stdin.on('data', onData);
85
+ });
86
+ } finally {
87
+ process.stdin.setRawMode(previous);
88
+ process.stdin.pause();
89
+ process.stderr.write('\n');
90
+ }
91
+ }
package/src/http.mjs ADDED
@@ -0,0 +1,104 @@
1
+ export class CliError extends Error {
2
+ constructor(code, message, exitCode = 2) {
3
+ super(message);
4
+ this.code = code;
5
+ this.exitCode = exitCode;
6
+ }
7
+ }
8
+ export function origin(value) {
9
+ let url;
10
+ try {
11
+ url = new URL(value);
12
+ } catch {
13
+ throw new CliError(
14
+ 'invalid_origin',
15
+ 'Base URL must be an HTTPS origin (or HTTP loopback for development).'
16
+ );
17
+ }
18
+ if (
19
+ url.username ||
20
+ url.password ||
21
+ url.pathname !== '/' ||
22
+ url.search ||
23
+ url.hash ||
24
+ !(
25
+ url.protocol === 'https:' ||
26
+ (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
27
+ )
28
+ )
29
+ throw new CliError(
30
+ 'invalid_origin',
31
+ 'Base URL must be an HTTPS origin (or HTTP loopback for development).'
32
+ );
33
+ return url.origin;
34
+ }
35
+ export async function request(base, key, operation, input, timeout) {
36
+ const url = new URL(operation.path, base);
37
+ if (operation.method === 'GET')
38
+ for (const [name, value] of Object.entries(input)) {
39
+ url.searchParams.set(name, typeof value === 'object' ? JSON.stringify(value) : String(value));
40
+ }
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(), timeout);
43
+ let reader;
44
+ try {
45
+ const response = await fetch(url, {
46
+ method: operation.method,
47
+ redirect: 'manual',
48
+ credentials: 'omit',
49
+ signal: controller.signal,
50
+ headers: {
51
+ authorization: `Bearer ${key}`,
52
+ accept: 'application/json',
53
+ 'user-agent': 'scrapeatlas-cli/0.1.0',
54
+ ...(operation.method === 'POST' ? { 'content-type': 'application/json' } : {}),
55
+ },
56
+ ...(operation.method === 'POST' ? { body: JSON.stringify(input) } : {}),
57
+ });
58
+ if (response.status >= 300 && response.status < 400) {
59
+ await response.body?.cancel();
60
+ throw new CliError('redirect_refused', 'Gateway redirect refused; verify your base URL.', 1);
61
+ }
62
+ if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '')) {
63
+ await response.body?.cancel();
64
+ throw new CliError('invalid_response', 'Gateway did not return JSON.', 1);
65
+ }
66
+ reader = response.body?.getReader();
67
+ let size = 0;
68
+ const chunks = [];
69
+ while (reader) {
70
+ const chunk = await reader.read();
71
+ if (chunk.done) break;
72
+ size += chunk.value.byteLength;
73
+ if (size > 16 * 1024 * 1024)
74
+ throw new CliError(
75
+ 'response_too_large',
76
+ 'Response exceeds 16 MiB; request a smaller page.',
77
+ 1
78
+ );
79
+ chunks.push(chunk.value);
80
+ }
81
+ let body;
82
+ try {
83
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
84
+ } catch {
85
+ throw new CliError('invalid_response', 'Gateway returned invalid JSON.', 1);
86
+ }
87
+ return {
88
+ body,
89
+ status: response.status,
90
+ requestId: response.headers.get('x-request-id'),
91
+ retryAfter: response.headers.get('retry-after'),
92
+ };
93
+ } catch (error) {
94
+ if (error instanceof CliError) throw error;
95
+ throw new CliError(
96
+ controller.signal.aborted ? 'timeout' : 'network_error',
97
+ controller.signal.aborted ? 'Request deadline exceeded.' : 'Gateway request failed.',
98
+ 1
99
+ );
100
+ } finally {
101
+ clearTimeout(timer);
102
+ await reader?.cancel().catch(() => {});
103
+ }
104
+ }
package/src/schema.mjs ADDED
@@ -0,0 +1,66 @@
1
+ export const kebab = (value) =>
2
+ value
3
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
4
+ .replaceAll('_', '-')
5
+ .toLowerCase();
6
+ export function valid(schema, value) {
7
+ if (schema.anyOf) return schema.anyOf.some((option) => valid(option, value));
8
+ if ('const' in schema && value !== schema.const) return false;
9
+ if (schema.enum && !schema.enum.includes(value)) return false;
10
+ switch (schema.type) {
11
+ case 'null':
12
+ return value === null;
13
+ case 'string':
14
+ return (
15
+ typeof value === 'string' &&
16
+ (schema.minLength === undefined || value.length >= schema.minLength) &&
17
+ (schema.maxLength === undefined || value.length <= schema.maxLength) &&
18
+ (!schema.pattern || new RegExp(schema.pattern).test(value))
19
+ );
20
+ case 'number':
21
+ case 'integer':
22
+ return (
23
+ typeof value === 'number' &&
24
+ Number.isFinite(value) &&
25
+ (schema.type !== 'integer' || Number.isInteger(value)) &&
26
+ (schema.minimum === undefined || value >= schema.minimum) &&
27
+ (schema.maximum === undefined || value <= schema.maximum)
28
+ );
29
+ case 'boolean':
30
+ return typeof value === 'boolean';
31
+ case 'array':
32
+ return (
33
+ Array.isArray(value) &&
34
+ (schema.minItems === undefined || value.length >= schema.minItems) &&
35
+ (schema.maxItems === undefined || value.length <= schema.maxItems) &&
36
+ value.every((item) => valid(schema.items, item))
37
+ );
38
+ case 'object':
39
+ return (
40
+ value !== null &&
41
+ typeof value === 'object' &&
42
+ !Array.isArray(value) &&
43
+ (schema.required ?? []).every((key) => Object.hasOwn(value, key)) &&
44
+ Object.entries(value).every(([key, item]) => {
45
+ if (schema.propertyNames && !valid(schema.propertyNames, key)) return false;
46
+ if (Object.hasOwn(schema.properties ?? {}, key))
47
+ return valid(schema.properties[key], item);
48
+ return (
49
+ schema.additionalProperties !== false &&
50
+ (typeof schema.additionalProperties !== 'object' ||
51
+ valid(schema.additionalProperties, item))
52
+ );
53
+ })
54
+ );
55
+ default:
56
+ return true;
57
+ }
58
+ }
59
+ export function convert(schema, raw) {
60
+ if (schema.type === 'string') return raw;
61
+ try {
62
+ return JSON.parse(raw);
63
+ } catch {
64
+ return raw;
65
+ }
66
+ }