@nurix/apollo 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 +114 -0
- package/dist/commands/add.js +152 -0
- package/dist/commands/describe.js +36 -0
- package/dist/commands/doctor.js +130 -0
- package/dist/commands/graph.js +36 -0
- package/dist/commands/init.js +73 -0
- package/dist/commands/list.js +18 -0
- package/dist/commands/open.js +37 -0
- package/dist/commands/sync.js +57 -0
- package/dist/commands/validate.js +40 -0
- package/dist/index.js +59 -0
- package/dist/lib/apollo_client.js +56 -0
- package/dist/lib/env_file.js +72 -0
- package/dist/lib/interactive.js +18 -0
- package/dist/lib/manifest.js +54 -0
- package/dist/lib/package_manager.js +24 -0
- package/dist/lib/schema_validator.js +32 -0
- package/dist/spec_schema.json +346 -0
- package/package.json +41 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// packages/cli/src/commands/validate.ts - `apollo validate [manifest]`: JSON
|
|
2
|
+
// Schema validation of an apollo.service.json against the apollo.dev/v1 grammar.
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import * as p from "@clack/prompts";
|
|
5
|
+
import { loadSchema, validateManifest } from "../lib/schema_validator.js";
|
|
6
|
+
// Validate one manifest (local path or URL); exit 1 when invalid.
|
|
7
|
+
export async function runValidate(manifestArg, options) {
|
|
8
|
+
const target = manifestArg ?? "./apollo.service.json";
|
|
9
|
+
let manifestRaw;
|
|
10
|
+
try {
|
|
11
|
+
manifestRaw = target.startsWith("http://") || target.startsWith("https://")
|
|
12
|
+
? await (await fetch(target, { signal: AbortSignal.timeout(10_000) })).text()
|
|
13
|
+
: await readFile(target, "utf8");
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
p.log.error(`Could not read ${target}: ${error instanceof Error ? error.message : error}`);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
let manifest;
|
|
21
|
+
try {
|
|
22
|
+
manifest = JSON.parse(manifestRaw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
p.log.error(`${target} is not valid JSON.`);
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const schema = await loadSchema(options.schema);
|
|
30
|
+
const result = validateManifest(schema, manifest);
|
|
31
|
+
if (result.isValid) {
|
|
32
|
+
p.log.success(`${target} is a valid apollo.dev/v1 manifest.`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
p.log.error(`${target} failed validation:`);
|
|
36
|
+
for (const error of result.errors) {
|
|
37
|
+
console.error(` - ${error}`);
|
|
38
|
+
}
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// packages/cli/src/index.ts - `apollo` CLI entry: command wiring.
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { runInit } from "./commands/init.js";
|
|
5
|
+
import { runAdd } from "./commands/add.js";
|
|
6
|
+
import { runList } from "./commands/list.js";
|
|
7
|
+
import { runSync } from "./commands/sync.js";
|
|
8
|
+
import { runDescribe } from "./commands/describe.js";
|
|
9
|
+
import { runValidate } from "./commands/validate.js";
|
|
10
|
+
import { runGraph } from "./commands/graph.js";
|
|
11
|
+
import { runDoctor } from "./commands/doctor.js";
|
|
12
|
+
import { runOpen } from "./commands/open.js";
|
|
13
|
+
const program = new Command();
|
|
14
|
+
program
|
|
15
|
+
.name("apollo")
|
|
16
|
+
.description("Bootstrap NuStack services into your repo via the Apollo discovery plane.")
|
|
17
|
+
.version("0.2.0")
|
|
18
|
+
.option("--apollo-url <url>", "Apollo base URL (defaults to $APOLLO_URL, then the hosted instance)");
|
|
19
|
+
program
|
|
20
|
+
.command("init")
|
|
21
|
+
.description("Store your APOLLO_APP_KEY in the managed .env block and scaffold the manifest")
|
|
22
|
+
.action(async () => runInit(program.opts()));
|
|
23
|
+
program
|
|
24
|
+
.command("add [service]")
|
|
25
|
+
.description("Install a stack service: exchange credentials, write .env, install the package")
|
|
26
|
+
.action(async (service) => runAdd(service, program.opts()));
|
|
27
|
+
program
|
|
28
|
+
.command("list")
|
|
29
|
+
.description("List the services in the Apollo catalogue")
|
|
30
|
+
.action(async () => runList(program.opts()));
|
|
31
|
+
program
|
|
32
|
+
.command("sync")
|
|
33
|
+
.description("Re-project all credentials issued to this application into the managed .env block")
|
|
34
|
+
.action(async () => runSync(program.opts()));
|
|
35
|
+
program
|
|
36
|
+
.command("describe <service>")
|
|
37
|
+
.description("Show the full catalogue entry for a service")
|
|
38
|
+
.option("--json", "emit the raw catalogue entry as JSON")
|
|
39
|
+
.action(async (service, options) => runDescribe(service, options, program.opts()));
|
|
40
|
+
program
|
|
41
|
+
.command("validate [manifest]")
|
|
42
|
+
.description("Validate an apollo.service.json (path or URL) against the apollo.dev/v1 schema")
|
|
43
|
+
.option("--schema <pathOrUrl>", "override the bundled spec/schema.json")
|
|
44
|
+
.action(async (manifest, options) => runValidate(manifest, options));
|
|
45
|
+
program
|
|
46
|
+
.command("graph")
|
|
47
|
+
.description("Render the catalogue's consumes-graph")
|
|
48
|
+
.option("--mermaid", "emit a mermaid `graph LR` block instead of ascii")
|
|
49
|
+
.action(async (options) => runGraph(options, program.opts()));
|
|
50
|
+
program
|
|
51
|
+
.command("doctor")
|
|
52
|
+
.description("Check key validity, credential health, and manifest conformance")
|
|
53
|
+
.action(async () => runDoctor(program.opts()));
|
|
54
|
+
program
|
|
55
|
+
.command("open <service>")
|
|
56
|
+
.description("Open the service's endpoint (or repository) in the browser")
|
|
57
|
+
.option("--env <name>", "pick a specific endpoint environment (default: production)")
|
|
58
|
+
.action(async (service, options) => runOpen(service, options, program.opts()));
|
|
59
|
+
await program.parseAsync(process.argv);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// packages/cli/src/lib/apollo_client.ts - HTTP client for the Apollo discovery
|
|
2
|
+
// plane: catalogue fetch + credential exchange + key validation.
|
|
3
|
+
const DEFAULT_APOLLO_URL = "https://apollo.nurix-ai.co";
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
5
|
+
// Resolve the Apollo base URL: --apollo-url flag > APOLLO_URL env > hosted default.
|
|
6
|
+
export function resolveApolloUrl(options) {
|
|
7
|
+
return (options.apolloUrl ?? process.env.APOLLO_URL ?? DEFAULT_APOLLO_URL).replace(/\/+$/, "");
|
|
8
|
+
}
|
|
9
|
+
// Fetch the public discovery catalogue.
|
|
10
|
+
export async function fetchCatalogue(baseUrl) {
|
|
11
|
+
const response = await fetch(`${baseUrl}/api/catalog.json`, {
|
|
12
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
13
|
+
});
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
throw new Error(`catalogue fetch failed: ${response.status} ${response.statusText}`);
|
|
16
|
+
}
|
|
17
|
+
return (await response.json());
|
|
18
|
+
}
|
|
19
|
+
// Exchange the app key for a service-scoped credential (idempotent per app + service).
|
|
20
|
+
export async function exchangeServiceCredential(baseUrl, appKey, serviceName) {
|
|
21
|
+
const response = await fetch(`${baseUrl}/api/v1/keys/exchange`, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { Authorization: `Bearer ${appKey}`, "Content-Type": "application/json" },
|
|
24
|
+
body: JSON.stringify({ service: serviceName }),
|
|
25
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
26
|
+
});
|
|
27
|
+
const payload = (await response.json().catch(() => null));
|
|
28
|
+
if (!response.ok || !payload?.endpoint || !payload?.serviceKey) {
|
|
29
|
+
throw new Error(payload?.message ?? `exchange failed: HTTP ${response.status}`);
|
|
30
|
+
}
|
|
31
|
+
return { endpoint: payload.endpoint, serviceKey: payload.serviceKey };
|
|
32
|
+
}
|
|
33
|
+
// Fetch every service credential issued to the calling application (for sync).
|
|
34
|
+
export async function fetchAppCredentials(baseUrl, appKey) {
|
|
35
|
+
const response = await fetch(`${baseUrl}/api/v1/keys/credentials`, {
|
|
36
|
+
headers: { Authorization: `Bearer ${appKey}` },
|
|
37
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
38
|
+
});
|
|
39
|
+
const payload = (await response.json().catch(() => null));
|
|
40
|
+
if (!response.ok || !payload?.app || !Array.isArray(payload.credentials)) {
|
|
41
|
+
throw new Error(payload?.message ?? `credential projection failed: HTTP ${response.status}`);
|
|
42
|
+
}
|
|
43
|
+
return { app: payload.app, credentials: payload.credentials };
|
|
44
|
+
}
|
|
45
|
+
// Probe whether an app key is valid: an exchange with no service body returns
|
|
46
|
+
// 400 for a good key but 401 for a bad one — the only key check the API
|
|
47
|
+
// exposes today (a dedicated validation endpoint is journey work, R2).
|
|
48
|
+
export async function validateAppKey(baseUrl, appKey) {
|
|
49
|
+
const response = await fetch(`${baseUrl}/api/v1/keys/exchange`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { Authorization: `Bearer ${appKey}`, "Content-Type": "application/json" },
|
|
52
|
+
body: JSON.stringify({}),
|
|
53
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
54
|
+
});
|
|
55
|
+
return response.status !== 401;
|
|
56
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// packages/cli/src/lib/env_file.ts - Read/write the Apollo-managed block in .env
|
|
2
|
+
// files. The CLI owns only this delimited block; everything outside it belongs
|
|
3
|
+
// to the developer and is never touched (consumer-journey.md §6).
|
|
4
|
+
import { appendFile, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
const BLOCK_START = "# >>> apollo:managed — do not edit by hand; the apollo CLI rewrites this block >>>";
|
|
8
|
+
const BLOCK_END = "# <<< apollo:managed <<<";
|
|
9
|
+
// Parse KEY=VALUE lines inside the managed block of an env file.
|
|
10
|
+
export async function readManagedValues(filePath) {
|
|
11
|
+
if (!existsSync(filePath))
|
|
12
|
+
return {};
|
|
13
|
+
const content = await readFile(filePath, "utf8");
|
|
14
|
+
const block = extractBlock(content);
|
|
15
|
+
if (block === null)
|
|
16
|
+
return {};
|
|
17
|
+
const values = {};
|
|
18
|
+
for (const line of block.split("\n")) {
|
|
19
|
+
const match = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
20
|
+
if (match)
|
|
21
|
+
values[match[1]] = match[2];
|
|
22
|
+
}
|
|
23
|
+
return values;
|
|
24
|
+
}
|
|
25
|
+
// Merge values into the managed block, creating the file/block when missing.
|
|
26
|
+
export async function upsertManagedValues(filePath, updates) {
|
|
27
|
+
const currentValues = await readManagedValues(filePath);
|
|
28
|
+
const mergedValues = { ...currentValues, ...updates };
|
|
29
|
+
const lines = Object.entries(mergedValues).map(([key, value]) => `${key}=${value}`);
|
|
30
|
+
const block = [BLOCK_START, ...lines, BLOCK_END].join("\n");
|
|
31
|
+
const existingContent = existsSync(filePath) ? await readFile(filePath, "utf8") : "";
|
|
32
|
+
await writeFile(filePath, replaceBlock(existingContent, block), "utf8");
|
|
33
|
+
}
|
|
34
|
+
// Replace the managed block wholly with exactly these values (sync semantics:
|
|
35
|
+
// the server-side projection wins; stale entries disappear).
|
|
36
|
+
export async function writeManagedValues(filePath, values) {
|
|
37
|
+
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
|
38
|
+
const block = [BLOCK_START, ...lines, BLOCK_END].join("\n");
|
|
39
|
+
const existingContent = existsSync(filePath) ? await readFile(filePath, "utf8") : "";
|
|
40
|
+
await writeFile(filePath, replaceBlock(existingContent, block), "utf8");
|
|
41
|
+
}
|
|
42
|
+
// Ensure .gitignore covers .env so managed secrets never get committed.
|
|
43
|
+
export async function ensureEnvGitignored(cwd) {
|
|
44
|
+
const gitignorePath = path.join(cwd, ".gitignore");
|
|
45
|
+
const content = existsSync(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
|
|
46
|
+
const hasEnvRule = content
|
|
47
|
+
.split("\n")
|
|
48
|
+
.some((line) => [".env", "*.env", ".env*"].includes(line.trim()));
|
|
49
|
+
if (hasEnvRule)
|
|
50
|
+
return false;
|
|
51
|
+
const separator = content === "" || content.endsWith("\n") ? "" : "\n";
|
|
52
|
+
await appendFile(gitignorePath, `${separator}.env\n`);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
// Slice out the managed block's inner lines, or null when no block exists.
|
|
56
|
+
function extractBlock(content) {
|
|
57
|
+
const start = content.indexOf(BLOCK_START);
|
|
58
|
+
const end = content.indexOf(BLOCK_END);
|
|
59
|
+
if (start === -1 || end === -1 || end < start)
|
|
60
|
+
return null;
|
|
61
|
+
return content.slice(start + BLOCK_START.length, end).trim();
|
|
62
|
+
}
|
|
63
|
+
// Swap the managed block in place, or append one to the end of the file.
|
|
64
|
+
function replaceBlock(content, block) {
|
|
65
|
+
const start = content.indexOf(BLOCK_START);
|
|
66
|
+
const end = content.indexOf(BLOCK_END);
|
|
67
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
68
|
+
return content.slice(0, start) + block + content.slice(end + BLOCK_END.length);
|
|
69
|
+
}
|
|
70
|
+
const separator = content === "" ? "" : content.endsWith("\n") ? "\n" : "\n\n";
|
|
71
|
+
return content + separator + block + "\n";
|
|
72
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// packages/cli/src/lib/interactive.ts - Non-TTY guards for clack prompts: in
|
|
2
|
+
// non-interactive contexts (CI, piped stdin) a prompt can never resolve, so
|
|
3
|
+
// confirms fall back to the auto-bootstrap default (consumer-journey §9.5)
|
|
4
|
+
// instead of hanging the process.
|
|
5
|
+
import * as p from "@clack/prompts";
|
|
6
|
+
// True when both stdin and stdout are attached to a terminal.
|
|
7
|
+
export function isInteractive() {
|
|
8
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
9
|
+
}
|
|
10
|
+
// Ask when interactive; otherwise log the default taken and return it.
|
|
11
|
+
export async function confirmOrFallback(message, fallback) {
|
|
12
|
+
if (!isInteractive()) {
|
|
13
|
+
p.log.info(`${message} → ${fallback ? "yes" : "no"} (non-interactive default)`);
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
const answer = await p.confirm({ message });
|
|
17
|
+
return p.isCancel(answer) ? false : answer;
|
|
18
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// packages/cli/src/lib/manifest.ts - Read and update the repo's
|
|
2
|
+
// apollo.service.json (its consumes[] edges double as the application's bill
|
|
3
|
+
// of materials, consumer-journey.md §7).
|
|
4
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
const MANIFEST_FILE = "apollo.service.json";
|
|
8
|
+
// Absolute path of the manifest in the working directory.
|
|
9
|
+
export function manifestPath(cwd) {
|
|
10
|
+
return path.join(cwd, MANIFEST_FILE);
|
|
11
|
+
}
|
|
12
|
+
export function hasManifest(cwd) {
|
|
13
|
+
return existsSync(manifestPath(cwd));
|
|
14
|
+
}
|
|
15
|
+
// Write a minimal, schema-valid single-service application manifest scaffold
|
|
16
|
+
// (root description/repository are required with minLength/uri formats, so the
|
|
17
|
+
// scaffold carries TODO placeholders rather than empty strings).
|
|
18
|
+
export async function scaffoldManifest(cwd, repoName) {
|
|
19
|
+
const manifest = {
|
|
20
|
+
spec: "apollo.dev/v1",
|
|
21
|
+
name: repoName,
|
|
22
|
+
description: `TODO: describe ${repoName}`,
|
|
23
|
+
repository: `https://github.com/TODO/${repoName}`,
|
|
24
|
+
owner: "TODO",
|
|
25
|
+
services: [
|
|
26
|
+
{
|
|
27
|
+
name: repoName,
|
|
28
|
+
kind: "application",
|
|
29
|
+
type: "app",
|
|
30
|
+
version: "0.0.0",
|
|
31
|
+
status: "experimental",
|
|
32
|
+
description: `TODO: describe the ${repoName} application`,
|
|
33
|
+
application: { ui: "spa" },
|
|
34
|
+
consumes: [],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
await writeFile(manifestPath(cwd), JSON.stringify(manifest, null, 4) + "\n", "utf8");
|
|
39
|
+
}
|
|
40
|
+
// Append a consumes edge to the first service entry (no-op if already declared).
|
|
41
|
+
export async function appendConsumesEdge(cwd, edge) {
|
|
42
|
+
const filePath = manifestPath(cwd);
|
|
43
|
+
const manifest = JSON.parse(await readFile(filePath, "utf8"));
|
|
44
|
+
const serviceEntry = manifest.services?.[0];
|
|
45
|
+
if (!serviceEntry)
|
|
46
|
+
throw new Error(`${MANIFEST_FILE} has no services[] entry`);
|
|
47
|
+
serviceEntry.consumes ??= [];
|
|
48
|
+
const isAlreadyDeclared = serviceEntry.consumes.some((existing) => existing.service === edge.service);
|
|
49
|
+
if (isAlreadyDeclared)
|
|
50
|
+
return false;
|
|
51
|
+
serviceEntry.consumes.push(edge);
|
|
52
|
+
await writeFile(filePath, JSON.stringify(manifest, null, 4) + "\n", "utf8");
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// packages/cli/src/lib/package_manager.ts - Detect the consumer repo's package
|
|
2
|
+
// manager from its lockfile and run installs with it.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
// Detect the package manager from the lockfile (pnpm-lock.yaml → pnpm, yarn.lock → yarn, default npm).
|
|
7
|
+
export function detectPackageManager(cwd) {
|
|
8
|
+
if (existsSync(path.join(cwd, "pnpm-lock.yaml")))
|
|
9
|
+
return "pnpm";
|
|
10
|
+
if (existsSync(path.join(cwd, "yarn.lock")))
|
|
11
|
+
return "yarn";
|
|
12
|
+
return "npm";
|
|
13
|
+
}
|
|
14
|
+
// Install one package as a dependency, streaming the manager's own output.
|
|
15
|
+
export function installPackage(manager, packageName, cwd) {
|
|
16
|
+
const args = manager === "npm" ? ["install", packageName] : ["add", packageName];
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const child = spawn(manager, args, { cwd, stdio: "inherit" });
|
|
19
|
+
child.on("error", reject);
|
|
20
|
+
child.on("exit", (code) => code === 0
|
|
21
|
+
? resolve()
|
|
22
|
+
: reject(new Error(`${manager} ${args.join(" ")} exited with code ${code}`)));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// packages/cli/src/lib/schema_validator.ts - Validate apollo.service.json
|
|
2
|
+
// manifests against the apollo.dev/v1 JSON Schema (draft-07). The canonical
|
|
3
|
+
// schema is copied from spec/schema.json into dist/ at build time so the
|
|
4
|
+
// published CLI is self-contained; --schema overrides it.
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { Ajv } from "ajv";
|
|
7
|
+
import addFormatsImport from "ajv-formats";
|
|
8
|
+
// ajv-formats ships CJS; under NodeNext resolution its callable surfaces as the
|
|
9
|
+
// namespace object, so re-type it to the plugin function it is at runtime.
|
|
10
|
+
const addFormats = addFormatsImport;
|
|
11
|
+
// Load the schema: an explicit path/URL override, or the bundled copy.
|
|
12
|
+
export async function loadSchema(override) {
|
|
13
|
+
if (override?.startsWith("http://") || override?.startsWith("https://")) {
|
|
14
|
+
const response = await fetch(override, { signal: AbortSignal.timeout(10_000) });
|
|
15
|
+
if (!response.ok)
|
|
16
|
+
throw new Error(`schema fetch failed: HTTP ${response.status}`);
|
|
17
|
+
return (await response.json());
|
|
18
|
+
}
|
|
19
|
+
const schemaUrl = override
|
|
20
|
+
? new URL(override, `file://${process.cwd()}/`)
|
|
21
|
+
: new URL("../spec_schema.json", import.meta.url);
|
|
22
|
+
return JSON.parse(await readFile(schemaUrl, "utf8"));
|
|
23
|
+
}
|
|
24
|
+
// Validate a manifest object against the schema; returns human-readable errors.
|
|
25
|
+
export function validateManifest(schema, manifest) {
|
|
26
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
27
|
+
addFormats(ajv);
|
|
28
|
+
const validate = ajv.compile(schema);
|
|
29
|
+
const isValid = validate(manifest);
|
|
30
|
+
const errors = (validate.errors ?? []).map((error) => `${error.instancePath || "/"} ${error.message ?? "invalid"}`);
|
|
31
|
+
return { isValid, errors };
|
|
32
|
+
}
|