dropsh 0.5.8 → 0.6.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.
package/README.md CHANGED
@@ -295,17 +295,170 @@ exit codes.
295
295
  ```bash
296
296
  dropsh read <entity_type>/<bundle>/<uuid> [--include=<field>,…]
297
297
  dropsh search <entity_type> [--bundle=<b>] [--filter=key:value]… [--limit=N] [--include=<field>,…]
298
- dropsh create <entity_type> --bundle=<b> --data=<json|@file> [--dry-run] [--no-validate]
299
- dropsh update <entity_type>/<bundle>/<uuid> --data=<json|@file> [--dry-run] [--no-validate]
298
+ dropsh create <entity_type> --bundle=<b> (--data=<json|@file> | <field parameters>) [--dry-run] [--no-validate]
299
+ dropsh update <entity_type>/<bundle>/<uuid> (--data=<json|@file> | <field parameters>) [--dry-run] [--no-validate]
300
300
  dropsh delete <entity_type>/<bundle>/<uuid> [--dry-run]
301
301
  dropsh upload-file --target=<entity_type>/<bundle>/<uuid>/<field> --file=<path> [--dry-run]
302
302
  dropsh schema [--refresh]
303
- dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh]
303
+ dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh] [--fields]
304
304
  ```
305
305
 
306
306
  `create` and `update` validate payloads against the current schema by default.
307
307
  Pass `--no-validate` when you intentionally want to skip local validation.
308
308
 
