dropsh 0.6.0 → 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 +156 -3
- package/dist/src/commands/create.d.ts +6 -1
- package/dist/src/commands/create.js +1 -2
- package/dist/src/commands/update.d.ts +13 -1
- package/dist/src/commands/update.js +13 -5
- package/dist/src/core/params/from-parameters.d.ts +12 -0
- package/dist/src/core/params/from-parameters.js +209 -0
- package/dist/src/core/params/help.d.ts +46 -0
- package/dist/src/core/params/help.js +148 -0
- package/dist/src/core/params/parse-args.d.ts +18 -0
- package/dist/src/core/params/parse-args.js +75 -0
- package/dist/src/core/params/schema-fields.d.ts +20 -0
- package/dist/src/core/params/schema-fields.js +65 -0
- package/dist/src/index.js +130 -44
- package/dist/src/plugin-api.d.ts +5 -0
- package/dist/src/plugin-api.js +2 -0
- package/package.json +3 -3
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -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;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { indexSchemaFields, propertiesOf } from "./schema-fields.js";
|
|
2
|
+
/**
|
|
3
|
+
* The `--help` block for the write commands. It documents the *forms* only and
|
|
4
|
+
* points at `--fields` for the bundle's actual field names: Commander renders
|
|
5
|
+
* help synchronously, so it cannot resolve a schema, while `--fields` runs in
|
|
6
|
+
* the action and goes through the normal fetch-and-cache pipeline.
|
|
7
|
+
*/
|
|
8
|
+
export const FIELD_PARAMETER_HELP = `
|
|
9
|
+
Field parameters (instead of --data):
|
|
10
|
+
--<field> <value> set a field, e.g. --title "Hello"
|
|
11
|
+
--<field>=<value> same; also the form for many fields in one call
|
|
12
|
+
--<field>.<sub> <value> set a sub-property, e.g. --body.value "Text"
|
|
13
|
+
--<relationship> <uuid> reference by UUID; repeat for a multi-valued field
|
|
14
|
+
--json <field>=<json> raw JSON value, for arrays of objects
|
|
15
|
+
|
|
16
|
+
Add --fields to list this bundle's actual field parameters and exit without
|
|
17
|
+
sending anything. An unknown field name is rejected, never ignored.
|
|
18
|
+
|
|
19
|
+
A value starting with -- needs the --<field>=<value> form. A field whose name
|
|
20
|
+
collides with a reserved option (--bundle, --data, --dry-run, --no-validate,
|
|
21
|
+
--fields, --format, --auth-profile, --config, --view-mode, --json) is reachable
|
|
22
|
+
through --json <field>=<json> or --data, never as a bare --<field>.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
dropsh create node --bundle article --fields
|
|
26
|
+
dropsh create node --bundle article --title "Hello" --body.value "Text" --dry-run`;
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function schemaTypeOf(node) {
|
|
31
|
+
if (!isRecord(node))
|
|
32
|
+
return undefined;
|
|
33
|
+
const t = node.type;
|
|
34
|
+
if (typeof t === "string")
|
|
35
|
+
return t;
|
|
36
|
+
if (Array.isArray(t))
|
|
37
|
+
return t.find((x) => typeof x === "string" && x !== "null");
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
function labelOf(node) {
|
|
41
|
+
if (!isRecord(node))
|
|
42
|
+
return undefined;
|
|
43
|
+
return typeof node.title === "string" ? node.title : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** The command-line spelling, including how the value is written. */
|
|
46
|
+
function spell(path, type) {
|
|
47
|
+
if (type === "array")
|
|
48
|
+
return `--json ${path}=<json>`;
|
|
49
|
+
if (type === "boolean")
|
|
50
|
+
return `--${path} [true|false]`;
|
|
51
|
+
return `--${path} <value>`;
|
|
52
|
+
}
|
|
53
|
+
function describeAttribute(path, node, required) {
|
|
54
|
+
const type = schemaTypeOf(node);
|
|
55
|
+
const label = labelOf(node);
|
|
56
|
+
return {
|
|
57
|
+
parameter: spell(path, type),
|
|
58
|
+
path,
|
|
59
|
+
...(type !== undefined ? { type } : {}),
|
|
60
|
+
...(label !== undefined ? { label } : {}),
|
|
61
|
+
...(required ? { required: true } : {}),
|
|
62
|
+
...(type === "boolean" ? { bare_flag: true } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** An object attribute is settable only through its leaves, so recurse into them. */
|
|
66
|
+
function describeAttributeTree(path, node, required) {
|
|
67
|
+
const props = schemaTypeOf(node) === "array" ? undefined : propertiesOf(node);
|
|
68
|
+
if (!props || Object.keys(props).length === 0) {
|
|
69
|
+
return [describeAttribute(path, node, required)];
|
|
70
|
+
}
|
|
71
|
+
const req = isRecord(node) && Array.isArray(node.required) ? node.required : [];
|
|
72
|
+
return Object.entries(props).flatMap(([sub, subNode]) => describeAttributeTree(`${path}.${sub}`, subNode, required && req.includes(sub)));
|
|
73
|
+
}
|
|
74
|
+
function describeRelationship(field) {
|
|
75
|
+
const targets = field.targetTypes ?? [];
|
|
76
|
+
const ambiguous = targets.length !== 1;
|
|
77
|
+
const label = labelOf(field.node);
|
|
78
|
+
return {
|
|
79
|
+
parameter: `--${field.name} ${ambiguous ? "<type>:<uuid>" : "<uuid>"}`,
|
|
80
|
+
path: field.name,
|
|
81
|
+
targets,
|
|
82
|
+
multiple: field.multiple === true,
|
|
83
|
+
...(label !== undefined ? { label } : {}),
|
|
84
|
+
...(ambiguous ? { requires_type_prefix: true } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Turn a resolved operation schema into the machine-readable field listing that
|
|
89
|
+
* `--fields` prints: every settable parameter, in schema declaration order.
|
|
90
|
+
*/
|
|
91
|
+
export function describeFields(schema) {
|
|
92
|
+
const index = indexSchemaFields(schema);
|
|
93
|
+
const attributeNode = propertiesOf(propertiesOf(schema)?.data)?.attributes;
|
|
94
|
+
const requiredNames = isRecord(attributeNode) && Array.isArray(attributeNode.required) ? attributeNode.required : [];
|
|
95
|
+
const attributes = [];
|
|
96
|
+
const relationships = [];
|
|
97
|
+
for (const name of index.order) {
|
|
98
|
+
const field = index.fields.get(name);
|
|
99
|
+
if (!field)
|
|
100
|
+
continue;
|
|
101
|
+
if (field.kind === "attribute") {
|
|
102
|
+
attributes.push(...describeAttributeTree(name, field.node, requiredNames.includes(name)));
|
|
103
|
+
}
|
|
104
|
+
else
|
|
105
|
+
relationships.push(describeRelationship(field));
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
...(index.resourceType !== undefined ? { type: index.resourceType } : {}),
|
|
109
|
+
attributes,
|
|
110
|
+
relationships,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Render the field listing as an aligned, human-readable table — what `--fields`
|
|
115
|
+
* prints by default. `--format json` yields `describeFields` verbatim instead,
|
|
116
|
+
* for a caller that wants to consume it.
|
|
117
|
+
*/
|
|
118
|
+
export function renderFieldsTable(described) {
|
|
119
|
+
const { attributes, relationships } = described;
|
|
120
|
+
const head = `${described.type ?? "this bundle"} — field parameters`;
|
|
121
|
+
if (attributes.length === 0 && relationships.length === 0) {
|
|
122
|
+
return `${head}\n\nThe schema declares no field parameters for this bundle.`;
|
|
123
|
+
}
|
|
124
|
+
const rows = [];
|
|
125
|
+
for (const a of attributes) {
|
|
126
|
+
const notes = [a.type ?? "", a.required ? "required" : "", a.label ?? ""].filter((n) => n !== "");
|
|
127
|
+
rows.push([a.parameter, notes.join(" ")]);
|
|
128
|
+
}
|
|
129
|
+
const attributeRows = rows.length;
|
|
130
|
+
for (const r of relationships) {
|
|
131
|
+
const notes = [
|
|
132
|
+
r.targets.join(" | ") || "(no target type declared)",
|
|
133
|
+
r.multiple ? "repeatable" : "",
|
|
134
|
+
r.label ?? "",
|
|
135
|
+
].filter((n) => n !== "");
|
|
136
|
+
rows.push([r.parameter, notes.join(" ")]);
|
|
137
|
+
}
|
|
138
|
+
const width = Math.max(...rows.map(([p]) => p.length)) + 2;
|
|
139
|
+
const line = ([p, notes]) => notes === "" ? ` ${p}` : ` ${p.padEnd(width)}${notes}`;
|
|
140
|
+
const out = [head];
|
|
141
|
+
if (attributeRows > 0) {
|
|
142
|
+
out.push("", "Attributes:", ...rows.slice(0, attributeRows).map(line));
|
|
143
|
+
}
|
|
144
|
+
if (relationships.length > 0) {
|
|
145
|
+
out.push("", "Relationships (take a UUID):", ...rows.slice(attributeRows).map(line));
|
|
146
|
+
}
|
|
147
|
+
return out.join("\n");
|
|
148
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface RawParameter {
|
|
2
|
+
/** Dotted path into the payload, e.g. "title" or "body.value". */
|
|
3
|
+
path: string;
|
|
4
|
+
value: string;
|
|
5
|
+
/** Which surface syntax produced it; "json" means `value` is raw JSON. */
|
|
6
|
+
form: "flag" | "json";
|
|
7
|
+
/**
|
|
8
|
+
* False for a bare `--field` with no following value. Whether that is legal
|
|
9
|
+
* depends on the field's schema type, so the decision belongs to the builder.
|
|
10
|
+
*/
|
|
11
|
+
hasValue: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Parse the token stream Commander passes through for unknown options into an
|
|
15
|
+
* ordered parameter list. Purely syntactic: no schema is consulted, so an
|
|
16
|
+
* unknown field name is not an error here.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseFieldArgs(tokens: string[]): RawParameter[];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ValidationError } from "../../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* `--json` is the one collector: it takes `<field>=<json>` pairs rather than a
|
|
4
|
+
* value of its own, because a raw JSON value cannot be coerced from the leaf
|
|
5
|
+
* schema type. The collector name is matched before any other handling, so the
|
|
6
|
+
* pair's left-hand side is an arbitrary field path — including one that collides
|
|
7
|
+
* with a reserved option, or `json` itself. That makes `--json <field>=<json>` a
|
|
8
|
+
* second route to such a field besides `--data`; only the bare `--<field>` form
|
|
9
|
+
* is out of reach.
|
|
10
|
+
*/
|
|
11
|
+
const COLLECTORS = new Set(["json"]);
|
|
12
|
+
function splitPair(pair, collector) {
|
|
13
|
+
const eq = pair.indexOf("=");
|
|
14
|
+
if (eq <= 0) {
|
|
15
|
+
throw new ValidationError(`--${collector} expects <field>=<value> pairs, got "${pair}"`);
|
|
16
|
+
}
|
|
17
|
+
return { path: pair.slice(0, eq), value: pair.slice(eq + 1) };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parse the token stream Commander passes through for unknown options into an
|
|
21
|
+
* ordered parameter list. Purely syntactic: no schema is consulted, so an
|
|
22
|
+
* unknown field name is not an error here.
|
|
23
|
+
*/
|
|
24
|
+
export function parseFieldArgs(tokens) {
|
|
25
|
+
const out = [];
|
|
26
|
+
let i = 0;
|
|
27
|
+
while (i < tokens.length) {
|
|
28
|
+
const token = tokens[i];
|
|
29
|
+
if (!token.startsWith("--")) {
|
|
30
|
+
throw new ValidationError(`unexpected argument "${token}"; field parameters are long options (--<field> <value>)`);
|
|
31
|
+
}
|
|
32
|
+
const body = token.slice(2);
|
|
33
|
+
if (body.length === 0) {
|
|
34
|
+
throw new ValidationError('"--" is not a field parameter');
|
|
35
|
+
}
|
|
36
|
+
const eq = body.indexOf("=");
|
|
37
|
+
const name = eq === -1 ? body : body.slice(0, eq);
|
|
38
|
+
if (COLLECTORS.has(name)) {
|
|
39
|
+
const pairs = [];
|
|
40
|
+
if (eq !== -1) {
|
|
41
|
+
pairs.push(body.slice(eq + 1));
|
|
42
|
+
i += 1;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
i += 1;
|
|
46
|
+
while (i < tokens.length && !tokens[i].startsWith("--")) {
|
|
47
|
+
pairs.push(tokens[i]);
|
|
48
|
+
i += 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (pairs.length === 0) {
|
|
52
|
+
throw new ValidationError(`--${name} expects at least one <field>=<value> pair`);
|
|
53
|
+
}
|
|
54
|
+
for (const pair of pairs) {
|
|
55
|
+
const { path, value } = splitPair(pair, name);
|
|
56
|
+
out.push({ path, value, form: "json", hasValue: true });
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (eq !== -1) {
|
|
61
|
+
out.push({ path: name, value: body.slice(eq + 1), form: "flag", hasValue: true });
|
|
62
|
+
i += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const next = tokens[i + 1];
|
|
66
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
67
|
+
out.push({ path: name, value: next, form: "flag", hasValue: true });
|
|
68
|
+
i += 2;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out.push({ path: name, value: "", form: "flag", hasValue: false });
|
|
72
|
+
i += 1;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface FieldDescriptor {
|
|
2
|
+
name: string;
|
|
3
|
+
kind: "attribute" | "relationship";
|
|
4
|
+
/** The field's own subschema, as declared under attributes/relationships. */
|
|
5
|
+
node: unknown;
|
|
6
|
+
/** Relationship only: the resource types its `data.type` accepts. */
|
|
7
|
+
targetTypes?: string[];
|
|
8
|
+
/** Relationship only: true when `data` is an array. */
|
|
9
|
+
multiple?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface SchemaFieldIndex {
|
|
12
|
+
/** From `data.properties.type.const`, else the first `enum` entry. */
|
|
13
|
+
resourceType?: string;
|
|
14
|
+
fields: Map<string, FieldDescriptor>;
|
|
15
|
+
/** Schema declaration order: attributes first, then relationships. */
|
|
16
|
+
order: string[];
|
|
17
|
+
}
|
|
18
|
+
/** The `properties` map of a schema node, or undefined when it has none. */
|
|
19
|
+
export declare function propertiesOf(node: unknown): Record<string, unknown> | undefined;
|
|
20
|
+
export declare function indexSchemaFields(schema: unknown): SchemaFieldIndex;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
/** The `properties` map of a schema node, or undefined when it has none. */
|
|
5
|
+
export function propertiesOf(node) {
|
|
6
|
+
if (!isRecord(node))
|
|
7
|
+
return undefined;
|
|
8
|
+
const props = node.properties;
|
|
9
|
+
return isRecord(props) ? props : undefined;
|
|
10
|
+
}
|
|
11
|
+
function resourceTypeOf(dataNode) {
|
|
12
|
+
const typeNode = propertiesOf(dataNode)?.type;
|
|
13
|
+
if (!isRecord(typeNode))
|
|
14
|
+
return undefined;
|
|
15
|
+
if (typeof typeNode.const === "string")
|
|
16
|
+
return typeNode.const;
|
|
17
|
+
if (Array.isArray(typeNode.enum) && typeof typeNode.enum[0] === "string") {
|
|
18
|
+
return typeNode.enum[0];
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A relationship declares its payload under `data`, either as a single resource
|
|
24
|
+
* identifier object or as an array of them. Both carry the allowed resource
|
|
25
|
+
* types in their `type` node's `enum`/`const`, which is what lets a bare UUID on
|
|
26
|
+
* the command line become a complete identifier object.
|
|
27
|
+
*/
|
|
28
|
+
function describeRelationship(name, node) {
|
|
29
|
+
const dataNode = propertiesOf(node)?.data;
|
|
30
|
+
const multiple = isRecord(dataNode) && dataNode.type === "array";
|
|
31
|
+
const itemNode = multiple ? dataNode.items : dataNode;
|
|
32
|
+
const typeNode = propertiesOf(itemNode)?.type;
|
|
33
|
+
let targetTypes;
|
|
34
|
+
if (isRecord(typeNode)) {
|
|
35
|
+
if (Array.isArray(typeNode.enum)) {
|
|
36
|
+
targetTypes = typeNode.enum.filter((t) => typeof t === "string");
|
|
37
|
+
}
|
|
38
|
+
else if (typeof typeNode.const === "string") {
|
|
39
|
+
targetTypes = [typeNode.const];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
name,
|
|
44
|
+
kind: "relationship",
|
|
45
|
+
node,
|
|
46
|
+
multiple,
|
|
47
|
+
...(targetTypes !== undefined ? { targetTypes } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function indexSchemaFields(schema) {
|
|
51
|
+
const dataNode = propertiesOf(schema)?.data;
|
|
52
|
+
const dataProps = propertiesOf(dataNode);
|
|
53
|
+
const fields = new Map();
|
|
54
|
+
const order = [];
|
|
55
|
+
for (const [name, node] of Object.entries(propertiesOf(dataProps?.attributes) ?? {})) {
|
|
56
|
+
fields.set(name, { name, kind: "attribute", node });
|
|
57
|
+
order.push(name);
|
|
58
|
+
}
|
|
59
|
+
for (const [name, node] of Object.entries(propertiesOf(dataProps?.relationships) ?? {})) {
|
|
60
|
+
fields.set(name, describeRelationship(name, node));
|
|
61
|
+
order.push(name);
|
|
62
|
+
}
|
|
63
|
+
const resourceType = resourceTypeOf(dataNode);
|
|
64
|
+
return { fields, order, ...(resourceType !== undefined ? { resourceType } : {}) };
|
|
65
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
+
import { readDataArg } from "./commands/_data.js";
|
|
4
5
|
import { runAuthLogin, runAuthLogout, runAuthStatus, runAuthUse } from "./commands/auth.js";
|
|
5
6
|
import { runCreate } from "./commands/create.js";
|
|
6
7
|
import { runDelete } from "./commands/delete.js";
|
|
7
8
|
import { runRead } from "./commands/read.js";
|
|
8
9
|
import { applyOperationSchemaPlugins, operationHookPluginIds, runSchema, SCHEMA_PIPELINE_VERSION, schemaCacheMetadataMatches, siteCacheRoot, } from "./commands/schema.js";
|
|
9
10
|
import { runSearch } from "./commands/search.js";
|
|
10
|
-
import { runUpdate } from "./commands/update.js";
|
|
11
|
+
import { assertUpdateTarget, runUpdate, } from "./commands/update.js";
|
|
11
12
|
import { runUploadFile } from "./commands/upload-file.js";
|
|
12
13
|
import { collectProviders, defaultProvider, providerById, sessionlessProvider, } from "./core/auth/registry.js";
|
|
13
14
|
import { readProfiles, writeProfile } from "./core/auth/session-store.js";
|
|
@@ -17,11 +18,14 @@ import { createPrompt } from "./core/cli/prompt.js";
|
|
|
17
18
|
import { loadConfig } from "./core/config.js";
|
|
18
19
|
import { createHttpClient } from "./core/http.js";
|
|
19
20
|
import { createJsonApiClient } from "./core/jsonapi/client.js";
|
|
21
|
+
import { buildPayloadFromParameters } from "./core/params/from-parameters.js";
|
|
22
|
+
import { describeFields, FIELD_PARAMETER_HELP, renderFieldsTable } from "./core/params/help.js";
|
|
23
|
+
import { parseFieldArgs } from "./core/params/parse-args.js";
|
|
20
24
|
import { composeRequestHooks } from "./core/request-hooks.js";
|
|
21
25
|
import { fetchJsonSchema } from "./core/schema/jsonschema-source.js";
|
|
22
26
|
import { toOperationVariant } from "./core/schema/to-jsonschema.js";
|
|
23
27
|
import { validatePayload } from "./core/schema/validate.js";
|
|
24
|
-
import { AuthError, ConfigError, exitCodeFor } from "./errors.js";
|
|
28
|
+
import { AuthError, ConfigError, exitCodeFor, ValidationError } from "./errors.js";
|
|
25
29
|
function resolveConfigPath(override) {
|
|
26
30
|
return override ?? process.env.DROPSH_CONFIG ?? "dropsh.config.js";
|
|
27
31
|
}
|
|
@@ -201,6 +205,73 @@ export function buildProgram(opts = {}) {
|
|
|
201
205
|
throw new ConfigError(`Unknown format '${f}'. Available: ${["json", ...renderers.map((r) => r.id)].join(", ")}`);
|
|
202
206
|
}
|
|
203
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Print a resolved schema as its field listing: a table for a human, or the
|
|
210
|
+
* same data verbatim on an explicit `--format json`. Shared by `create`,
|
|
211
|
+
* `update` and `schema`, so the three cannot drift.
|
|
212
|
+
*/
|
|
213
|
+
function emitFieldListing(schema, rctx) {
|
|
214
|
+
const described = describeFields(schema);
|
|
215
|
+
if (program.getOptionValueSource("format") === "default") {
|
|
216
|
+
stdout(`${renderFieldsTable(described)}\n`);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
output.emit(described, rctx);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The write-command wiring `create` and `update` share: decide `--data` vs
|
|
224
|
+
* field parameters, fetch the operation schema at most once, build the payload
|
|
225
|
+
* from the parameters, and hand back the args/deps both `runCreate` and
|
|
226
|
+
* `runUpdate` accept. The two commands differ only in how they identify their
|
|
227
|
+
* target, which is why that part stays in each action.
|
|
228
|
+
*/
|
|
229
|
+
async function writeWiring(input) {
|
|
230
|
+
const { ctx, options } = input;
|
|
231
|
+
// --fields is a discovery request, not a write: resolve the schema through
|
|
232
|
+
// the normal fetch-and-cache pipeline, print the parameters, send nothing.
|
|
233
|
+
// It reads as a table because a human asked what the fields are; an explicit
|
|
234
|
+
// --format json hands back the same data verbatim for a caller to consume.
|
|
235
|
+
if (options.fields === true) {
|
|
236
|
+
emitFieldListing(await loadOrFetchSchema(ctx, input.schemaTarget, input.operation), input.rctx);
|
|
237
|
+
return "listed";
|
|
238
|
+
}
|
|
239
|
+
// Exactly one route produces the document, and it is settled here so the
|
|
240
|
+
// command itself only ever receives a finished payload.
|
|
241
|
+
const fields = parseFieldArgs(input.tokens);
|
|
242
|
+
if (options.data !== undefined && fields.length > 0) {
|
|
243
|
+
throw new ValidationError("--data and field parameters are mutually exclusive; use one or the other");
|
|
244
|
+
}
|
|
245
|
+
if (options.data === undefined && fields.length === 0) {
|
|
246
|
+
throw new ValidationError("provide either --data or field parameters (--<field> <value>)");
|
|
247
|
+
}
|
|
248
|
+
let cached;
|
|
249
|
+
const schema = async () => (cached ??= await loadOrFetchSchema(ctx, input.schemaTarget, input.operation));
|
|
250
|
+
let payload;
|
|
251
|
+
if (options.data !== undefined) {
|
|
252
|
+
payload = await readDataArg(options.data);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
input.guard?.();
|
|
256
|
+
payload = buildPayloadFromParameters({
|
|
257
|
+
schema: await schema(),
|
|
258
|
+
parameters: fields,
|
|
259
|
+
operation: input.operation,
|
|
260
|
+
...(input.id !== undefined ? { id: input.id } : {}),
|
|
261
|
+
resourceType: input.resourceType,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const args = { payload };
|
|
265
|
+
if (options.dryRun !== undefined)
|
|
266
|
+
args.dryRun = options.dryRun;
|
|
267
|
+
if (options.validate === false)
|
|
268
|
+
args.noValidate = true;
|
|
269
|
+
const deps = { client: ctx.client, emit: (v) => output.emit(v, input.rctx) };
|
|
270
|
+
if (!args.noValidate) {
|
|
271
|
+
deps.validate = async (payload, t) => validatePayload(await schema(), payload, t);
|
|
272
|
+
}
|
|
273
|
+
return { args, deps };
|
|
274
|
+
}
|
|
204
275
|
function assertJsonOnly(command) {
|
|
205
276
|
const f = currentFormat();
|
|
206
277
|
if (f !== "json") {
|
|
@@ -347,54 +418,61 @@ Example:
|
|
|
347
418
|
.command("create <entity_type>")
|
|
348
419
|
.description("Create an entity of given type/bundle")
|
|
349
420
|
.requiredOption("--bundle <bundle>")
|
|
350
|
-
.
|
|
421
|
+
.option("--data <json>", "inline JSON or @path")
|
|
351
422
|
.option("--dry-run")
|
|
352
423
|
.option("--no-validate", "skip client-side schema validation")
|
|
353
|
-
.
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (o.validate === false)
|
|
358
|
-
args.noValidate = true;
|
|
424
|
+
.option("--fields", "list this bundle's field parameters and exit")
|
|
425
|
+
.allowUnknownOption()
|
|
426
|
+
.addHelpText("after", FIELD_PARAMETER_HELP)
|
|
427
|
+
.action((entityType, o, cmd) => {
|
|
359
428
|
return run(async (ctx) => {
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
|
|
429
|
+
const wiring = await writeWiring({
|
|
430
|
+
ctx,
|
|
431
|
+
operation: "create",
|
|
432
|
+
schemaTarget: `${entityType}/${o.bundle}`,
|
|
433
|
+
resourceType: `${entityType}--${o.bundle}`,
|
|
434
|
+
tokens: cmd.args.slice(1),
|
|
435
|
+
rctx: { command: "create", entityType, bundle: o.bundle },
|
|
436
|
+
options: o,
|
|
437
|
+
});
|
|
438
|
+
if (wiring === "listed")
|
|
439
|
+
return;
|
|
440
|
+
await runCreate({ entityType, bundle: o.bundle, ...wiring.args }, wiring.deps);
|
|
369
441
|
}, assertRenderable);
|
|
370
442
|
});
|
|
371
443
|
program
|
|
372
444
|
.command("update <target>")
|
|
373
445
|
.description("Update an existing entity")
|
|
374
|
-
.
|
|
446
|
+
.option("--data <json>", "inline JSON or @path")
|
|
375
447
|
.option("--dry-run")
|
|
376
448
|
.option("--no-validate", "skip client-side schema validation")
|
|
377
|
-
.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (o.validate === false)
|
|
382
|
-
args.noValidate = true;
|
|
449
|
+
.option("--fields", "list this bundle's field parameters and exit")
|
|
450
|
+
.allowUnknownOption()
|
|
451
|
+
.addHelpText("after", FIELD_PARAMETER_HELP)
|
|
452
|
+
.action((target, o, cmd) => {
|
|
383
453
|
return run(async (ctx) => {
|
|
384
|
-
const [entityType, bundle] = target.split("/");
|
|
454
|
+
const [entityType, bundle, id] = target.split("/");
|
|
385
455
|
const rctx = { command: "update", target };
|
|
386
456
|
if (entityType !== undefined)
|
|
387
457
|
rctx.entityType = entityType;
|
|
388
458
|
if (bundle !== undefined)
|
|
389
459
|
rctx.bundle = bundle;
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
460
|
+
const wiring = await writeWiring({
|
|
461
|
+
ctx,
|
|
462
|
+
operation: "update",
|
|
463
|
+
schemaTarget: `${entityType}/${bundle}`,
|
|
464
|
+
resourceType: `${entityType}--${bundle}`,
|
|
465
|
+
tokens: cmd.args.slice(1),
|
|
466
|
+
rctx,
|
|
467
|
+
options: o,
|
|
468
|
+
...(id !== undefined ? { id } : {}),
|
|
469
|
+
// Guard the target before building, so a malformed one is reported by
|
|
470
|
+
// its own message rather than as a missing data.id.
|
|
471
|
+
guard: () => assertUpdateTarget(target),
|
|
472
|
+
});
|
|
473
|
+
if (wiring === "listed")
|
|
474
|
+
return;
|
|
475
|
+
await runUpdate({ target, ...wiring.args }, wiring.deps);
|
|
398
476
|
}, assertRenderable);
|
|
399
477
|
});
|
|
400
478
|
program
|
|
@@ -426,21 +504,29 @@ Example:
|
|
|
426
504
|
.description("Catalog (no target) or JSON Schema for <entity>/<bundle>")
|
|
427
505
|
.option("--for <op>", "create|update", "create")
|
|
428
506
|
.option("--refresh", "bypass cache for this call")
|
|
507
|
+
.option("--fields", "list the target's field parameters instead of its schema")
|
|
429
508
|
.action((target, o) => {
|
|
430
509
|
const operation = o.for === "update" ? "update" : "create";
|
|
431
510
|
const schemaArgs = target !== undefined
|
|
432
511
|
? { target, operation, refresh: Boolean(o.refresh) }
|
|
433
512
|
: { operation, refresh: Boolean(o.refresh) };
|
|
434
|
-
return run((ctx) =>
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
513
|
+
return run((ctx) => {
|
|
514
|
+
if (o.fields === true && target === undefined) {
|
|
515
|
+
throw new ValidationError("--fields needs a target: dropsh schema <entity_type>/<bundle> --fields");
|
|
516
|
+
}
|
|
517
|
+
// --fields is the same schema, viewed as its parameters: swapping the
|
|
518
|
+
// emitter inherits runSchema's caching and --refresh untouched.
|
|
519
|
+
return runSchema(schemaArgs, {
|
|
520
|
+
http: ctx.http,
|
|
521
|
+
auth: ctx.auth,
|
|
522
|
+
baseUrl: ctx.baseUrl,
|
|
523
|
+
jsonapiPrefix: ctx.jsonapiPrefix,
|
|
524
|
+
cwd: ctx.cwd,
|
|
525
|
+
emit: o.fields === true ? (schema) => emitFieldListing(schema) : output.emit,
|
|
526
|
+
warn: (m) => stderr(`${m}\n`),
|
|
527
|
+
plugins: ctx.plugins,
|
|
528
|
+
});
|
|
529
|
+
}, () => assertJsonOnly("schema"));
|
|
444
530
|
});
|
|
445
531
|
const auth = program.command("auth").description("Manage authentication");
|
|
446
532
|
auth
|
package/dist/src/plugin-api.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export { createJsonApiClient } from "./core/jsonapi/client.js";
|
|
|
12
12
|
export type { Collection, FilterOp } from "./core/jsonapi/collection.js";
|
|
13
13
|
export type { JsonApiResourceObject, Resource, } from "./core/jsonapi/resource.js";
|
|
14
14
|
export type { JsonApiDocument, JsonApiResource } from "./core/jsonapi/types.js";
|
|
15
|
+
export type { BuildPayloadInput } from "./core/params/from-parameters.js";
|
|
16
|
+
export { buildPayloadFromParameters } from "./core/params/from-parameters.js";
|
|
17
|
+
export type { RawParameter } from "./core/params/parse-args.js";
|
|
18
|
+
export type { FieldDescriptor, SchemaFieldIndex } from "./core/params/schema-fields.js";
|
|
19
|
+
export { indexSchemaFields, propertiesOf } from "./core/params/schema-fields.js";
|
|
15
20
|
export type { DropSHPlugin, PluginContext, PluginDescriptor, RequestContext, SchemaOperation, } from "./core/plugin.js";
|
|
16
21
|
export { composePlugins } from "./core/plugin.js";
|
|
17
22
|
export { AuthError, ConfigError, HttpError, PluginError } from "./errors.js";
|
package/dist/src/plugin-api.js
CHANGED
|
@@ -3,5 +3,7 @@ export { indexIncluded, isInteractive } from "./core/cli/render.js";
|
|
|
3
3
|
export { loadConfig } from "./core/config.js";
|
|
4
4
|
export { createHttpClient } from "./core/http.js";
|
|
5
5
|
export { createJsonApiClient } from "./core/jsonapi/client.js";
|
|
6
|
+
export { buildPayloadFromParameters } from "./core/params/from-parameters.js";
|
|
7
|
+
export { indexSchemaFields, propertiesOf } from "./core/params/schema-fields.js";
|
|
6
8
|
export { composePlugins } from "./core/plugin.js";
|
|
7
9
|
export { AuthError, ConfigError, HttpError, PluginError } from "./errors.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dropsh",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Entity-agnostic helper CLI for Drupal 11 JSON:API, used by the dropsh Claude skill for editorial publishing workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -66,8 +66,8 @@
|
|
|
66
66
|
"test:watch": "vitest",
|
|
67
67
|
"test:integration": "pnpm run build && pnpm run build:packages && vitest run -c vitest.config.integration.ts",
|
|
68
68
|
"typecheck": "pnpm run build:packages && tsc --noEmit && pnpm -r run typecheck",
|
|
69
|
-
"lint": "biome check src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
|
|
70
|
-
"lint:fix": "biome check --write src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
|
|
69
|
+
"lint": "biome check src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/params/ tests/integrations/schema/ tests/integrations/*.ts",
|
|
70
|
+
"lint:fix": "biome check --write src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/params/ tests/integrations/schema/ tests/integrations/*.ts",
|
|
71
71
|
"drupal:up": "bash tests/integrations/bin/drupal-up.sh",
|
|
72
72
|
"drupal:down": "bash tests/integrations/bin/drupal-down.sh",
|
|
73
73
|
"init-worktree": "node scripts/init-worktree.mjs",
|