dropsh 0.4.1 → 0.5.2
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 +193 -39
- package/dist/src/commands/create.js +2 -2
- package/dist/src/commands/read.js +11 -2
- package/dist/src/commands/search.js +3 -1
- package/dist/src/commands/update.js +2 -2
- package/dist/src/core/cli/output.js +32 -3
- package/dist/src/core/cli/render.js +13 -0
- package/dist/src/core/context.js +44 -0
- package/dist/src/core/schema/jsonschema-source.js +7 -1
- package/dist/src/core/schema/validate.js +7 -0
- package/dist/src/index.js +87 -16
- package/dist/src/plugin-api.js +1 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -28,8 +28,27 @@ content without hard-coding every content model.
|
|
|
28
28
|
|
|
29
29
|
## Install
|
|
30
30
|
|
|
31
|
+
Install dropsh globally to put the `dropsh` binary on `PATH`. No local project,
|
|
32
|
+
`package.json`, or `node_modules` is required — dropsh runs from anywhere:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm i -g dropsh # or: pnpm add -g dropsh
|
|
36
|
+
dropsh --help # binary now on PATH, no project needed
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
dropsh needs Node.js 20 or newer.
|
|
40
|
+
|
|
41
|
+
Plugins are **separate published packages** and are **not** bundled in the
|
|
42
|
+
`dropsh` tarball. To use one, install it next to dropsh — either globally
|
|
43
|
+
(`npm i -g dropsh @dropsh/plugin-oauth2`) or for a single command via
|
|
44
|
+
`npx -p dropsh -p @dropsh/plugin-oauth2 dropsh …`. See [Plugins](#plugins) for
|
|
45
|
+
how names resolve.
|
|
46
|
+
|
|
47
|
+
<details>
|
|
48
|
+
<summary>From source (contributors)</summary>
|
|
49
|
+
|
|
31
50
|
This repository is a pnpm workspace containing the root CLI, packages, and
|
|
32
|
-
plugins.
|
|
51
|
+
plugins. Build it from source when developing dropsh itself:
|
|
33
52
|
|
|
34
53
|
```bash
|
|
35
54
|
corepack enable
|
|
@@ -38,6 +57,11 @@ pnpm run build
|
|
|
38
57
|
```
|
|
39
58
|
|
|
40
59
|
The required Node.js and pinned pnpm versions are declared in `package.json`.
|
|
60
|
+
Inside the workspace, plugins resolve from the local `node_modules`, so config
|
|
61
|
+
may import the plugin factories directly (see the workspace variant under
|
|
62
|
+
[Configuration](#configuration)).
|
|
63
|
+
|
|
64
|
+
</details>
|
|
41
65
|
|
|
42
66
|
## Drupal Prerequisites
|
|
43
67
|
|
|
@@ -55,14 +79,18 @@ Optional modules unlock stricter schemas or builder-specific support:
|
|
|
55
79
|
| Feature | Drupal modules |
|
|
56
80
|
| --- | --- |
|
|
57
81
|
| OAuth2 login | `simple_oauth` |
|
|
58
|
-
| Authoritative schemas | `
|
|
82
|
+
| Authoritative schemas (Drupal 10.1+/11) | `jsonapi_schema` |
|
|
83
|
+
| Authoritative schemas (legacy; 500s on D11/PHP 8.4) | `schemata`, `schemata_json_schema` |
|
|
59
84
|
| Canvas schemas | `canvas`, `jsonapi_sdc` |
|
|
60
85
|
| Display Builder schemas | `display_builder`, `display_builder_entity_view`, `jsonapi_sdc` |
|
|
61
86
|
|
|
62
|
-
dropsh works without
|
|
63
|
-
back to a shallow schema inferred from JSON:API sample records.
|
|
64
|
-
|
|
65
|
-
|
|
87
|
+
dropsh works without an authoritative-schema module. In that case, `dropsh
|
|
88
|
+
schema` falls back to a shallow schema inferred from JSON:API sample records.
|
|
89
|
+
Installing `jsonapi_schema` lets `@dropsh/plugin-jsonapi-schema` return a more
|
|
90
|
+
precise schema with Drupal's required fields, formats, and constraints — so
|
|
91
|
+
`create`/`update` reject malformed payloads client-side. On **Drupal 11 / PHP
|
|
92
|
+
8.4** prefer `jsonapi_schema`: the older `schemata_json_schema` endpoint returns
|
|
93
|
+
HTTP 500 there.
|
|
66
94
|
|
|
67
95
|
### Installing the Drupal modules
|
|
68
96
|
|
|
@@ -77,16 +105,21 @@ drush en jsonapi
|
|
|
77
105
|
composer require drupal/simple_oauth
|
|
78
106
|
drush en simple_oauth
|
|
79
107
|
|
|
80
|
-
# Optional: authoritative schemas (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
108
|
+
# Optional: authoritative schemas (Drupal 10.1+/11, PHP 8.4) — recommended
|
|
109
|
+
composer require drupal/jsonapi_schema
|
|
110
|
+
drush en jsonapi_schema
|
|
111
|
+
|
|
112
|
+
# Legacy alternative — the schemata endpoint returns HTTP 500 on
|
|
113
|
+
# Drupal 11 / PHP 8.4; prefer jsonapi_schema there.
|
|
114
|
+
# composer require drupal/schemata
|
|
115
|
+
# drush en schemata schemata_json_schema
|
|
116
|
+
# drush role:perm:add "content_editor" "access schemata data models"
|
|
85
117
|
```
|
|
86
118
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
endpoint
|
|
119
|
+
`jsonapi_schema` serves its routes under the JSON:API prefix (`/jsonapi/*`), so
|
|
120
|
+
the role/consumer used by dropsh needs no extra permission beyond JSON:API
|
|
121
|
+
access. (The legacy `schemata` endpoint instead needs `access schemata data
|
|
122
|
+
models` on that role.)
|
|
90
123
|
|
|
91
124
|
By default JSON:API only accepts read operations. To create, update, or delete
|
|
92
125
|
entities through dropsh, set **Accept all JSON:API create, read, update, and
|
|
@@ -95,16 +128,17 @@ delete operations** at `/admin/config/services/jsonapi` (or via
|
|
|
95
128
|
|
|
96
129
|
## Configuration
|
|
97
130
|
|
|
98
|
-
Create a `dropsh.config.js` in the
|
|
99
|
-
only non-secret connection settings. Passwords, client secrets, and tokens
|
|
100
|
-
prompted during login and stored outside the project in the per-host session
|
|
131
|
+
Create a `dropsh.config.js` in the directory where you run dropsh. The config
|
|
132
|
+
holds only non-secret connection settings. Passwords, client secrets, and tokens
|
|
133
|
+
are prompted during login and stored outside the project in the per-host session
|
|
101
134
|
store.
|
|
102
135
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
import
|
|
106
|
-
|
|
136
|
+
Register plugins as **descriptor objects** — `{ plugin, with?, export? }` — that
|
|
137
|
+
name a package by string. dropsh resolves and constructs each plugin itself, so
|
|
138
|
+
the config needs **no `import` statements** and **no local `node_modules`**. This
|
|
139
|
+
is the path a global install uses:
|
|
107
140
|
|
|
141
|
+
```js
|
|
108
142
|
export default {
|
|
109
143
|
site: {
|
|
110
144
|
base_url: "https://my-drupal.example.com",
|
|
@@ -114,6 +148,45 @@ export default {
|
|
|
114
148
|
dry_run: false,
|
|
115
149
|
timeout_ms: 30000,
|
|
116
150
|
},
|
|
151
|
+
plugins: [
|
|
152
|
+
{ plugin: "dropsh/plugin", export: "basicAuthPlugin" },
|
|
153
|
+
{
|
|
154
|
+
plugin: "@dropsh/plugin-oauth2",
|
|
155
|
+
export: "oauth2Plugin",
|
|
156
|
+
with: {
|
|
157
|
+
type: "oauth2_authcode",
|
|
158
|
+
client_id: "my-client",
|
|
159
|
+
token_url: "https://my-drupal.example.com/oauth/token",
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{ plugin: "@dropsh/plugin-jsonapi-schema", export: "jsonapiSchemaPlugin" },
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Each descriptor's `with` is passed to the plugin factory (legacy alias:
|
|
168
|
+
`options`). `export` names the factory to call and is **required** for every
|
|
169
|
+
`@dropsh/plugin-*` package — they export named factories with no default export
|
|
170
|
+
(see [Plugins](#plugins)). Basic auth lives on the `dropsh/plugin` entry, so its
|
|
171
|
+
descriptor names `dropsh/plugin` with `export: "basicAuthPlugin"`.
|
|
172
|
+
|
|
173
|
+
Use `dropsh.config.example.js` as a fuller starting point. Override the config
|
|
174
|
+
path with `--config <path>` or `DROPSH_CONFIG`.
|
|
175
|
+
|
|
176
|
+
<details>
|
|
177
|
+
<summary>Workspace variant — importing factories directly</summary>
|
|
178
|
+
|
|
179
|
+
Inside the pnpm workspace or any project that has the plugin packages in its
|
|
180
|
+
local `node_modules`, you may skip descriptors and import the factories instead.
|
|
181
|
+
The `plugins[]` array then holds constructed plugins:
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
import { basicAuthPlugin } from "dropsh/plugin";
|
|
185
|
+
import { oauth2Plugin } from "@dropsh/plugin-oauth2";
|
|
186
|
+
import { jsonapiSchemaPlugin } from "@dropsh/plugin-jsonapi-schema";
|
|
187
|
+
|
|
188
|
+
export default {
|
|
189
|
+
site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
|
|
117
190
|
plugins: [
|
|
118
191
|
basicAuthPlugin(),
|
|
119
192
|
oauth2Plugin({
|
|
@@ -121,13 +194,15 @@ export default {
|
|
|
121
194
|
client_id: "my-client",
|
|
122
195
|
token_url: "https://my-drupal.example.com/oauth/token",
|
|
123
196
|
}),
|
|
124
|
-
|
|
197
|
+
jsonapiSchemaPlugin(),
|
|
125
198
|
],
|
|
126
199
|
};
|
|
127
200
|
```
|
|
128
201
|
|
|
129
|
-
|
|
130
|
-
|
|
202
|
+
The two forms may be mixed in one `plugins[]` array. `import` resolves relative
|
|
203
|
+
to the config file, so it only works when the packages are installed there.
|
|
204
|
+
|
|
205
|
+
</details>
|
|
131
206
|
|
|
132
207
|
## Authentication
|
|
133
208
|
|
|
@@ -218,12 +293,12 @@ All commands write JSON to stdout, structured errors to stderr, and use stable
|
|
|
218
293
|
exit codes.
|
|
219
294
|
|
|
220
295
|
```bash
|
|
221
|
-
dropsh read <entity_type>/<bundle>/<uuid>
|
|
222
|
-
dropsh search <entity_type> [--bundle=<
|
|
223
|
-
dropsh create <entity_type> --bundle=<
|
|
224
|
-
dropsh update <entity_type>/<bundle>/<uuid> --data=<json|@file> [--dry-run]
|
|
296
|
+
dropsh read <entity_type>/<bundle>/<uuid> [--include=<field>,…]
|
|
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]
|
|
225
300
|
dropsh delete <entity_type>/<bundle>/<uuid> [--dry-run]
|
|
226
|
-
dropsh upload-file --target=<entity_type>/<bundle>/<uuid>/<field> --file=<path>
|
|
301
|
+
dropsh upload-file --target=<entity_type>/<bundle>/<uuid>/<field> --file=<path> [--dry-run]
|
|
227
302
|
dropsh schema [--refresh]
|
|
228
303
|
dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh]
|
|
229
304
|
```
|
|
@@ -231,6 +306,49 @@ dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh]
|
|
|
231
306
|
`create` and `update` validate payloads against the current schema by default.
|
|
232
307
|
Pass `--no-validate` when you intentionally want to skip local validation.
|
|
233
308
|
|
|
309
|
+
### `--include` on `read` / `search`
|
|
310
|
+
|
|
311
|
+
Pass `--include` to embed related resources in the response's JSON:API
|
|
312
|
+
`included` array (the standard `?include=` query parameter). Accepts a
|
|
313
|
+
comma-separated list or repeated/space-separated values, and nested paths with
|
|
314
|
+
dots:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
dropsh read node/article/<uuid> --include field_related,field_image
|
|
318
|
+
dropsh search node --bundle=article --include field_related --filter status:1
|
|
319
|
+
dropsh read node/article/<uuid> --include field_related.uid
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
`read` and `search` are the only commands that accept `--include`. Without it,
|
|
323
|
+
the response contains only the primary resource(s).
|
|
324
|
+
|
|
325
|
+
### Output formats
|
|
326
|
+
|
|
327
|
+
By default every command emits JSON. Load a renderer plugin and pass a global
|
|
328
|
+
`--format <id>` (before the subcommand) to render entities differently:
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
dropsh --format md read node/article/<uuid> # Markdown detail view
|
|
332
|
+
dropsh --format table search node --bundle=article # aligned table
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`--format` applies only to entity commands (`read`, `search`, `create`,
|
|
336
|
+
`update`). Using it on `delete`, `upload-file`, or `schema` exits with code 2.
|
|
337
|
+
|
|
338
|
+
The `@dropsh/plugin-tui` plugin adds an interactive full-screen format,
|
|
339
|
+
selected the same way via `--format tui` (requires a TTY — an interactive
|
|
340
|
+
terminal):
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
dropsh --format tui read node/article/<uuid> # opens straight into the detail pane
|
|
344
|
+
dropsh --format tui search node --bundle=article # list → Enter → detail pane
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
`read --format tui` boots directly into the detail pane for the single
|
|
348
|
+
entity. `search --format tui` shows a navigable list (↑/↓, Enter opens the
|
|
349
|
+
detail pane, Esc/q goes back or quits). `--include` flows through to the
|
|
350
|
+
detail pane in both cases.
|
|
351
|
+
|
|
234
352
|
## Plugins
|
|
235
353
|
|
|
236
354
|
Plugins are regular JavaScript or TypeScript modules that return a `dropshPlugin`.
|
|
@@ -248,14 +366,47 @@ structures, allowed values, and other metadata that helps the agent produce a
|
|
|
248
366
|
valid payload. Auth plugins keep the same command interface while supporting
|
|
249
367
|
common Drupal authentication systems such as basic auth or OAuth2.
|
|
250
368
|
|
|
251
|
-
|
|
369
|
+
Plugins developed in this repository (**"bundled"** = part of this repo, **not**
|
|
370
|
+
shipped inside the `dropsh` npm tarball — each is a separately published package):
|
|
252
371
|
|
|
253
|
-
| Package | Purpose |
|
|
254
|
-
| --- | --- |
|
|
255
|
-
| [`@dropsh/plugin-oauth2`](plugins/oauth2/README.md) | OAuth2 auth provider for Drupal `simple_oauth` |
|
|
256
|
-
| [`@dropsh/plugin-
|
|
257
|
-
| [`@dropsh/plugin-
|
|
258
|
-
| [`@dropsh/plugin-
|
|
372
|
+
| Package | Factory export | Purpose |
|
|
373
|
+
| --- | --- | --- |
|
|
374
|
+
| [`@dropsh/plugin-oauth2`](plugins/oauth2/README.md) | `oauth2Plugin` | OAuth2 auth provider for Drupal `simple_oauth` |
|
|
375
|
+
| [`@dropsh/plugin-jsonapi-schema`](plugins/jsonapi-schema/README.md) | `jsonapiSchemaPlugin` | authoritative JSON Schema from Drupal `jsonapi_schema` |
|
|
376
|
+
| [`@dropsh/plugin-schemata`](plugins/schemata/README.md) | `schemataPlugin` | authoritative JSON Schema from Drupal `schemata` (legacy) |
|
|
377
|
+
| [`@dropsh/plugin-canvas`](plugins/canvas/README.md) | `canvasPlugin` | Canvas component schemas from `jsonapi_sdc` |
|
|
378
|
+
| [`@dropsh/plugin-display-builder`](plugins/display-builder/README.md) | `displayBuilderPlugin` | Display Builder operation schemas and metadata |
|
|
379
|
+
|
|
380
|
+
Basic auth is not a separate package — its `basicAuthPlugin` factory ships in the
|
|
381
|
+
core CLI's `dropsh/plugin` entry.
|
|
382
|
+
|
|
383
|
+
### Installing and resolving plugins
|
|
384
|
+
|
|
385
|
+
`@dropsh/plugin-*` packages are **not** dependencies of `dropsh` and are **not**
|
|
386
|
+
part of its tarball, so a global dropsh does not carry them. Install the ones you
|
|
387
|
+
use next to dropsh:
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
npm i -g dropsh @dropsh/plugin-oauth2 # globally, alongside a global dropsh
|
|
391
|
+
npx -p dropsh -p @dropsh/plugin-oauth2 dropsh … # or one-off, no global install
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
A descriptor's `plugin` name is resolved like ESLint resolves its plugins, by
|
|
395
|
+
string name against these locations **in order**:
|
|
396
|
+
|
|
397
|
+
1. the directory of the `dropsh.config.js` file
|
|
398
|
+
2. the current working directory
|
|
399
|
+
3. dropsh's own install location
|
|
400
|
+
|
|
401
|
+
Location 3 is what lets a plugin installed globally next to a global `dropsh` be
|
|
402
|
+
found with no local project. If the name cannot be resolved from any of them,
|
|
403
|
+
dropsh reports which package to install.
|
|
404
|
+
|
|
405
|
+
`export` selects the factory to call and defaults to `"default"`. Every
|
|
406
|
+
`@dropsh/plugin-*` factory is a **named** export with **no default**, so a
|
|
407
|
+
descriptor for one of these packages **must** set `export` (e.g.
|
|
408
|
+
`export: "oauth2Plugin"` — see the table above). Basic auth uses
|
|
409
|
+
`{ plugin: "dropsh/plugin", export: "basicAuthPlugin" }`.
|
|
259
410
|
|
|
260
411
|
Plugin APIs are exported from `dropsh/plugin`:
|
|
261
412
|
|
|
@@ -273,15 +424,18 @@ export function examplePlugin(): dropshPlugin {
|
|
|
273
424
|
}
|
|
274
425
|
```
|
|
275
426
|
|
|
276
|
-
Register
|
|
427
|
+
Register a package plugin with a descriptor (no import needed), and a local
|
|
428
|
+
plugin either by descriptor (path name) or by importing it:
|
|
277
429
|
|
|
278
430
|
```js
|
|
279
|
-
import { basicAuthPlugin } from "dropsh";
|
|
280
431
|
import { examplePlugin } from "./plugins/example.js";
|
|
281
432
|
|
|
282
433
|
export default {
|
|
283
434
|
site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
|
|
284
|
-
plugins: [
|
|
435
|
+
plugins: [
|
|
436
|
+
{ plugin: "dropsh/plugin", export: "basicAuthPlugin" }, // packaged, resolved by name
|
|
437
|
+
examplePlugin(), // local module, imported directly
|
|
438
|
+
],
|
|
285
439
|
};
|
|
286
440
|
```
|
|
287
441
|
|
|
@@ -6,9 +6,9 @@ export async function runCreate(args, deps) {
|
|
|
6
6
|
await deps.validate(payload, target);
|
|
7
7
|
}
|
|
8
8
|
if (args.dryRun) {
|
|
9
|
-
deps.emit({ dry_run: true, method: "POST", path: target, payload });
|
|
9
|
+
await deps.emit({ dry_run: true, method: "POST", path: target, payload });
|
|
10
10
|
return;
|
|
11
11
|
}
|
|
12
12
|
const res = await deps.client.post(target, payload);
|
|
13
|
-
deps.emit(res);
|
|
13
|
+
await deps.emit(res);
|
|
14
14
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DrupalJsonApiParams } from "drupal-jsonapi-params";
|
|
1
2
|
import { ValidationError } from "../errors.js";
|
|
2
3
|
// Allow short test UUIDs (e.g. "u1") and full Drupal UUIDs (36 chars)
|
|
3
4
|
const TARGET_RE = /^[a-z0-9_]+\/[a-z0-9_]+\/[a-z0-9_-]+$/;
|
|
@@ -5,6 +6,14 @@ export async function runRead(args, deps) {
|
|
|
5
6
|
if (!TARGET_RE.test(args.target)) {
|
|
6
7
|
throw new ValidationError(`target must be <entity_type>/<bundle>/<uuid>, got "${args.target}"`);
|
|
7
8
|
}
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
let res;
|
|
10
|
+
if (args.include?.length) {
|
|
11
|
+
const params = new DrupalJsonApiParams();
|
|
12
|
+
params.addInclude(args.include);
|
|
13
|
+
res = await deps.client.get(args.target, params);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
res = await deps.client.get(args.target);
|
|
17
|
+
}
|
|
18
|
+
await deps.emit(res);
|
|
10
19
|
}
|
|
@@ -24,7 +24,9 @@ export async function runSearch(args, deps) {
|
|
|
24
24
|
params.addFilter(f.key, f.value);
|
|
25
25
|
}
|
|
26
26
|
params.addPageLimit(args.limit);
|
|
27
|
+
if (args.include?.length)
|
|
28
|
+
params.addInclude(args.include);
|
|
27
29
|
const path = args.bundle ? `${args.entityType}/${args.bundle}` : args.entityType;
|
|
28
30
|
const res = await deps.client.get(path, params);
|
|
29
|
-
deps.emit(res);
|
|
31
|
+
await deps.emit(res);
|
|
30
32
|
}
|
|
@@ -12,9 +12,9 @@ export async function runUpdate(args, deps) {
|
|
|
12
12
|
await deps.validate(payload, schemaTarget);
|
|
13
13
|
}
|
|
14
14
|
if (args.dryRun) {
|
|
15
|
-
deps.emit({ dry_run: true, method: "PATCH", path: args.target, payload });
|
|
15
|
+
await deps.emit({ dry_run: true, method: "PATCH", path: args.target, payload });
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
18
|
const res = await deps.client.patch(args.target, payload);
|
|
19
|
-
deps.emit(res);
|
|
19
|
+
await deps.emit(res);
|
|
20
20
|
}
|
|
@@ -1,8 +1,34 @@
|
|
|
1
|
-
import { CliError } from "../../errors.js";
|
|
1
|
+
import { CliError, ConfigError } from "../../errors.js";
|
|
2
|
+
import { isInteractive, } from "./render.js";
|
|
3
|
+
function isJsonApiDocument(v) {
|
|
4
|
+
return !!v && typeof v === "object" && "data" in v;
|
|
5
|
+
}
|
|
2
6
|
export function createOutput(opts) {
|
|
7
|
+
const registry = new Map();
|
|
8
|
+
for (const r of opts.renderers ?? []) {
|
|
9
|
+
if (registry.has(r.id))
|
|
10
|
+
throw new ConfigError(`Duplicate renderer id: ${r.id}`);
|
|
11
|
+
registry.set(r.id, r);
|
|
12
|
+
}
|
|
13
|
+
const getFormat = opts.getFormat ?? (() => "json");
|
|
3
14
|
return {
|
|
4
|
-
emit(value) {
|
|
5
|
-
|
|
15
|
+
emit(value, ctx, services) {
|
|
16
|
+
const fmt = getFormat();
|
|
17
|
+
if (fmt === "json" || !isJsonApiDocument(value)) {
|
|
18
|
+
opts.stdout(`${JSON.stringify(value)}\n`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const renderer = registry.get(fmt);
|
|
22
|
+
if (!renderer) {
|
|
23
|
+
throw new ConfigError(`Unknown format: ${fmt}`, {
|
|
24
|
+
available: ["json", ...registry.keys()],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (isInteractive(renderer)) {
|
|
28
|
+
return renderer.run(value, ctx ?? { command: "read" }, services);
|
|
29
|
+
}
|
|
30
|
+
const text = renderer.render(value, ctx ?? { command: "read" });
|
|
31
|
+
opts.stdout(text.endsWith("\n") ? text : `${text}\n`);
|
|
6
32
|
},
|
|
7
33
|
fail(err) {
|
|
8
34
|
const payload = err instanceof CliError
|
|
@@ -16,5 +42,8 @@ export function createOutput(opts) {
|
|
|
16
42
|
};
|
|
17
43
|
opts.stderr(`${JSON.stringify(payload)}\n`);
|
|
18
44
|
},
|
|
45
|
+
hasFormat(id) {
|
|
46
|
+
return id === "json" || registry.has(id);
|
|
47
|
+
},
|
|
19
48
|
};
|
|
20
49
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Indexes a document's `included` resources by `${type}/${id}` so renderers can
|
|
2
|
+
// resolve a relationship reference to its embedded resource (populated by
|
|
3
|
+
// `--include`). Empty map when the document carries no `included`.
|
|
4
|
+
export function indexIncluded(doc) {
|
|
5
|
+
const map = new Map();
|
|
6
|
+
for (const r of doc.included ?? []) {
|
|
7
|
+
map.set(`${r.type}/${r.id}`, r);
|
|
8
|
+
}
|
|
9
|
+
return map;
|
|
10
|
+
}
|
|
11
|
+
export function isInteractive(r) {
|
|
12
|
+
return "interactive" in r && r.interactive === true;
|
|
13
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { AuthError, ConfigError } from "../errors.js";
|
|
2
|
+
import { collectProviders, providerById } from "./auth/registry.js";
|
|
3
|
+
import { readSession, writeSession } from "./auth/session-store.js";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { createHttpClient } from "./http.js";
|
|
6
|
+
import { createJsonApiClient } from "./jsonapi/client.js";
|
|
7
|
+
export async function resolveAuth(deps) {
|
|
8
|
+
const rec = await readSession(deps.baseUrl, deps.stateDir);
|
|
9
|
+
if (!rec)
|
|
10
|
+
throw new AuthError("Not authenticated. Run 'dropsh auth login'.");
|
|
11
|
+
const provider = providerById(collectProviders(deps.plugins), rec.activeProvider);
|
|
12
|
+
if (!provider)
|
|
13
|
+
throw new ConfigError(`active provider '${rec.activeProvider}' is not configured`);
|
|
14
|
+
return provider.createAdapter(rec.session, {
|
|
15
|
+
http: deps.http,
|
|
16
|
+
now: deps.now,
|
|
17
|
+
save: (session) => writeSession(deps.baseUrl, provider.id, session, deps.stateDir),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function createCommandContext(configPath) {
|
|
21
|
+
const cfg = await loadConfig(configPath);
|
|
22
|
+
const http = createHttpClient({ timeoutMs: cfg.defaults.timeout_ms });
|
|
23
|
+
const auth = await resolveAuth({
|
|
24
|
+
baseUrl: cfg.site.base_url,
|
|
25
|
+
plugins: cfg.plugins,
|
|
26
|
+
http,
|
|
27
|
+
now: Date.now,
|
|
28
|
+
});
|
|
29
|
+
const client = createJsonApiClient({
|
|
30
|
+
baseUrl: cfg.site.base_url,
|
|
31
|
+
prefix: cfg.site.jsonapi_prefix,
|
|
32
|
+
http,
|
|
33
|
+
auth,
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
client,
|
|
37
|
+
http,
|
|
38
|
+
auth,
|
|
39
|
+
baseUrl: cfg.site.base_url,
|
|
40
|
+
jsonapiPrefix: cfg.site.jsonapi_prefix,
|
|
41
|
+
cwd: process.cwd(),
|
|
42
|
+
plugins: cfg.plugins,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -4,7 +4,13 @@ export async function fetchJsonSchema(deps) {
|
|
|
4
4
|
const { entity, bundle } = deps;
|
|
5
5
|
const target = { entity_type: entity, bundle };
|
|
6
6
|
const plugins = deps.plugins ?? [];
|
|
7
|
-
const ctx = {
|
|
7
|
+
const ctx = {
|
|
8
|
+
http: deps.http,
|
|
9
|
+
auth: deps.auth,
|
|
10
|
+
baseUrl: deps.baseUrl,
|
|
11
|
+
jsonapiPrefix: deps.jsonapiPrefix,
|
|
12
|
+
warn: deps.warn,
|
|
13
|
+
};
|
|
8
14
|
// Build heuristic base schema
|
|
9
15
|
let heuristicResult;
|
|
10
16
|
try {
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import Ajv from "ajv";
|
|
2
2
|
import draft6MetaSchema from "ajv/dist/refs/json-schema-draft-06.json" with { type: "json" };
|
|
3
3
|
import draft7MetaSchema from "ajv/dist/refs/json-schema-draft-07.json" with { type: "json" };
|
|
4
|
+
import addFormats from "ajv-formats";
|
|
4
5
|
import { ValidationError } from "../../errors.js";
|
|
5
6
|
export function validatePayload(schema, payload, target) {
|
|
6
7
|
const ajv = new Ajv({ allErrors: true, strict: false, validateSchema: false, logger: false });
|
|
8
|
+
// Enforce standard `format` keywords (uri, date-time, uuid, email, …) so a
|
|
9
|
+
// constraint-bearing schema (e.g. from the jsonapi-schema plugin) rejects a
|
|
10
|
+
// malformed value — such as an SCP-style git remote where a URI is required —
|
|
11
|
+
// client-side, before the request is sent. Schemas without `format` are
|
|
12
|
+
// unaffected.
|
|
13
|
+
addFormats(ajv);
|
|
7
14
|
// Schemata (Drupal module) may emit Draft-04 schemas that use `id` instead of `$id`.
|
|
8
15
|
// AJV v8 hard-throws on `id`; removing the keyword makes AJV silently ignore it.
|
|
9
16
|
ajv.removeKeyword("id");
|
package/dist/src/index.js
CHANGED
|
@@ -24,6 +24,17 @@ import { AuthError, ConfigError, exitCodeFor } from "./errors.js";
|
|
|
24
24
|
function resolveConfigPath(override) {
|
|
25
25
|
return override ?? process.env.DROPSH_CONFIG ?? "dropsh.config.js";
|
|
26
26
|
}
|
|
27
|
+
// Normalizes a variadic --include option into a flat, trimmed list of field
|
|
28
|
+
// names: splits any comma-containing entries (so both `--include a,b` and
|
|
29
|
+
// `--include a b` work) and drops empty strings.
|
|
30
|
+
export function normalizeInclude(raw) {
|
|
31
|
+
if (!raw)
|
|
32
|
+
return [];
|
|
33
|
+
return raw
|
|
34
|
+
.flatMap((entry) => entry.split(","))
|
|
35
|
+
.map((s) => s.trim())
|
|
36
|
+
.filter((s) => s.length > 0);
|
|
37
|
+
}
|
|
27
38
|
/**
|
|
28
39
|
* Choose the profile name to authenticate as. Precedence:
|
|
29
40
|
* explicit → stored `active` → config `default:true` → the sole provider.
|
|
@@ -131,7 +142,9 @@ export function buildProgram(opts = {}) {
|
|
|
131
142
|
.description("Entity-agnostic CLI for Drupal 11 JSON:API")
|
|
132
143
|
.version("0.0.0")
|
|
133
144
|
.option("--config <path>", "path to config file (overrides DROPSH_CONFIG)")
|
|
134
|
-
.option("--auth-profile <id>", "auth profile to use (overrides the active profile and $DROPSH_AUTH_PROFILE)")
|
|
145
|
+
.option("--auth-profile <id>", "auth profile to use (overrides the active profile and $DROPSH_AUTH_PROFILE)")
|
|
146
|
+
.option("--format <id>", "output format: json (default) or a renderer id", "json")
|
|
147
|
+
.option("--view-mode <name>", "entity view mode for interactive formats", "default");
|
|
135
148
|
const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
|
|
136
149
|
const stderr = opts.stderr ?? ((s) => process.stderr.write(s));
|
|
137
150
|
const setExitCode = opts.setExitCode ??
|
|
@@ -144,9 +157,16 @@ export function buildProgram(opts = {}) {
|
|
|
144
157
|
const profile = o.authProfile ?? process.env.DROPSH_AUTH_PROFILE;
|
|
145
158
|
return defaultContext(resolveConfigPath(o.config), profile);
|
|
146
159
|
});
|
|
147
|
-
const
|
|
148
|
-
|
|
160
|
+
const renderers = (opts.plugins ?? []).flatMap((p) => p.renderers ?? []);
|
|
161
|
+
const output = createOutput({
|
|
162
|
+
stdout,
|
|
163
|
+
stderr,
|
|
164
|
+
renderers,
|
|
165
|
+
getFormat: () => program.opts().format ?? "json",
|
|
166
|
+
});
|
|
167
|
+
async function run(fn, precheck) {
|
|
149
168
|
try {
|
|
169
|
+
precheck?.();
|
|
150
170
|
const ctx = await contextFactory();
|
|
151
171
|
await fn(ctx);
|
|
152
172
|
}
|
|
@@ -164,6 +184,21 @@ export function buildProgram(opts = {}) {
|
|
|
164
184
|
setExitCode(exitCodeFor(err));
|
|
165
185
|
}
|
|
166
186
|
}
|
|
187
|
+
function currentFormat() {
|
|
188
|
+
return program.opts().format ?? "json";
|
|
189
|
+
}
|
|
190
|
+
function assertRenderable() {
|
|
191
|
+
const f = currentFormat();
|
|
192
|
+
if (!output.hasFormat(f)) {
|
|
193
|
+
throw new ConfigError(`Unknown format '${f}'. Available: ${["json", ...renderers.map((r) => r.id)].join(", ")}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function assertJsonOnly(command) {
|
|
197
|
+
const f = currentFormat();
|
|
198
|
+
if (f !== "json") {
|
|
199
|
+
throw new ConfigError(`format '${f}' not applicable to command '${command}'`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
167
202
|
async function authDeps() {
|
|
168
203
|
const cfg = await loadConfig(resolveConfigPath(program.opts().config));
|
|
169
204
|
return {
|
|
@@ -224,19 +259,48 @@ export function buildProgram(opts = {}) {
|
|
|
224
259
|
program
|
|
225
260
|
.command("read <target>")
|
|
226
261
|
.description("Read entity_type/bundle/uuid")
|
|
227
|
-
.
|
|
262
|
+
.option("--include <fields...>", "related fields to include (JSON:API include)")
|
|
263
|
+
.action((target, o) => {
|
|
264
|
+
const [entityType, bundle] = target.split("/");
|
|
265
|
+
const rctx = { command: "read", target };
|
|
266
|
+
if (entityType !== undefined)
|
|
267
|
+
rctx.entityType = entityType;
|
|
268
|
+
if (bundle !== undefined)
|
|
269
|
+
rctx.bundle = bundle;
|
|
270
|
+
rctx.viewMode = program.opts().viewMode;
|
|
271
|
+
// biome-ignore lint/suspicious/noExplicitAny: optional include added conditionally
|
|
272
|
+
const args = { target };
|
|
273
|
+
const include = normalizeInclude(o.include);
|
|
274
|
+
if (include.length > 0)
|
|
275
|
+
args.include = include;
|
|
276
|
+
return run((ctx) => runRead(args, {
|
|
277
|
+
client: ctx.client,
|
|
278
|
+
emit: (v) => output.emit(v, rctx, { client: ctx.client, baseUrl: ctx.baseUrl }),
|
|
279
|
+
}), assertRenderable);
|
|
280
|
+
});
|
|
228
281
|
program
|
|
229
282
|
.command("search <entity_type>")
|
|
230
283
|
.description("Search entities with filters")
|
|
231
284
|
.option("--bundle <bundle>")
|
|
232
285
|
.option("--filter <kv...>", "filter in key:value or key:op:value form", [])
|
|
233
286
|
.option("--limit <n>", "max results", (v) => parseInt(v, 10), 50)
|
|
287
|
+
.option("--include <fields...>", "related fields to include (JSON:API include)")
|
|
234
288
|
.action((entityType, o) => {
|
|
235
|
-
// biome-ignore lint/suspicious/noExplicitAny: optional bundle added conditionally
|
|
289
|
+
// biome-ignore lint/suspicious/noExplicitAny: optional bundle/include added conditionally
|
|
236
290
|
const args = { entityType, filters: o.filter, limit: o.limit };
|
|
237
291
|
if (o.bundle !== undefined)
|
|
238
292
|
args.bundle = o.bundle;
|
|
239
|
-
|
|
293
|
+
const include = normalizeInclude(o.include);
|
|
294
|
+
if (include.length > 0)
|
|
295
|
+
args.include = include;
|
|
296
|
+
const rctx = { command: "search", entityType };
|
|
297
|
+
if (o.bundle !== undefined)
|
|
298
|
+
rctx.bundle = o.bundle;
|
|
299
|
+
rctx.viewMode = program.opts().viewMode;
|
|
300
|
+
return run((ctx) => runSearch(args, {
|
|
301
|
+
client: ctx.client,
|
|
302
|
+
emit: (v) => output.emit(v, rctx, { client: ctx.client, baseUrl: ctx.baseUrl }),
|
|
303
|
+
}), assertRenderable);
|
|
240
304
|
});
|
|
241
305
|
program
|
|
242
306
|
.command("create <entity_type>")
|
|
@@ -251,8 +315,9 @@ export function buildProgram(opts = {}) {
|
|
|
251
315
|
args.dryRun = o.dryRun;
|
|
252
316
|
if (o.validate === false)
|
|
253
317
|
args.noValidate = true;
|
|
254
|
-
run(async (ctx) => {
|
|
255
|
-
const
|
|
318
|
+
return run(async (ctx) => {
|
|
319
|
+
const rctx = { command: "create", entityType, bundle: o.bundle };
|
|
320
|
+
const deps = { client: ctx.client, emit: (v) => output.emit(v, rctx) };
|
|
256
321
|
if (!args.noValidate) {
|
|
257
322
|
deps.validate = async (payload, target) => {
|
|
258
323
|
const schema = await loadOrFetchSchema(ctx, target, "create");
|
|
@@ -260,7 +325,7 @@ export function buildProgram(opts = {}) {
|
|
|
260
325
|
};
|
|
261
326
|
}
|
|
262
327
|
await runCreate(args, deps);
|
|
263
|
-
});
|
|
328
|
+
}, assertRenderable);
|
|
264
329
|
});
|
|
265
330
|
program
|
|
266
331
|
.command("update <target>")
|
|
@@ -274,8 +339,14 @@ export function buildProgram(opts = {}) {
|
|
|
274
339
|
args.dryRun = o.dryRun;
|
|
275
340
|
if (o.validate === false)
|
|
276
341
|
args.noValidate = true;
|
|
277
|
-
run(async (ctx) => {
|
|
278
|
-
const
|
|
342
|
+
return run(async (ctx) => {
|
|
343
|
+
const [entityType, bundle] = target.split("/");
|
|
344
|
+
const rctx = { command: "update", target };
|
|
345
|
+
if (entityType !== undefined)
|
|
346
|
+
rctx.entityType = entityType;
|
|
347
|
+
if (bundle !== undefined)
|
|
348
|
+
rctx.bundle = bundle;
|
|
349
|
+
const deps = { client: ctx.client, emit: (v) => output.emit(v, rctx) };
|
|
279
350
|
if (!args.noValidate) {
|
|
280
351
|
deps.validate = async (payload, t) => {
|
|
281
352
|
const schema = await loadOrFetchSchema(ctx, t, "update");
|
|
@@ -283,7 +354,7 @@ export function buildProgram(opts = {}) {
|
|
|
283
354
|
};
|
|
284
355
|
}
|
|
285
356
|
await runUpdate(args, deps);
|
|
286
|
-
});
|
|
357
|
+
}, assertRenderable);
|
|
287
358
|
});
|
|
288
359
|
program
|
|
289
360
|
.command("delete <target>")
|
|
@@ -294,7 +365,7 @@ export function buildProgram(opts = {}) {
|
|
|
294
365
|
const args = { target };
|
|
295
366
|
if (o.dryRun !== undefined)
|
|
296
367
|
args.dryRun = o.dryRun;
|
|
297
|
-
run((ctx) => runDelete(args, { client: ctx.client, emit: output.emit }));
|
|
368
|
+
return run((ctx) => runDelete(args, { client: ctx.client, emit: output.emit }), () => assertJsonOnly("delete"));
|
|
298
369
|
});
|
|
299
370
|
program
|
|
300
371
|
.command("upload-file")
|
|
@@ -307,7 +378,7 @@ export function buildProgram(opts = {}) {
|
|
|
307
378
|
const args = { target: o.target, file: o.file };
|
|
308
379
|
if (o.dryRun !== undefined)
|
|
309
380
|
args.dryRun = o.dryRun;
|
|
310
|
-
run((ctx) => runUploadFile(args, { client: ctx.client, emit: output.emit }));
|
|
381
|
+
return run((ctx) => runUploadFile(args, { client: ctx.client, emit: output.emit }), () => assertJsonOnly("upload-file"));
|
|
311
382
|
});
|
|
312
383
|
program
|
|
313
384
|
.command("schema [target]")
|
|
@@ -319,7 +390,7 @@ export function buildProgram(opts = {}) {
|
|
|
319
390
|
const schemaArgs = target !== undefined
|
|
320
391
|
? { target, operation, refresh: Boolean(o.refresh) }
|
|
321
392
|
: { operation, refresh: Boolean(o.refresh) };
|
|
322
|
-
run((ctx) => runSchema(schemaArgs, {
|
|
393
|
+
return run((ctx) => runSchema(schemaArgs, {
|
|
323
394
|
http: ctx.http,
|
|
324
395
|
auth: ctx.auth,
|
|
325
396
|
baseUrl: ctx.baseUrl,
|
|
@@ -328,7 +399,7 @@ export function buildProgram(opts = {}) {
|
|
|
328
399
|
emit: output.emit,
|
|
329
400
|
warn: (m) => stderr(`${m}\n`),
|
|
330
401
|
plugins: ctx.plugins,
|
|
331
|
-
}));
|
|
402
|
+
}), () => assertJsonOnly("schema"));
|
|
332
403
|
});
|
|
333
404
|
const auth = program.command("auth").description("Manage authentication");
|
|
334
405
|
auth
|
package/dist/src/plugin-api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { basicAuthPlugin, basicAuthProvider } from "./core/auth/basic.js";
|
|
2
|
+
export { indexIncluded, isInteractive } from "./core/cli/render.js";
|
|
2
3
|
export { loadConfig } from "./core/config.js";
|
|
3
4
|
export { createHttpClient } from "./core/http.js";
|
|
4
5
|
export { createJsonApiClient } from "./core/jsonapi/client.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dropsh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.2",
|
|
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": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"ajv": "^8.18.0",
|
|
37
|
+
"ajv-formats": "^3.0.1",
|
|
37
38
|
"commander": "^12.1.0",
|
|
38
39
|
"drupal-jsonapi-params": "^3.0.1",
|
|
39
40
|
"open": "^11.0.0"
|
|
@@ -62,6 +63,7 @@
|
|
|
62
63
|
"lint:fix": "biome check --write src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
|
|
63
64
|
"drupal:up": "bash tests/integrations/bin/drupal-up.sh",
|
|
64
65
|
"drupal:down": "bash tests/integrations/bin/drupal-down.sh",
|
|
66
|
+
"init-worktree": "node scripts/init-worktree.mjs",
|
|
65
67
|
"release": "node scripts/release.mjs"
|
|
66
68
|
}
|
|
67
69
|
}
|