309
+ ### Field parameters on `create` / `update`
310
+
311
+ Instead of handing `create`/`update` a finished JSON:API document via `--data`,
312
+ you can name the fields directly. The CLI reads the bundle's operation schema and
313
+ builds the document itself, so it knows which name is an attribute and which is a
314
+ relationship:
315
+
316
+ | Form | Meaning |
317
+ |---|---|
318
+ | `--<field> <value>` | set a field |
319
+ | `--<field>=<value>` | same; required when the value itself starts with `--` |
320
+ | `--<field>.<sub> <value>` | set a sub-property, e.g. `--body.value "Text"` |
321
+ | `--<relationship> <uuid>` | reference by UUID; repeat for a multi-valued field |
322
+ | `--json <field>=<json>` | raw JSON value, for arrays of objects |
323
+
324
+ Any number of fields goes into a single invocation; the `--<field>=<value>` pair
325
+ form and the spaced form are interchangeable and produce identical output.
326
+
327
+ **`--fields` lists the bundle's actual fields.** `--help` documents the forms;
328
+ `--fields` resolves the bundle's schema and prints every settable parameter,
329
+ then exits without sending anything:
330
+
331
+ ```bash
332
+ dropsh schema node/article_test --fields # the introspection route
333
+ dropsh create node --bundle=article_test --fields # same listing, on the command
334
+ ```
335
+
336
+ ```
337
+ node--article_test — field parameters
338
+
339
+ Attributes:
340
+ --drupal_internal__nid <value> integer ID
341
+ --drupal_internal__vid <value> integer Revision ID
342
+ --langcode.value <value> string Language code
343
+ --langcode.language <value> Language object
344
+ --revision_timestamp <value> number Revision create time
345
+ --revision_log <value> string Revision log message
346
+ --status [true|false] boolean Published
347
+ --title <value> string required Title
348
+ --created <value> number Authored on
349
+ --changed <value> number Changed
350
+ --promote [true|false] boolean Promoted to front page
351
+ --sticky [true|false] boolean Sticky at top of lists
352
+ --default_langcode [true|false] boolean Default translation
353
+ --revision_default [true|false] boolean Default revision
354
+ --revision_translation_affected [true|false] boolean Revision translation affected
355
+ --path.alias <value> string Path alias
356
+ --path.pid <value> integer Path id
357
+ --path.langcode <value> string Language Code
358
+ --body.value <value> string Text
359
+ --body.format <value> string Text format
360
+ --body.summary <value> string Summary
361
+ --field_test_text <value> string Test Text
362
+
363
+ Relationships (take a UUID):
364
+ --node_type <uuid> node_type--node_type Content type
365
+ --revision_uid <uuid> user--user Revision user
366
+ --uid <uuid> user--user Authored by
367
+ --field_image <uuid> file--file Image
368
+ ```
369
+
370
+ The listing is the schema's, not a curated subset: Drupal's base fields
371
+ (`--drupal_internal__nid`, `--langcode.*`, `--path.*`, `--node_type`,
372
+ `--revision_uid`, …) are settable parameters and appear alongside the
373
+ bundle's own fields.
374
+
375
+ Each row carries the schema type, whether the field is required, and the field's
376
+ human label. Object attributes appear as their dotted leaves (`--body.value`,
377
+ never `--body`), array attributes as their `--json` form, and a relationship that
378
+ allows several target types is spelled `<type>:<uuid>`. The same listing is reachable three ways, all derived from the schema alone with
379
+ no extra request: `dropsh schema <entity>/<bundle> --fields` (honouring `--for`
380
+ and `--refresh`), and `--fields` on `create` and `update`.
381
+
382
+ Add an explicit `--format json` to get the same listing as JSON — `parameter`,
383
+ `path`, `type`, `label`, `required`, `bare_flag` per attribute, and `targets`,
384
+ `multiple`, `requires_type_prefix` per relationship — for a caller that wants to
385
+ consume it rather than read it.
386
+
387
+ Rules worth knowing:
388
+
389
+ - **Field names come from the schema** (`dropsh schema <entity>/<bundle>`), so an
390
+ unknown name is **rejected** with exit code `4` instead of being ignored. The
391
+ error names the rejected parameter and lists the known attributes and
392
+ relationships.
393
+ - **Relationships take a bare UUID**; the resource type comes from the schema.
394
+ When a relationship allows several target types, pass `<type>:<uuid>`.
395
+ Repeating the flag accumulates values on a multi-valued relationship; a second
396
+ value on a single-valued one is an error.
397
+ - **Values are typed by the schema** — `boolean`, `integer`/`number`, else
398
+ string. A bare `--flag` means `true` only on a boolean field. Values are never
399
+ comma-split, so `--title "a, b"` survives intact.
400
+ - **`--data` and field parameters are mutually exclusive**; giving neither is an
401
+ error.
402
+ - **`--dry-run` prints the generated document** and sends nothing. Output keys are
403
+ emitted in schema declaration order, so two dry-runs of the same fields are
404
+ byte-identical and diffable.
405
+ - **`--no-validate` skips Ajv, not the schema.** In parameter mode the schema is
406
+ always loaded — attribute/relationship placement is impossible without it.
407
+ - A field whose name collides with a reserved option (`--bundle`, `--data`,
408
+ `--dry-run`, `--no-validate`, `--fields`, `--format`, `--auth-profile`,
409
+ `--config`, `--view-mode`, `--json`) is out of reach as a bare `--<field>`, but
410
+ `--json <field>=<json>` still sets it (write the value as JSON), and so does
411
+ `--data`. No Drupal base field name collides with that list in practice.
412
+ - Fields contributed by a plugin's `extendOperationSchema` hook are usable as
413
+ parameters like any native field.
414
+
415
+ Worked example:
416
+
417
+ ```bash
418
+ dropsh create node --bundle=article_test \
419
+ --title "Hello" --body.value "Text" \
420
+ --uid c8e0b5d2-9e66-4caf-ac43-337f3b3af62d --dry-run | jq
421
+ ```
422
+
423
+ ```json
424
+ {
425
+ "dry_run": true,
426
+ "method": "POST",
427
+ "path": "node/article_test",
428
+ "payload": {
429
+ "data": {
430
+ "type": "node--article_test",
431
+ "attributes": {
432
+ "title": "Hello",
433
+ "body": {
434
+ "value": "Text"
435
+ }
436
+ },
437
+ "relationships": {
438
+ "uid": {
439
+ "data": {
440
+ "type": "user--user",
441
+ "id": "c8e0b5d2-9e66-4caf-ac43-337f3b3af62d"
442
+ }
443
+ }
444
+ }
445
+ }
446
+ }
447
+ }
448
+ ```
449
+
450
+ `update` works the same way and puts the target UUID into `data.id`, sending a
451
+ PATCH with only the fields you named:
452
+
453
+ ```bash
454
+ dropsh update node/article_test/<uuid> --title "changed title"
455
+ ```
456
+
457
+ The builder is also available programmatically — `buildPayloadFromParameters`,
458
+ `indexSchemaFields` and `propertiesOf` are exported from `dropsh/plugin`, so a
459
+ plugin can turn (schema, parameters) into the same document without invoking the
460
+ CLI.
461
+
309
462
  ### `--include` on `read` / `search`
