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 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 | `schemata`, `schemata_json_schema` |
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 the Schemata modules. In that case, `dropsh schema` falls
63
- back to a shallow schema inferred from JSON:API sample records. Installing
64
- `schemata` and `schemata_json_schema` lets `@dropsh/plugin-schemata` return a
65
- more precise schema with Drupal's required fields and constraints.
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 (schemata_json_schema is a submodule
81
- # of the schemata project)
82
- composer require drupal/schemata
83
- drush en schemata schemata_json_schema
84
- drush role:perm:add "content_editor" "access schemata data models"
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
- Grant `access schemata data models` to the role used by dropsh. For OAuth2, that
88
- means the user role or client credential access context that reads the Schemata
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 project where you run dropsh. The config holds
99
- only non-secret connection settings. Passwords, client secrets, and tokens are
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
- ```js
104
- import { basicAuthPlugin } from "dropsh";
105
- import { oauth2Plugin } from "@dropsh/plugin-oauth2";
106
- import { schemataPlugin } from "@dropsh/plugin-schemata";
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
- schemataPlugin(),
197
+ jsonapiSchemaPlugin(),
125
198
  ],
126
199
  };
127
200
  ```
128
201
 
129
- Use `dropsh.config.example.js` as a fuller starting point. Override the config
130
- path with `--config <path>` or `DROPSH_CONFIG`.
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=<bundle>] [--filter=key:value] [--limit=N]
223
- dropsh create <entity_type> --bundle=<bundle> --data=<json|@file> [--dry-run]
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
- Bundled plugins:
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-schemata`](plugins/schemata/README.md) | authoritative JSON Schema source from Drupal `schemata` |
257
- | [`@dropsh/plugin-canvas`](plugins/canvas/README.md) | Canvas component schemas from `jsonapi_sdc` |
258
- | [`@dropsh/plugin-display-builder`](plugins/display-builder/README.md) | Display Builder operation schemas and metadata |
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 local or package plugins in `dropsh.config.js`:
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: [basicAuthPlugin(), examplePlugin()],
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
- const res = await deps.client.get(args.target);
9
- deps.emit(res);
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
- opts.stdout(`${JSON.stringify(value)}\n`);
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 = { http: deps.http, auth: deps.auth, baseUrl: deps.baseUrl };
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 output = createOutput({ stdout, stderr });
148
- async function run(fn) {
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
- .action((target) => run((ctx) => runRead({ target }, { client: ctx.client, emit: output.emit })));
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
- run((ctx) => runSearch(args, { client: ctx.client, emit: output.emit }));
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 deps = { client: ctx.client, emit: output.emit };
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 deps = { client: ctx.client, emit: output.emit };
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
@@ -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.4.1",
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
  }