dropsh 0.5.1 → 0.5.3

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
 
@@ -104,16 +128,17 @@ delete operations** at `/admin/config/services/jsonapi` (or via
104
128
 
105
129
  ## Configuration
106
130
 
107
- Create a `dropsh.config.js` in the project where you run dropsh. The config holds
108
- only non-secret connection settings. Passwords, client secrets, and tokens are
109
- 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
110
134
  store.
111
135
 
112
- ```js
113
- import { basicAuthPlugin } from "dropsh";
114
- import { oauth2Plugin } from "@dropsh/plugin-oauth2";
115
- import { jsonapiSchemaPlugin } from "@dropsh/plugin-jsonapi-schema";
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:
116
140
 
141
+ ```js
117
142
  export default {
118
143
  site: {
119
144
  base_url: "https://my-drupal.example.com",
@@ -123,6 +148,45 @@ export default {
123
148
  dry_run: false,
124
149
  timeout_ms: 30000,
125
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" },
126
190
  plugins: [
127
191
  basicAuthPlugin(),
128
192
  oauth2Plugin({
@@ -135,8 +199,10 @@ export default {
135
199
  };
136
200
  ```
137
201
 
138
- Use `dropsh.config.example.js` as a fuller starting point. Override the config
139
- 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>
140
206
 
141
207
  ## Authentication
142
208
 
@@ -227,12 +293,12 @@ All commands write JSON to stdout, structured errors to stderr, and use stable
227
293
  exit codes.
228
294
 
229
295
  ```bash
230
- dropsh read <entity_type>/<bundle>/<uuid>
231
- dropsh search <entity_type> [--bundle=<bundle>] [--filter=key:value] [--limit=N]
232
- dropsh create <entity_type> --bundle=<bundle> --data=<json|@file> [--dry-run]
233
- 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]
234
300
  dropsh delete <entity_type>/<bundle>/<uuid> [--dry-run]
235
- 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]
236
302
  dropsh schema [--refresh]
237
303
  dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh]
238
304
  ```
@@ -240,6 +306,49 @@ dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh]
240
306
  `create` and `update` validate payloads against the current schema by default.
241
307
  Pass `--no-validate` when you intentionally want to skip local validation.
242
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
+
243
352
  ## Plugins
244
353
 
245
354
  Plugins are regular JavaScript or TypeScript modules that return a `dropshPlugin`.
@@ -257,14 +366,47 @@ structures, allowed values, and other metadata that helps the agent produce a
257
366
  valid payload. Auth plugins keep the same command interface while supporting
258
367
  common Drupal authentication systems such as basic auth or OAuth2.
259
368
 
260
- 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):
261
371
 
262
- | Package | Purpose |
263
- | --- | --- |
264
- | [`@dropsh/plugin-oauth2`](plugins/oauth2/README.md) | OAuth2 auth provider for Drupal `simple_oauth` |
265
- | [`@dropsh/plugin-schemata`](plugins/schemata/README.md) | authoritative JSON Schema source from Drupal `schemata` |
266
- | [`@dropsh/plugin-canvas`](plugins/canvas/README.md) | Canvas component schemas from `jsonapi_sdc` |
267
- | [`@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" }`.
268
410
 
269
411
  Plugin APIs are exported from `dropsh/plugin`:
270
412
 
@@ -282,15 +424,18 @@ export function examplePlugin(): dropshPlugin {
282
424
  }
283
425
  ```
284
426
 
285
- 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:
286
429
 
287
430
  ```js
288
- import { basicAuthPlugin } from "dropsh";
289
431
  import { examplePlugin } from "./plugins/example.js";
290
432
 
291
433
  export default {
292
434
  site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
293
- plugins: [basicAuthPlugin(), examplePlugin()],
435
+ plugins: [
436
+ { plugin: "dropsh/plugin", export: "basicAuthPlugin" }, // packaged, resolved by name
437
+ examplePlugin(), // local module, imported directly
438
+ ],
294
439
  };
295
440
  ```
296
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
+ }
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.5.1",
3
+ "version": "0.5.3",
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": {
@@ -63,6 +63,7 @@
63
63
  "lint:fix": "biome check --write src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
64
64
  "drupal:up": "bash tests/integrations/bin/drupal-up.sh",
65
65
  "drupal:down": "bash tests/integrations/bin/drupal-down.sh",
66
+ "init-worktree": "node scripts/init-worktree.mjs",
66
67
  "release": "node scripts/release.mjs"
67
68
  }
68
69
  }