dropsh 0.0.1

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.
Files changed (37) hide show
  1. package/LICENSE +51 -0
  2. package/README.md +78 -0
  3. package/bin/dropsh +7 -0
  4. package/bin/dropsh-src +7 -0
  5. package/dist/src/commands/_data.js +19 -0
  6. package/dist/src/commands/create.js +14 -0
  7. package/dist/src/commands/delete.js +13 -0
  8. package/dist/src/commands/login.js +121 -0
  9. package/dist/src/commands/read.js +10 -0
  10. package/dist/src/commands/schema.js +61 -0
  11. package/dist/src/commands/search.js +30 -0
  12. package/dist/src/commands/update.js +20 -0
  13. package/dist/src/commands/upload-file.js +30 -0
  14. package/dist/src/core/auth/api-key.js +13 -0
  15. package/dist/src/core/auth/basic.js +24 -0
  16. package/dist/src/core/auth/factory.js +17 -0
  17. package/dist/src/core/auth/jwt.js +12 -0
  18. package/dist/src/core/auth/oauth2-authcode.js +67 -0
  19. package/dist/src/core/auth/oauth2.js +64 -0
  20. package/dist/src/core/auth/token-store.js +26 -0
  21. package/dist/src/core/auth/types.js +1 -0
  22. package/dist/src/core/cache/file-store.js +38 -0
  23. package/dist/src/core/cli/output.js +20 -0
  24. package/dist/src/core/config.js +36 -0
  25. package/dist/src/core/http.js +69 -0
  26. package/dist/src/core/jsonapi/client.js +46 -0
  27. package/dist/src/core/jsonapi/query.js +18 -0
  28. package/dist/src/core/plugin.js +1 -0
  29. package/dist/src/core/schema/catalog.js +29 -0
  30. package/dist/src/core/schema/jsonschema-source.js +46 -0
  31. package/dist/src/core/schema/sources/heuristic.js +100 -0
  32. package/dist/src/core/schema/to-jsonschema.js +51 -0
  33. package/dist/src/core/schema/validate.js +32 -0
  34. package/dist/src/errors.js +49 -0
  35. package/dist/src/index.js +244 -0
  36. package/dist/src/plugin-api.js +4 -0
  37. package/package.json +64 -0