310
463
 
311
464
  Pass `--include` to embed related resources in the response's JSON:API
@@ -2,7 +2,12 @@ import type { JsonApiClient } from "../core/jsonapi/client.js";
2
2
  export interface CreateArgs {
3
3
  entityType: string;
4
4
  bundle: string;
5
- dataArg: string;
5
+ /**
6
+ * The JSON:API document to send. Resolving it — from `--data` or from field
7
+ * parameters — happens before this command runs, so nothing here knows or
8
+ * cares which route produced it.
9
+ */
10
+ payload: unknown;
6
11
  dryRun?: boolean;
7
12
  noValidate?: boolean;
8
13
  }
@@ -1,6 +1,5 @@
1
- import { readDataArg } from "./_data.js";
2
1
  export async function runCreate(args, deps) {
3
- const payload = await readDataArg(args.dataArg);
2
+ const { payload } = args;
4
3
  const target = `${args.entityType}/${args.bundle}`;
5
4
  if (!args.noValidate && deps.validate) {
6
5
  await deps.validate(payload, target);
@@ -1,7 +1,12 @@
1
1
  import type { JsonApiClient } from "../core/jsonapi/client.js";
2
2
  export interface UpdateArgs {
3
3
  target: string;
4
- dataArg: string;
4
+ /**
5
+ * The JSON:API document to send. Resolving it — from `--data` or from field
6
+ * parameters — happens before this command runs, so nothing here knows or
7
+ * cares which route produced it.
8
+ */
9
+ payload: unknown;
5
10
  dryRun?: boolean;
6
11
  noValidate?: boolean;
7
12
  }
@@ -10,4 +15,11 @@ export interface UpdateDeps {
10
15
  emit: (v: unknown) => void | Promise<void>;
11
16
  validate?: (payload: unknown, target: string) => void | Promise<void>;
12
17
  }
18
+ /**
19
+ * Reject a malformed target. Exported so the CLI can run this guard *before* it
20
+ * builds a payload from field parameters: without it, a bad target would surface
21
+ * as the builder's "update requires the entity id" instead of this clearer
22
+ * message.
23
+ */
24
+ export declare function assertUpdateTarget(target: string): void;
13
25
  export declare function runUpdate(args: UpdateArgs, deps: UpdateDeps): Promise<void>;
@@ -1,11 +1,19 @@
1
1
  import { ValidationError } from "../errors.js";
2
- import { readDataArg } from "./_data.js";
3
2
  const TARGET_RE = /^[a-z0-9_]+\/[a-z0-9_]+\/[a-f0-9-]{8,}$/;
4
- export async function runUpdate(args, deps) {
5
- if (!TARGET_RE.test(args.target)) {
6
- throw new ValidationError(`target must be <entity_type>/<bundle>/<uuid>, got "${args.target}"`);
3
+ /**
4
+ * Reject a malformed target. Exported so the CLI can run this guard *before* it
5
+ * builds a payload from field parameters: without it, a bad target would surface
6
+ * as the builder's "update requires the entity id" instead of this clearer
7
+ * message.
8
+ */
9
+ export function assertUpdateTarget(target) {
10
+ if (!TARGET_RE.test(target)) {
11
+ throw new ValidationError(`target must be <entity_type>/<bundle>/<uuid>, got "${target}"`);
7
12
  }
8
- const payload = await readDataArg(args.dataArg);
13
+ }
14
+ export async function runUpdate(args, deps) {
15
+ assertUpdateTarget(args.target);
16
+ const { payload } = args;
9
17
  const [entity, bundle] = args.target.split("/", 3);
10
18
  const schemaTarget = `${entity}/${bundle}`;
11
19
  if (!args.noValidate && deps.validate) {
@@ -1,13 +1,26 @@
1
1
  import type { HttpClient, HttpRequest } from "../http.js";
2
+ /**
3
+ * Outcome of a reactive credential renewal. On failure the renewal cause is
4
+ * carried back so the caller can surface *why* renewal failed instead of the
5
+ * bare, unexplained 401 that first triggered it.
6
+ */
7
+ export type RenewOutcome = {
8
+ ok: true;
9
+ } | {
10
+ ok: false;
11
+ cause?: unknown;
12
+ };
2
13
  export interface AuthAdapter {
3
14
  apply(req: HttpRequest): Promise<HttpRequest>;
4
15
  /**
5
16
  * Force a credential refresh after the server rejected the current token (401),
6
- * then persist it. Returns true if renewal succeeded (caller retries the request
7
- * once), false if it could not renew (caller surfaces the original 401).
8
- * Providers that cannot renew (e.g. basic auth) omit this.
17
+ * then persist it. Resolves `{ ok: true }` when renewal succeeded (caller
18
+ * retries the request once) or `{ ok: false, cause }` when it could not renew,
19
+ * where `cause` is the error explaining the failure (the caller surfaces it in
20
+ * place of the original 401). Providers that cannot renew (e.g. basic auth)
21
+ * omit this method.
9
22
  */
10
- renew?(): Promise<boolean>;
23
+ renew?(): Promise<RenewOutcome>;
11
24
  }
12
25
  /** Provider-specific opaque session payload persisted to the state dir. */
13
26
  export type AuthSession = Record<string, unknown>;
@@ -1,8 +1,9 @@
1
1
  import type { DrupalJsonApiParams } from "drupal-jsonapi-params";
2
2
  import type { AuthAdapter } from "../auth/types.js";
3
- import type { HttpClient } from "../http.js";
3
+ import type { HttpClient, HttpRequest } from "../http.js";
4
4
  import { type Collection } from "./collection.js";
5
5
  import { type Resource } from "./resource.js";
6
+ export type DropSHOperation = "read" | "search" | "create" | "update" | "delete" | "upload";
6
7
  /** Attributes/relationships payload accepted by create/update/upsert. */
7
8
  export interface ResourceWriteBody {
8
9
  attributes?: Record<string, unknown>;
@@ -35,5 +36,6 @@ export interface JsonApiOptions {
35
36
  prefix: string;
36
37
  http: HttpClient;
37
38
  auth: AuthAdapter;
39
+ alterRequest?: (req: HttpRequest, operation: DropSHOperation, entityType?: string, bundle?: string) => Promise<HttpRequest>;
38
40
  }
39
41
  export declare function createJsonApiClient(opts: JsonApiOptions): JsonApiClient;
@@ -12,27 +12,45 @@ const JSONAPI_HEADERS = {
12
12
  Accept: "application/vnd.api+json",
13
13
  "Content-Type": "application/vnd.api+json",
14
14
  };
15
+ function targetFromPath(path) {
16
+ const [entityType, bundle] = path.split("/").filter(Boolean);
17
+ return {
18
+ ...(entityType !== undefined ? { entityType } : {}),
19
+ ...(bundle !== undefined ? { bundle } : {}),
20
+ };
21
+ }
22
+ function getOperation(path) {
23
+ const segments = path.split("/").filter(Boolean);
24
+ return segments.length === 0 || segments.length >= 3 ? "read" : "search";
25
+ }
15
26
  export function createJsonApiClient(opts) {
16
- async function send(method, url, body, extraHeaders = {}) {
27
+ async function send(method, path, operation, body, extraHeaders = {}, queryString = "") {
17
28
  const reqBase = {
18
29
  method,
19
- url,
30
+ url: joinUrl(opts.baseUrl, opts.prefix, path) + queryString,
20
31
  headers: { ...JSONAPI_HEADERS, ...extraHeaders },
21
32
  };
22
33
  const req = body !== undefined ? { ...reqBase, body } : reqBase;
34
+ const authenticatedRequest = await opts.auth.apply(req);
35
+ const { entityType, bundle } = targetFromPath(path);
36
+ const alteredRequest = opts.alterRequest
37
+ ? await opts.alterRequest(authenticatedRequest, operation, entityType, bundle)
38
+ : authenticatedRequest;
23
39
  let res;
24
40
  try {
25
- res = await opts.http.send(await opts.auth.apply(req));
41
+ res = await opts.http.send(alteredRequest);
26
42
  }
27
43
  catch (err) {
28
44
  // The server rejected the token (401). Ask the auth adapter to renew and,
29
- // if it could, retry the request exactly once with a fresh token.
45
+ // if it could, retry the request exactly once with a fresh token. If it
46
+ // could not, surface the renewal cause (e.g. an unconfigured secret) so
47
+ // the caller sees *why* renewal failed, not just the original 401.
30
48
  if (!(err instanceof HttpError) || err.status !== 401 || !opts.auth.renew)
31
49
  throw err;
32
- const renewed = await opts.auth.renew();
33
- if (!renewed)
34
- throw err;
35
- res = await opts.http.send(await opts.auth.apply(req));
50
+ const outcome = await opts.auth.renew();
51
+ if (!outcome.ok)
52
+ throw outcome.cause ?? err;
53
+ res = await opts.http.send(await opts.auth.apply(alteredRequest));
36
54
  }
37
55
  if (res.status === 204 || res.body.length === 0)
38
56
  return { ok: true };
@@ -41,19 +59,19 @@ export function createJsonApiClient(opts) {
41
59
  const client = {
42
60
  async get(path, params) {
43
61
  const qs = params ? `?${params.getQueryString()}` : "";
44
- return send("GET", joinUrl(opts.baseUrl, opts.prefix, path) + qs);
62
+ return send("GET", path, getOperation(path), undefined, {}, qs);
45
63
  },
46
64
  async post(path, body) {
47
- return send("POST", joinUrl(opts.baseUrl, opts.prefix, path), JSON.stringify(body));
65
+ return send("POST", path, "create", JSON.stringify(body));
48
66
  },
49
67
  async patch(path, body) {
50
- return send("PATCH", joinUrl(opts.baseUrl, opts.prefix, path), JSON.stringify(body));
68
+ return send("PATCH", path, "update", JSON.stringify(body));
51
69
  },
52
70
  async delete(path) {
53
- return send("DELETE", joinUrl(opts.baseUrl, opts.prefix, path));
71
+ return send("DELETE", path, "delete");
54
72
  },
55
73
  async upload(path, filename, data) {
56
- return send("POST", joinUrl(opts.baseUrl, opts.prefix, path), data, {
74
+ return send("POST", path, "upload", data, {
57
75
  "Content-Type": "application/octet-stream",
58
76
  "Content-Disposition": `file; filename="${filename}"`,
59
77
  });
@@ -0,0 +1,12 @@
1
+ import type { RawParameter } from "./parse-args.js";
2
+ export interface BuildPayloadInput {
3
+ /** The resolved operation schema, plugin extensions already applied. */
4
+ schema: unknown;
5
+ parameters: RawParameter[];
6
+ operation: "create" | "update";
7
+ /** Required for `update`: becomes `data.id`. */
8
+ id?: string;
9
+ /** Fallback resource type when the schema carries no `type` const/enum. */
10
+ resourceType?: string;
11
+ }
12
+ export declare function buildPayloadFromParameters(input: BuildPayloadInput): unknown;
@@ -0,0 +1,209 @@
1
+ import { ValidationError } from "../../errors.js";
2
+ import { indexSchemaFields, propertiesOf, } from "./schema-fields.js";
3
+ function isRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ function schemaTypeOf(node) {
7
+ if (!isRecord(node))
8
+ return undefined;
9
+ const t = node.type;
10
+ if (typeof t === "string")
11
+ return t;
12
+ if (Array.isArray(t))
13
+ return t.find((x) => typeof x === "string" && x !== "null");
14
+ return undefined;
15
+ }
16
+ /**
17
+ * Convert one command-line string into the value the leaf schema node asks for.
18
+ * A non-convertible value is rejected here rather than left for Ajv, because
19
+ * `--no-validate` must not turn a wrong type into a silently wrong payload.
20
+ */
21
+ function coerce(node, param, label) {
22
+ const type = schemaTypeOf(node);
23
+ if (!param.hasValue) {
24
+ if (type === "boolean")
25
+ return true;
26
+ throw new ValidationError(`missing value for --${label}${type !== undefined ? ` (expected ${type})` : ""}`);
27
+ }
28
+ if (type === "boolean") {
29
+ if (param.value === "true")
30
+ return true;
31
+ if (param.value === "false")
32
+ return false;
33
+ throw new ValidationError(`--${label} expects true or false, got "${param.value}"`);
34
+ }
35
+ if (type === "integer" || type === "number") {
36
+ const n = Number(param.value);
37
+ if (param.value.trim() === "" || !Number.isFinite(n)) {
38
+ throw new ValidationError(`--${label} expects ${type}, got "${param.value}"`);
39
+ }
40
+ if (type === "integer" && !Number.isInteger(n)) {
41
+ throw new ValidationError(`--${label} expects integer, got "${param.value}"`);
42
+ }
43
+ return n;
44
+ }
45
+ return param.value;
46
+ }
47
+ function parseJsonValue(param) {
48
+ try {
49
+ return JSON.parse(param.value);
50
+ }
51
+ catch (err) {
52
+ throw new ValidationError(`--json ${param.path} is not valid JSON: ${err.message}`);
53
+ }
54
+ }
55
+ /**
56
+ * Re-emit an object with its keys in the order its subschema declares them, so
57
+ * the same fields always serialise to the same bytes.
58
+ */
59
+ function ordered(node, value) {
60
+ if (Array.isArray(value)) {
61
+ const items = isRecord(node) ? node.items : undefined;
62
+ return value.map((entry) => ordered(items, entry));
63
+ }
64
+ if (!isRecord(value))
65
+ return value;
66
+ const props = propertiesOf(node);
67
+ const keys = Object.keys(value);
68
+ const declared = props ? Object.keys(props).filter((k) => keys.includes(k)) : [];
69
+ const extra = keys.filter((k) => !declared.includes(k)).sort();
70
+ const out = {};
71
+ for (const key of [...declared, ...extra]) {
72
+ out[key] = ordered(props?.[key], value[key]);
73
+ }
74
+ return out;
75
+ }
76
+ /**
77
+ * Walk a dotted path into a field's subschema, rejecting a segment the schema
78
+ * does not declare. Returns the leaf node the value must satisfy.
79
+ */
80
+ function resolveLeaf(field, segments) {
81
+ let node = field.node;
82
+ for (const segment of segments) {
83
+ const props = propertiesOf(node);
84
+ const next = props?.[segment];
85
+ if (next === undefined) {
86
+ throw new ValidationError(`unknown sub-property '${segment}' of '${field.name}'${props ? `; known: ${Object.keys(props).join(", ")}` : ""}`);
87
+ }
88
+ node = next;
89
+ }
90
+ return node;
91
+ }
92
+ function assign(target, segments, value, label) {
93
+ let cursor = target;
94
+ for (const segment of segments.slice(0, -1)) {
95
+ const existing = cursor[segment];
96
+ if (existing === undefined) {
97
+ const created = {};
98
+ cursor[segment] = created;
99
+ cursor = created;
100
+ }
101
+ else if (isRecord(existing)) {
102
+ cursor = existing;
103
+ }
104
+ else {
105
+ throw new ValidationError(`parameter '${label}' conflicts with an earlier value`);
106
+ }
107
+ }
108
+ const leaf = segments[segments.length - 1];
109
+ if (leaf in cursor) {
110
+ throw new ValidationError(`parameter '${label}' was given more than once`);
111
+ }
112
+ cursor[leaf] = value;
113
+ }
114
+ function unknownParameter(name, index) {
115
+ const attributes = [];
116
+ const relationships = [];
117
+ for (const field of index.fields.values()) {
118
+ (field.kind === "attribute" ? attributes : relationships).push(field.name);
119
+ }
120
+ throw new ValidationError(`unknown parameter '${name}'. Known attributes: ${attributes.join(", ") || "(none)"}. ` +
121
+ `Known relationships: ${relationships.join(", ") || "(none)"}.`);
122
+ }
123
+ /**
124
+ * A relationship parameter is a bare UUID whenever the schema allows exactly one
125
+ * target type, and `<type>:<uuid>` when it allows several. The UUID itself never
126
+ * contains a colon, so the split is unambiguous.
127
+ */
128
+ function resourceIdentifier(field, param) {
129
+ if (!param.hasValue) {
130
+ throw new ValidationError(`missing value for --${field.name} (expected a UUID)`);
131
+ }
132
+ const allowed = field.targetTypes ?? [];
133
+ const colon = param.value.lastIndexOf(":");
134
+ if (colon > 0) {
135
+ const type = param.value.slice(0, colon);
136
+ const id = param.value.slice(colon + 1);
137
+ if (allowed.length > 0 && !allowed.includes(type)) {
138
+ throw new ValidationError(`'${type}' is not an allowed target type for '${field.name}'; allowed: ${allowed.join(", ")}`);
139
+ }
140
+ return { type, id };
141
+ }
142
+ if (allowed.length === 0) {
143
+ throw new ValidationError(`relationship '${field.name}' declares no target type in the schema; pass <type>:<uuid>`);
144
+ }
145
+ if (allowed.length > 1) {
146
+ throw new ValidationError(`relationship '${field.name}' allows several target types (${allowed.join(", ")}); ` +
147
+ "pass <type>:<uuid>");
148
+ }
149
+ return { type: allowed[0], id: param.value };
150
+ }
151
+ export function buildPayloadFromParameters(input) {
152
+ const index = indexSchemaFields(input.schema);
153
+ const attributes = {};
154
+ const relationships = new Map();
155
+ for (const param of input.parameters) {
156
+ const segments = param.path.split(".");
157
+ const name = segments[0];
158
+ const field = index.fields.get(name);
159
+ if (!field)
160
+ unknownParameter(name, index);
161
+ if (field.kind === "relationship") {
162
+ if (segments.length > 1) {
163
+ throw new ValidationError(`relationship '${name}' takes a UUID, not a sub-path ('${param.path}')`);
164
+ }
165
+ const collected = relationships.get(name) ?? [];
166
+ if (collected.length > 0 && field.multiple !== true) {
167
+ throw new ValidationError(`relationship '${name}' accepts a single value; it was given more than once`);
168
+ }
169
+ collected.push(resourceIdentifier(field, param));
170
+ relationships.set(name, collected);
171
+ continue;
172
+ }
173
+ const tail = segments.slice(1);
174
+ const leaf = resolveLeaf(field, tail);
175
+ const value = param.form === "json"
176
+ ? ordered(leaf, parseJsonValue(param))
177
+ : coerce(leaf, param, param.path);
178
+ assign(attributes, segments, value, param.path);
179
+ }
180
+ const resourceType = index.resourceType ?? input.resourceType;
181
+ if (resourceType === undefined) {
182
+ throw new ValidationError("cannot determine the JSON:API resource type: the schema declares no type and no fallback was given");
183
+ }
184
+ const data = { type: resourceType };
185
+ if (input.operation === "update") {
186
+ if (input.id === undefined || input.id === "") {
187
+ throw new ValidationError("update requires the entity id for data.id");
188
+ }
189
+ data.id = input.id;
190
+ }
191
+ const attributeNode = propertiesOf(propertiesOf(input.schema)?.data)?.attributes;
192
+ if (Object.keys(attributes).length > 0) {
193
+ data.attributes = ordered(attributeNode, attributes);
194
+ }
195
+ if (relationships.size > 0) {
196
+ const out = {};
197
+ for (const name of index.order) {
198
+ const collected = relationships.get(name);
199
+ if (!collected)
200
+ continue;
201
+ const field = index.fields.get(name);
202
+ out[name] = {
203
+ data: field.multiple === true ? collected : collected[0],
204
+ };
205
+ }
206
+ data.relationships = out;
207
+ }
208
+ return { data };
209
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The `--help` block for the write commands. It documents the *forms* only and
3
+ * points at `--fields` for the bundle's actual field names: Commander renders
4
+ * help synchronously, so it cannot resolve a schema, while `--fields` runs in
5
+ * the action and goes through the normal fetch-and-cache pipeline.
6
+ */
7
+ export declare const FIELD_PARAMETER_HELP = "\nField parameters (instead of --data):\n --<field> <value> set a field, e.g. --title \"Hello\"\n --<field>=<value> same; also the form for many fields in one call\n --<field>.<sub> <value> set a sub-property, e.g. --body.value \"Text\"\n --<relationship> <uuid> reference by UUID; repeat for a multi-valued field\n --json <field>=<json> raw JSON value, for arrays of objects\n\nAdd --fields to list this bundle's actual field parameters and exit without\nsending anything. An unknown field name is rejected, never ignored.\n\nA value starting with -- needs the --<field>=<value> form. A field whose name\ncollides with a reserved option (--bundle, --data, --dry-run, --no-validate,\n--fields, --format, --auth-profile, --config, --view-mode, --json) is reachable\nthrough --json <field>=<json> or --data, never as a bare --<field>.\n\nExample:\n dropsh create node --bundle article --fields\n dropsh create node --bundle article --title \"Hello\" --body.value \"Text\" --dry-run";
8
+ export interface AttributeDescription {
9
+ /** The complete command-line spelling, value placeholder included. */
10
+ parameter: string;
11
+ /** Dotted path into `data.attributes`. */
12
+ path: string;
13
+ type?: string;
14
+ /** The field's human label, from the schema's `title`. */
15
+ label?: string;
16
+ /** Present only when the schema requires the field. */
17
+ required?: true;
18
+ /** Present only when the field can be given as a bare `--flag`. */
19
+ bare_flag?: true;
20
+ }
21
+ export interface RelationshipDescription {
22
+ parameter: string;
23
+ path: string;
24
+ targets: string[];
25
+ multiple: boolean;
26
+ /** The relationship's human label, from the schema's `title`. */
27
+ label?: string;
28
+ /** Present only when several target types make `<type>:<uuid>` mandatory. */
29
+ requires_type_prefix?: true;
30
+ }
31
+ export interface FieldsDescription {
32
+ type?: string;
33
+ attributes: AttributeDescription[];
34
+ relationships: RelationshipDescription[];
35
+ }
36
+ /**
37
+ * Turn a resolved operation schema into the machine-readable field listing that
38
+ * `--fields` prints: every settable parameter, in schema declaration order.
39
+ */
40
+ export declare function describeFields(schema: unknown): FieldsDescription;
41
+ /**
42
+ * Render the field listing as an aligned, human-readable table — what `--fields`
43
+ * prints by default. `--format json` yields `describeFields` verbatim instead,
44
+ * for a caller that wants to consume it.
45
+ */
46
+ export declare function renderFieldsTable(described: FieldsDescription): string;