@@ -0,0 +1,64 @@
1
+ import { AuthError, HttpError } from "../../errors.js";
2
+ function requireString(cfg, key) {
3
+ const v = cfg[key];
4
+ if (typeof v !== "string" || v.length === 0) {
5
+ throw new AuthError(`${cfg.type} auth requires ${key}`);
6
+ }
7
+ return v;
8
+ }
9
+ export function createOAuth2Auth(cfg, deps) {
10
+ const now = deps.now ?? Date.now;
11
+ const clientId = requireString(cfg, "client_id");
12
+ const clientSecret = requireString(cfg, "client_secret");
13
+ const tokenUrl = typeof cfg.token_url === "string" ? cfg.token_url : `${deps.baseUrl.replace(/\/$/, "")}/oauth/token`;
14
+ const params = new URLSearchParams({
15
+ client_id: clientId,
16
+ client_secret: clientSecret,
17
+ });
18
+ if (cfg.type === "oauth2_password") {
19
+ params.set("grant_type", "password");
20
+ params.set("username", requireString(cfg, "username"));
21
+ params.set("password", requireString(cfg, "password"));
22
+ if (typeof cfg.scope === "string")
23
+ params.set("scope", cfg.scope);
24
+ }
25
+ else if (cfg.type === "oauth2_client_credentials") {
26
+ params.set("grant_type", "client_credentials");
27
+ if (typeof cfg.scope === "string")
28
+ params.set("scope", cfg.scope);
29
+ }
30
+ else {
31
+ throw new AuthError(`Unsupported oauth2 grant: ${cfg.type}`);
32
+ }
33
+ let cached = null;
34
+ async function fetchToken() {
35
+ try {
36
+ const res = await deps.http.send({
37
+ method: "POST",
38
+ url: tokenUrl,
39
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
40
+ body: params.toString(),
41
+ });
42
+ const body = JSON.parse(res.body);
43
+ if (typeof body.access_token !== "string") {
44
+ throw new AuthError("Token endpoint returned no access_token");
45
+ }
46
+ const ttlSec = typeof body.expires_in === "number" ? body.expires_in : 3600;
47
+ return { token: body.access_token, expiresAt: now() + ttlSec * 1000 - 5000 };
48
+ }
49
+ catch (err) {
50
+ if (err instanceof HttpError)
51
+ throw new AuthError(`Token request failed: HTTP ${err.status}`, { body: err.body });
52
+ if (err instanceof AuthError)
53
+ throw err;
54
+ throw new AuthError(`Token request failed: ${err.message}`);
55
+ }
56
+ }
57
+ return {
58
+ async apply(req) {
59
+ if (!cached || cached.expiresAt <= now())
60
+ cached = await fetchToken();
61
+ return { ...req, headers: { ...(req.headers ?? {}), Authorization: `Bearer ${cached.token}` } };
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,26 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ function hostnameFromUrl(baseUrl) {
5
+ return new URL(baseUrl).hostname;
6
+ }
7
+ export function defaultTokenDir() {
8
+ return join(homedir(), ".config", "drupal-cli");
9
+ }
10
+ export async function readToken(baseUrl, dir) {
11
+ const tokenDir = dir ?? defaultTokenDir();
12
+ const tokenPath = join(tokenDir, `${hostnameFromUrl(baseUrl)}.json`);
13
+ try {
14
+ const raw = await readFile(tokenPath, "utf8");
15
+ return JSON.parse(raw);
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ export async function writeToken(baseUrl, token, dir) {
22
+ const tokenDir = dir ?? defaultTokenDir();
23
+ await mkdir(tokenDir, { recursive: true });
24
+ const tokenPath = join(tokenDir, `${hostnameFromUrl(baseUrl)}.json`);
25
+ await writeFile(tokenPath, JSON.stringify(token, null, 2), { encoding: "utf8", mode: 0o600 });
26
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ export function createFileStore(opts) {
4
+ function resolve(rel) {
5
+ return join(opts.rootDir, rel);
6
+ }
7
+ return {
8
+ async read(relPath) {
9
+ const abs = resolve(relPath);
10
+ let raw;
11
+ try {
12
+ raw = await readFile(abs, "utf8");
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ try {
18
+ return JSON.parse(raw);
19
+ }
20
+ catch {
21
+ opts.warn(`warning: cache file ${abs} was unreadable and has been refetched`);
22
+ return undefined;
23
+ }
24
+ },
25
+ async write(relPath, value) {
26
+ const abs = resolve(relPath);
27
+ const tmp = `${abs}.tmp`;
28
+ try {
29
+ await mkdir(dirname(abs), { recursive: true });
30
+ await writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
31
+ await rename(tmp, abs);
32
+ }
33
+ catch (err) {
34
+ opts.warn(`warning: could not update cache at ${abs}: ${err.message}`);
35
+ }
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,20 @@
1
+ import { CliError } from "../../errors.js";
2
+ export function createOutput(opts) {
3
+ return {
4
+ emit(value) {
5
+ opts.stdout(`${JSON.stringify(value)}\n`);
6
+ },
7
+ fail(err) {
8
+ const payload = err instanceof CliError
9
+ ? { error: { code: err.code, message: err.message, details: err.details } }
10
+ : {
11
+ error: {
12
+ code: "E_UNKNOWN",
13
+ message: err.message ?? String(err),
14
+ details: {},
15
+ },
16
+ };
17
+ opts.stderr(`${JSON.stringify(payload)}\n`);
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,36 @@
1
+ import { resolve } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { ConfigError } from "../errors.js";
4
+ function isRecord(v) {
5
+ return !!v && typeof v === "object" && !Array.isArray(v);
6
+ }
7
+ export async function loadConfig(filePath) {
8
+ let mod;
9
+ try {
10
+ mod = await import(pathToFileURL(resolve(filePath)).href);
11
+ }
12
+ catch (err) {
13
+ throw new ConfigError(`Cannot load config file: ${filePath}`, { cause: String(err) });
14
+ }
15
+ const raw = mod.default;
16
+ if (!isRecord(raw))
17
+ throw new ConfigError("Config default export must be an object");
18
+ const site = raw.site;
19
+ if (!isRecord(site))
20
+ throw new ConfigError("site section missing");
21
+ if (typeof site.base_url !== "string" || site.base_url.length === 0)
22
+ throw new ConfigError("site.base_url required");
23
+ const defaults = isRecord(raw.defaults) ? raw.defaults : {};
24
+ const plugins = Array.isArray(raw.plugins) ? raw.plugins : [];
25
+ return {
26
+ site: {
27
+ base_url: site.base_url,
28
+ jsonapi_prefix: typeof site.jsonapi_prefix === "string" ? site.jsonapi_prefix : "/jsonapi",
29
+ },
30
+ defaults: {
31
+ dry_run: defaults.dry_run === true,
32
+ timeout_ms: typeof defaults.timeout_ms === "number" ? defaults.timeout_ms : 30000,
33
+ },
34
+ plugins,
35
+ };
36
+ }
@@ -0,0 +1,69 @@
1
+ import { HttpError } from "../errors.js";
2
+ const RETRY_STATUSES = new Set([502, 503, 504, 408, 429]);
3
+ export function createHttpClient(opts = {}) {
4
+ const f = opts.fetch ?? fetch;
5
+ const maxRetries = opts.maxRetries ?? 3;
6
+ const retryDelayMs = opts.retryDelayMs ?? 300;
7
+ async function attempt(req) {
8
+ const controller = new AbortController();
9
+ const timeout = opts.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : null;
10
+ try {
11
+ const init = {
12
+ method: req.method,
13
+ signal: controller.signal,
14
+ };
15
+ if (req.headers !== undefined)
16
+ init.headers = req.headers;
17
+ if (req.body !== undefined)
18
+ init.body = req.body;
19
+ const res = await f(req.url, init);
20
+ const text = await res.text();
21
+ const headers = {};
22
+ for (const [k, v] of res.headers.entries())
23
+ headers[k] = v;
24
+ return { status: res.status, headers, body: text };
25
+ }
26
+ finally {
27
+ if (timeout)
28
+ clearTimeout(timeout);
29
+ }
30
+ }
31
+ async function sleep(ms) {
32
+ if (ms <= 0)
33
+ return;
34
+ await new Promise((r) => setTimeout(r, ms));
35
+ }
36
+ return {
37
+ async send(req) {
38
+ let lastErr = null;
39
+ for (let attemptIdx = 0; attemptIdx <= maxRetries; attemptIdx++) {
40
+ try {
41
+ const res = await attempt(req);
42
+ if (res.status >= 200 && res.status < 300)
43
+ return res;
44
+ if (!RETRY_STATUSES.has(res.status) || attemptIdx === maxRetries) {
45
+ let parsedBody = res.body;
46
+ try {
47
+ parsedBody = JSON.parse(res.body);
48
+ }
49
+ catch {
50
+ /* keep text */
51
+ }
52
+ throw new HttpError(res.status, `HTTP ${res.status}`, parsedBody);
53
+ }
54
+ }
55
+ catch (err) {
56
+ if (err instanceof HttpError)
57
+ throw err;
58
+ lastErr = err;
59
+ if (attemptIdx === maxRetries)
60
+ break;
61
+ }
62
+ await sleep(retryDelayMs * 2 ** attemptIdx);
63
+ }
64
+ throw new HttpError(0, `Network failure after ${maxRetries + 1} attempts`, {
65
+ cause: String(lastErr),
66
+ });
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,46 @@
1
+ function joinUrl(base, prefix, path) {
2
+ const b = base.replace(/\/+$/, "");
3
+ const p = prefix.startsWith("/") ? prefix : `/${prefix}`;
4
+ const r = path.startsWith("/") ? path : `/${path}`;
5
+ return `${b}${p}${r}`;
6
+ }
7
+ const JSONAPI_HEADERS = {
8
+ Accept: "application/vnd.api+json",
9
+ "Content-Type": "application/vnd.api+json",
10
+ };
11
+ export function createJsonApiClient(opts) {
12
+ async function send(method, url, body, extraHeaders = {}) {
13
+ const reqBase = {
14
+ method,
15
+ url,
16
+ headers: { ...JSONAPI_HEADERS, ...extraHeaders },
17
+ };
18
+ const req = body !== undefined ? { ...reqBase, body } : reqBase;
19
+ const withAuth = await opts.auth.apply(req);
20
+ const res = await opts.http.send(withAuth);
21
+ if (res.status === 204 || res.body.length === 0)
22
+ return { ok: true };
23
+ return JSON.parse(res.body);
24
+ }
25
+ return {
26
+ async get(path, params) {
27
+ const qs = params ? `?${params.getQueryString()}` : "";
28
+ return send("GET", joinUrl(opts.baseUrl, opts.prefix, path) + qs);
29
+ },
30
+ async post(path, body) {
31
+ return send("POST", joinUrl(opts.baseUrl, opts.prefix, path), JSON.stringify(body));
32
+ },
33
+ async patch(path, body) {
34
+ return send("PATCH", joinUrl(opts.baseUrl, opts.prefix, path), JSON.stringify(body));
35
+ },
36
+ async delete(path) {
37
+ return send("DELETE", joinUrl(opts.baseUrl, opts.prefix, path));
38
+ },
39
+ async upload(path, filename, data) {
40
+ return send("POST", joinUrl(opts.baseUrl, opts.prefix, path), data, {
41
+ "Content-Type": "application/octet-stream",
42
+ "Content-Disposition": `file; filename="${filename}"`,
43
+ });
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,18 @@
1
+ export function buildQueryString(q) {
2
+ const params = new URLSearchParams();
3
+ for (const f of q.filter ?? []) {
4
+ params.append(`filter[${f.key}][value]`, f.value);
5
+ if (f.operator)
6
+ params.append(`filter[${f.key}][operator]`, f.operator);
7
+ }
8
+ if (q.sort)
9
+ params.append("sort", q.sort);
10
+ if (q.page?.limit !== undefined)
11
+ params.append("page[limit]", String(q.page.limit));
12
+ if (q.page?.offset !== undefined)
13
+ params.append("page[offset]", String(q.page.offset));
14
+ if (q.include && q.include.length > 0)
15
+ params.append("include", q.include.join(","));
16
+ const s = params.toString();
17
+ return s.length === 0 ? "" : `?${s}`;
18
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { HttpError } from "../../errors.js";
2
+ function prettify(machine) {
3
+ const spaced = machine.replace(/_/g, " ");
4
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
5
+ }
6
+ export async function fetchCatalog(deps) {
7
+ const url = `${deps.baseUrl.replace(/\/+$/, "")}${deps.jsonapiPrefix.startsWith("/") ? deps.jsonapiPrefix : `/${deps.jsonapiPrefix}`}`;
8
+ const req = await deps.auth.apply({
9
+ method: "GET",
10
+ url,
11
+ headers: { Accept: "application/vnd.api+json" },
12
+ });
13
+ const res = await deps.http.send(req);
14
+ if (res.status < 200 || res.status >= 300) {
15
+ throw new HttpError(res.status, `cannot reach ${url}: HTTP ${res.status}`, res.body);
16
+ }
17
+ const body = JSON.parse(res.body);
18
+ const links = body.links ?? {};
19
+ const out = [];
20
+ for (const [key, link] of Object.entries(links)) {
21
+ if (!key.includes("--"))
22
+ continue;
23
+ // key.includes("--") guard above ensures two segments exist.
24
+ const [entity_type, bundle] = key.split("--", 2);
25
+ const label = link?.meta?.title ?? prettify(bundle);
26
+ out.push({ entity_type, bundle, label });
27
+ }
28
+ return out;
29
+ }
@@ -0,0 +1,46 @@
1
+ import { HttpError, ValidationError } from "../../errors.js";
2
+ import { fetchHeuristic } from "./sources/heuristic.js";
3
+ export async function fetchJsonSchema(deps) {
4
+ const { entity, bundle } = deps;
5
+ const target = { entity_type: entity, bundle };
6
+ const plugins = deps.plugins ?? [];
7
+ const ctx = { http: deps.http, auth: deps.auth, baseUrl: deps.baseUrl };
8
+ // Build heuristic base schema
9
+ let heuristicResult;
10
+ try {
11
+ heuristicResult = await fetchHeuristic({
12
+ http: deps.http,
13
+ auth: deps.auth,
14
+ baseUrl: deps.baseUrl,
15
+ jsonapiPrefix: deps.jsonapiPrefix,
16
+ entity,
17
+ bundle,
18
+ });
19
+ }
20
+ catch (err) {
21
+ if (err instanceof HttpError && err.status === 404) {
22
+ throw new ValidationError(`no such target '${entity}/${bundle}'. Run 'dropsh schema' to see available targets.`, { entity, bundle });
23
+ }
24
+ throw err;
25
+ }
26
+ let schema = heuristicResult.schema;
27
+ let source = heuristicResult.empty ? "heuristic-empty" : "heuristic";
28
+ // Run each plugin's extendSchema — a plugin may replace the schema entirely (e.g. schemata)
29
+ for (const plugin of plugins) {
30
+ const extended = await plugin.extendSchema(entity, bundle, schema, ctx);
31
+ if (extended !== schema) {
32
+ schema = extended;
33
+ source = plugin.id;
34
+ }
35
+ }
36
+ // Only warn when no plugin improved the schema
37
+ if (source === "heuristic" || source === "heuristic-empty") {
38
+ if (heuristicResult.empty) {
39
+ deps.warn(`warning: bundle '${entity}/${bundle}' has no instances and no schema plugin; returning envelope-only schema`);
40
+ }
41
+ else {
42
+ deps.warn(`warning: no schema plugin configured; returning heuristic schema (no required fields, no constraints)`);
43
+ }
44
+ }
45
+ return { schema, source, target };
46
+ }
@@ -0,0 +1,100 @@
1
+ import { HttpError } from "../../../errors.js";
2
+ function inferType(v) {
3
+ if (v === null)
4
+ return "null";
5
+ if (Array.isArray(v))
6
+ return "array";
7
+ switch (typeof v) {
8
+ case "string":
9
+ return "string";
10
+ case "number":
11
+ return Number.isInteger(v) ? "integer" : "number";
12
+ case "boolean":
13
+ return "boolean";
14
+ default:
15
+ return "object";
16
+ }
17
+ }
18
+ function mergeTypes(types) {
19
+ const arr = [...types].filter((t) => t !== undefined);
20
+ if (arr.length === 0)
21
+ return {};
22
+ if (arr.length === 1)
23
+ return { type: arr[0] };
24
+ return { type: arr };
25
+ }
26
+ export async function fetchHeuristic(deps) {
27
+ const base = deps.baseUrl.replace(/\/+$/, "");
28
+ const prefix = deps.jsonapiPrefix.startsWith("/") ? deps.jsonapiPrefix : `/${deps.jsonapiPrefix}`;
29
+ const url = `${base}${prefix}/${deps.entity}/${deps.bundle}?page%5Blimit%5D=3`;
30
+ const req = await deps.auth.apply({
31
+ method: "GET",
32
+ url,
33
+ headers: { Accept: "application/vnd.api+json" },
34
+ });
35
+ const res = await deps.http.send(req);
36
+ if (res.status < 200 || res.status >= 300) {
37
+ throw new HttpError(res.status, `cannot reach ${url}: HTTP ${res.status}`, res.body);
38
+ }
39
+ const body = JSON.parse(res.body);
40
+ const records = body.data ?? [];
41
+ const resourceType = `${deps.entity}--${deps.bundle}`;
42
+ const attrTypes = new Map();
43
+ const relNames = new Set();
44
+ for (const r of records) {
45
+ for (const [k, v] of Object.entries(r.attributes ?? {})) {
46
+ if (!attrTypes.has(k))
47
+ attrTypes.set(k, new Set());
48
+ attrTypes.get(k)?.add(inferType(v));
49
+ }
50
+ for (const k of Object.keys(r.relationships ?? {}))
51
+ relNames.add(k);
52
+ }
53
+ const attrProps = {};
54
+ for (const [k, types] of attrTypes)
55
+ attrProps[k] = mergeTypes(types);
56
+ const relProps = {};
57
+ for (const k of relNames) {
58
+ relProps[k] = {
59
+ type: "object",
60
+ properties: {
61
+ data: {
62
+ oneOf: [
63
+ {
64
+ type: "object",
65
+ properties: { type: { type: "string" }, id: { type: "string", format: "uuid" } },
66
+ required: ["type", "id"],
67
+ },
68
+ {
69
+ type: "array",
70
+ items: {
71
+ type: "object",
72
+ properties: { type: { type: "string" }, id: { type: "string", format: "uuid" } },
73
+ required: ["type", "id"],
74
+ },
75
+ },
76
+ { type: "null" },
77
+ ],
78
+ },
79
+ },
80
+ };
81
+ }
82
+ const schema = {
83
+ $schema: "https://json-schema.org/draft-07/schema",
84
+ type: "object",
85
+ properties: {
86
+ data: {
87
+ type: "object",
88
+ properties: {
89
+ type: { const: resourceType },
90
+ id: { type: "string", format: "uuid" },
91
+ attributes: { type: "object", properties: attrProps, required: [] },
92
+ relationships: { type: "object", properties: relProps, required: [] },
93
+ },
94
+ required: ["type"],
95
+ },
96
+ },
97
+ required: ["data"],
98
+ };
99
+ return { schema, empty: records.length === 0 };
100
+ }
@@ -0,0 +1,51 @@
1
+ const VALID_TYPES = new Set(["array", "boolean", "integer", "null", "number", "object", "string"]);
2
+ function normalizeTypes(schema) {
3
+ if (Array.isArray(schema))
4
+ return schema.map(normalizeTypes);
5
+ if (typeof schema !== "object" || schema === null)
6
+ return schema;
7
+ const out = {};
8
+ for (const [k, v] of Object.entries(schema)) {
9
+ if (k === "type") {
10
+ if (typeof v === "string") {
11
+ if (VALID_TYPES.has(v))
12
+ out[k] = v;
13
+ // else: drop Drupal-specific non-standard type string
14
+ }
15
+ else if (Array.isArray(v)) {
16
+ const valid = v.filter((t) => typeof t === "string" && VALID_TYPES.has(t));
17
+ if (valid.length > 0)
18
+ out[k] = valid.length === 1 ? valid[0] : valid;
19
+ // else: drop all-invalid type array
20
+ }
21
+ else {
22
+ out[k] = normalizeTypes(v);
23
+ }
24
+ }
25
+ else {
26
+ out[k] = normalizeTypes(v);
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+ export function toOperationVariant(schema, op) {
32
+ // biome-ignore lint/suspicious/noExplicitAny: deep JSON structure from external schemata source
33
+ const cloned = JSON.parse(JSON.stringify(normalizeTypes(schema)));
34
+ const data = cloned?.properties?.data;
35
+ if (data && typeof data === "object") {
36
+ if (op === "create") {
37
+ // id is server-assigned on create; strip it from required even if schemata includes it
38
+ if (Array.isArray(data.required)) {
39
+ data.required = data.required.filter((r) => r !== "id");
40
+ }
41
+ }
42
+ else {
43
+ data.required = ["type", "id"];
44
+ if (data.properties?.attributes)
45
+ data.properties.attributes.required = [];
46
+ if (data.properties?.relationships)
47
+ data.properties.relationships.required = [];
48
+ }
49
+ }
50
+ return cloned;
51
+ }
@@ -0,0 +1,32 @@
1
+ import Ajv from "ajv";
2
+ import draft6MetaSchema from "ajv/dist/refs/json-schema-draft-06.json" with { type: "json" };
3
+ import draft7MetaSchema from "ajv/dist/refs/json-schema-draft-07.json" with { type: "json" };
4
+ import { ValidationError } from "../../errors.js";
5
+ export function validatePayload(schema, payload, target) {
6
+ const ajv = new Ajv({ allErrors: true, strict: false, validateSchema: false, logger: false });
7
+ // Schemata (Drupal module) may emit Draft-04 schemas that use `id` instead of `$id`.
8
+ // AJV v8 hard-throws on `id`; removing the keyword makes AJV silently ignore it.
9
+ ajv.removeKeyword("id");
10
+ if (!ajv.getSchema("http://json-schema.org/draft-06/schema#")) {
11
+ ajv.addMetaSchema(draft6MetaSchema);
12
+ }
13
+ if (!ajv.getSchema("http://json-schema.org/draft-07/schema")) {
14
+ ajv.addMetaSchema(draft7MetaSchema);
15
+ }
16
+ // Register the https URI variant so $schema annotations using it resolve correctly.
17
+ if (!ajv.getSchema("https://json-schema.org/draft-07/schema")) {
18
+ ajv.addMetaSchema({ ...draft7MetaSchema, $id: "https://json-schema.org/draft-07/schema" });
19
+ }
20
+ let validate;
21
+ try {
22
+ validate = ajv.compile(schema);
23
+ }
24
+ catch (err) {
25
+ throw new ValidationError(`schema for ${target} cannot be compiled: ${err.message}`, { cause: String(err) });
26
+ }
27
+ const ok = validate(payload);
28
+ if (!ok) {
29
+ const errors = (validate.errors ?? []);
30
+ throw new ValidationError(`payload does not match schema for ${target}`, { errors });
31
+ }
32
+ }
@@ -0,0 +1,49 @@
1
+ export class CliError extends Error {
2
+ code;
3
+ details;
4
+ constructor(code, message, details = {}) {
5
+ super(message);
6
+ this.name = "CliError";
7
+ this.code = code;
8
+ this.details = details;
9
+ }
10
+ }
11
+ export class ConfigError extends CliError {
12
+ constructor(message, details = {}) {
13
+ super("E_CONFIG", message, details);
14
+ this.name = "ConfigError";
15
+ }
16
+ }
17
+ export class AuthError extends CliError {
18
+ constructor(message, details = {}) {
19
+ super("E_AUTH", message, details);
20
+ this.name = "AuthError";
21
+ }
22
+ }
23
+ export class ValidationError extends CliError {
24
+ constructor(message, details = {}) {
25
+ super("E_VALIDATION", message, details);
26
+ this.name = "ValidationError";
27
+ }
28
+ }
29
+ export class HttpError extends CliError {
30
+ status;
31
+ body;
32
+ constructor(status, message, body = null) {
33
+ super("E_HTTP", message, { status, body });
34
+ this.name = "HttpError";
35
+ this.status = status;
36
+ this.body = body;
37
+ }
38
+ }
39
+ export function exitCodeFor(err) {
40
+ if (err instanceof ConfigError)
41
+ return 2;
42
+ if (err instanceof AuthError)
43
+ return 3;
44
+ if (err instanceof ValidationError)
45
+ return 4;
46
+ if (err instanceof HttpError)
47
+ return 5;
48
+ return 1;
49
+ }