dropsh 0.0.2 → 0.1.0

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
@@ -1,344 +1,245 @@
1
1
  # dropsh
2
2
 
3
- Entity-agnostic helper CLI for Drupal 11 JSON:API. Written in TypeScript, used directly by the `dropsh` Claude skill for editorial publishing workflows.
4
-
5
- This is the foundation CLI. Discovery, schema generation, and the skill layer are delivered by follow-up plans.
3
+ dropsh is a CLI for Drupal JSON:API with JSON Schema output and request
4
+ validation. It is meant to give agents enough structure to build reliable Drupal
5
+ payloads instead of guessing field names, required properties, component shapes,
6
+ or authentication details.
7
+
8
+ The CLI can read and search existing content, print schemas for create and update
9
+ payloads, validate data locally, and then send the JSON:API request. Plugins can
10
+ extend those schemas with site-specific fields, component metadata, builder
11
+ constraints, and other context the agent needs to do the job. Authentication for
12
+ common Drupal setups is handled through plugins as well.
13
+
14
+ ## Concept
15
+
16
+ dropsh is built for workflows where an agent or script needs to work with Drupal
17
+ content without hard-coding every content model.
18
+
19
+ - `dropsh schema` returns JSON Schema for targets like `node/article`.
20
+ - `create` and `update` validate payloads before sending them to Drupal.
21
+ - Agents can inspect existing records with `search` and `read` before generating
22
+ new data.
23
+ - Plugins can replace shallow inferred schemas with authoritative Drupal schemas
24
+ and enrich payload schemas with the extra context an agent needs.
25
+ - Auth plugins support common Drupal authentication systems without changing the
26
+ commands that use them.
27
+ - A skill can use dropsh as its Drupal execution layer and focus on the workflow.
6
28
 
7
29
  ## Install
8
30
 
9
- This is a [pnpm](https://pnpm.io) workspace (root CLI + `plugins/*` + `packages/*`).
10
- The pinned pnpm version is declared in `package.json` (`packageManager`); enable it with
11
- `corepack enable`.
31
+ This repository is a pnpm workspace containing the root CLI, packages, and
32
+ plugins.
12
33
 
13
34
  ```bash
35
+ corepack enable
14
36
  pnpm install
15
37
  pnpm run build
16
38
  ```
17
39
 
18
- ## Configure
19
-
20
- Copy `dropsh.config.example.js` to `dropsh.config.js` in your project. The config
21
- carries only **non-secret connection parameters** — secrets (passwords, client
22
- secrets, tokens) are never stored in the config. They are prompted at login and
23
- persisted separately (see [Authentication](#authentication)).
24
-
25
- ```js
26
- import { basicAuthPlugin } from "dropsh";
27
- import { oauth2Plugin } from "@dropsh/plugin-oauth2";
28
-
29
- export default {
30
- site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
31
- defaults: { dry_run: false, timeout_ms: 30000 },
32
- plugins: [
33
- basicAuthPlugin(),
34
- oauth2Plugin({ type: "oauth2_authcode", client_id: "my-client", token_url: "https://my-drupal.example.com/oauth/token" }),
35
- ],
36
- };
37
- ```
38
-
39
- ## Authentication
40
-
41
- Authentication is provider-based. Each entry in `plugins` that contributes an auth
42
- provider (`basicAuthPlugin()`, `oauth2Plugin(...)`) becomes a login option. You log
43
- in once per host; dropsh stores a single active session and uses it for every
44
- subsequent command.
40
+ The required Node.js and pinned pnpm versions are declared in `package.json`.
45
41
 
46
- ```bash
47
- dropsh auth login # interactive picker over the configured providers
48
- dropsh auth login --provider basic # skip the picker and use a provider by id
49
- dropsh auth logout # clear the active session for the host
50
- dropsh auth status # show the active session
51
- dropsh auth status --json # machine-readable status
52
- ```
42
+ ## Drupal Prerequisites
53
43
 
54
- `dropsh auth login` prompts for any secrets the chosen provider needs (basic auth
55
- prompts username + password; OAuth2 password / client-credentials prompt the client
56
- secret and, for the password grant, the user password; `oauth2_authcode` opens the
57
- browser for a PKCE flow). On a non-interactive (non-TTY) shell you must pass
58
- `--provider <id>`.
44
+ The base CLI expects a Drupal site with JSON:API enabled and reachable from the
45
+ machine running dropsh.
59
46
 
60
- ### Where secrets live
47
+ Minimum Drupal setup:
61
48
 
62
- Secrets and tokens are **never** written to `dropsh.config.js`. The active session
63
- is stored per host at `~/.config/dropsh/<host>.json` with file mode `0600`:
49
+ - Drupal 10 or 11
50
+ - JSON:API enabled
51
+ - A user or OAuth client with permissions for the entities you want to access
64
52
 
65
- ```json
66
- { "activeProvider": "oauth2_authcode", "session": { "access_token": "…", "expires_at": 1717459200000 } }
67
- ```
53
+ Optional modules unlock stricter schemas or builder-specific support:
68
54
 
69
- Only non-secret connection parameters stay in the config:
55
+ | Feature | Drupal modules |
56
+ | --- | --- |
57
+ | OAuth2 login | `simple_oauth` |
58
+ | Authoritative schemas | `schemata`, `schemata_json_schema` |
59
+ | Canvas schemas | `canvas`, `jsonapi_sdc` |
60
+ | Display Builder schemas | `display_builder`, `display_builder_entity_view`, `jsonapi_sdc` |
70
61
 
71
- - **basic** — `basicAuthPlugin()` (no fields; an optional `username` may be
72
- pre-seeded, but the password is always prompted)
73
- - **oauth2** — `client_id`, `token_url`, optional `scope`; `redirect_port` for
74
- `oauth2_authcode`; `username` for `oauth2_password`. The `client_secret` and the
75
- user `password` are prompted at login, never stored in config.
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.
76
66
 
77
- ## Commands
67
+ ### Installing the Drupal modules
78
68
 
79
- All commands write JSON to stdout, structured errors to stderr, and use exit codes 0-5.
69
+ JSON:API ships with Drupal core; enable it together with the optional modules
70
+ you need:
80
71
 
81
72
  ```bash
82
- dropsh read <entity_type>/<bundle>/<uuid>
83
- dropsh search <entity_type> [--bundle=<b>] [--filter=key:value]… [--limit=N]
84
- dropsh create <entity_type> --bundle=<b> --data=<json|@file> [--dry-run] [--no-validate]
85
- dropsh update <entity_type>/<bundle>/<uuid> --data=<json|@file> [--dry-run] [--no-validate]
86
- dropsh delete <entity_type>/<bundle>/<uuid> [--dry-run]
87
- dropsh upload-file --target=<entity_type>/<bundle>/<uuid>/<field> --file=<path> [--dry-run]
88
- dropsh schema [--refresh]
89
- dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh]
73
+ # Minimum: JSON:API (Drupal core)
74
+ drush en jsonapi
75
+
76
+ # Optional: OAuth2 login
77
+ composer require drupal/simple_oauth
78
+ drush en simple_oauth
79
+
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"
90
85
  ```
91
86
 
92
- ### `schema`
93
-
94
- Without a target, prints the list of available `<entity_type>/<bundle>` targets on the site.
95
-
96
- With a target (e.g. `node/article`), prints a JSON Schema document that validates a JSON:API request body for that resource.
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.
97
90
 
98
- - `--for=create` (default) / `--for=update` — operation variant (update clears required fields)
99
- - `--refresh` — bypass the cache for this call
91
+ By default JSON:API only accepts read operations. To create, update, or delete
92
+ entities through dropsh, set **Accept all JSON:API create, read, update, and
93
+ delete operations** at `/admin/config/services/jsonapi` (or via
94
+ `drush config:set jsonapi.settings read_only 0`).
100
95
 
101
- If the site has `drupal/schemata` + `drupal/schemata_json_schema` installed, the schema is authoritative (required fields, constraints). Otherwise, a shallow schema is returned from sample records with a warning on stderr (field names only, no required fields, no constraints). The output carries `x-dropsh-source: "schemata" | "heuristic" | "heuristic-empty"` so consumers can tell how strict the schema is.
96
+ ## Configuration
102
97
 
103
- Schemas are cached under `.dropsh/cache/`. Override the config path via `DROPSH_CONFIG` or `--config`.
104
-
105
- ### Authoritative schemas — `@dropsh/plugin-schemata`
106
-
107
- Without a schema plugin, the schema is derived from sample records (field names
108
- only). [`@dropsh/plugin-schemata`](plugins/schemata/README.md) returns the
109
- authoritative schema (required fields + constraints) from Drupal's `schemata`
110
- module, which makes client-side validation in `create` / `update` meaningful.
111
- Requires the Drupal modules `schemata` + `jsonapi_schema`.
112
-
113
- ```js
114
- import { schemataPlugin } from "@dropsh/plugin-schemata";
115
-
116
- export default {
117
- site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
118
- plugins: [basicAuthPlugin(), schemataPlugin()],
119
- };
120
- ```
121
-
122
- The output's `x-dropsh-source` is `"schemata"` when authoritative, or
123
- `"heuristic"` / `"heuristic-empty"` for the sample-derived fallback.
124
-
125
- ### Canvas schema plugin
126
-
127
- Canvas support lives in `@dropsh/plugin-canvas` and requires these Drupal modules:
128
-
129
- - `canvas`
130
- - `jsonapi_sdc`
131
-
132
- Register the plugin in `dropsh.config.js`:
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
101
+ store.
133
102
 
134
103
  ```js
135
104
  import { basicAuthPlugin } from "dropsh";
136
- import { canvasPlugin } from "@dropsh/plugin-canvas";
105
+ import { oauth2Plugin } from "@dropsh/plugin-oauth2";
106
+ import { schemataPlugin } from "@dropsh/plugin-schemata";
137
107
 
138
108
  export default {
139
- site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
109
+ site: {
110
+ base_url: "https://my-drupal.example.com",
111
+ jsonapi_prefix: "/jsonapi",
112
+ },
113
+ defaults: {
114
+ dry_run: false,
115
+ timeout_ms: 30000,
116
+ },
140
117
  plugins: [
141
118
  basicAuthPlugin(),
142
- canvasPlugin(),
119
+ oauth2Plugin({
120
+ type: "oauth2_authcode",
121
+ client_id: "my-client",
122
+ token_url: "https://my-drupal.example.com/oauth/token",
123
+ }),
124
+ schemataPlugin(),
143
125
  ],
144
126
  };
145
127
  ```
146
128
 
147
- Then ask for the Canvas schema:
129
+ Use `dropsh.config.example.js` as a fuller starting point. Override the config
130
+ path with `--config <path>` or `DROPSH_CONFIG`.
131
+
132
+ ## Authentication
133
+
134
+ Authentication is configured through plugins. Each plugin that contributes an
135
+ auth provider appears as a login option.
148
136
 
149
137
  ```bash
150
- dropsh schema canvas_page/canvas_page --for=create
138
+ dropsh auth login
139
+ dropsh auth login --provider basic
140
+ dropsh auth status
141
+ dropsh auth logout
151
142
  ```
152
143
 
153
- The returned JSON Schema includes `x-dropsh-builder: "canvas"`, `x-dropsh-components`, and a typed `attributes.components` array. If `jsonapi_sdc` is missing or inaccessible, the Canvas schema fails instead of returning incomplete component information.
144
+ Secrets and tokens are never written to `dropsh.config.js`. The active session is
145
+ stored per host under `~/.config/dropsh/<host>.json` with file mode `0600`.
154
146
 
155
- ### Client-side validation in `create` / `update`
147
+ ## Commands
156
148
 
157
- `dropsh create` and `dropsh update` run the payload through the bundle's schema before sending it. A validation failure exits with code 4 (`E_VALIDATION`) and emits the Ajv errors on stderr without issuing an HTTP request. Pass `--no-validate` to skip the check.
149
+ All commands write JSON to stdout, structured errors to stderr, and use stable
150
+ exit codes.
158
151
 
159
- ## Writing plugins
152
+ ```bash
153
+ dropsh read <entity_type>/<bundle>/<uuid>
154
+ dropsh search <entity_type> [--bundle=<bundle>] [--filter=key:value] [--limit=N]
155
+ dropsh create <entity_type> --bundle=<bundle> --data=<json|@file> [--dry-run]
156
+ dropsh update <entity_type>/<bundle>/<uuid> --data=<json|@file> [--dry-run]
157
+ dropsh delete <entity_type>/<bundle>/<uuid> [--dry-run]
158
+ dropsh upload-file --target=<entity_type>/<bundle>/<uuid>/<field> --file=<path>
159
+ dropsh schema [--refresh]
160
+ dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh]
161
+ ```
160
162
 
161
- A plugin is a function that returns a `DropSHPlugin`. Plugins can do three
162
- independent things — extend schemas, provide an auth provider, and register CLI
163
- commands — and a single plugin may combine any of them. Everything you need is
164
- re-exported from `dropsh/plugin`:
163
+ `create` and `update` validate payloads against the current schema by default.
164
+ Pass `--no-validate` when you intentionally want to skip local validation.
165
165
 
166
- ```ts
167
- import type {
168
- DropSHPlugin, PluginContext, SchemaOperation, // schema extension
169
- AuthProvider, AuthContext, AuthSession, AdapterRuntime, AuthAdapter, // auth
170
- } from "dropsh/plugin";
171
- import { AuthError, ConfigError, HttpError, createHttpClient } from "dropsh/plugin";
172
- ```
166
+ ## Plugins
173
167
 
174
- The `DropSHPlugin` shape:
168
+ Plugins are regular JavaScript or TypeScript modules that return a `dropshPlugin`.
169
+ A plugin can contribute one or more of these capabilities:
175
170
 
176
- ```ts
177
- interface DropSHPlugin {
178
- readonly id: string; // unique id, also the auth-provider key
179
- readonly requiredModules: string[]; // Drupal modules the plugin needs
180
- authProvider?: AuthProvider; // optional: contributes a login option
181
- extendSchema(entityType, bundle, baseSchema, ctx): Promise<unknown>;
182
- extendOperationSchema?(entityType, bundle, operation, schema, ctx): Promise<unknown>;
183
- registerCommands?(program): void; // optional: add Commander subcommands
184
- }
185
- ```
171
+ - an auth provider
172
+ - schema extensions
173
+ - operation-specific schema changes for `create` or `update`
174
+ - additional CLI commands
175
+
176
+ Schema plugins are the main way to make dropsh useful for agents on real sites.
177
+ dropsh can generate a basic schema without them, but plugins can make that schema
178
+ more precise by adding required fields, component definitions, builder-specific
179
+ structures, allowed values, and other metadata that helps the agent produce a
180
+ valid payload. Auth plugins keep the same command interface while supporting
181
+ common Drupal authentication systems such as basic auth or OAuth2.
186
182
 
187
- A plugin is wired up by adding it to `plugins` in `dropsh.config.js`. Package it
188
- as its own npm module with `dropsh` as a **peer dependency** (so it shares the
189
- host's core), or keep it local and import it directly.
183
+ Bundled plugins:
190
184
 
191
- ### A schema-extension plugin
185
+ | Package | Purpose |
186
+ | --- | --- |
187
+ | [`@dropsh/plugin-oauth2`](plugins/oauth2/README.md) | OAuth2 auth provider for Drupal `simple_oauth` |
188
+ | [`@dropsh/plugin-schemata`](plugins/schemata/README.md) | authoritative JSON Schema source from Drupal `schemata` |
189
+ | [`@dropsh/plugin-canvas`](plugins/canvas/README.md) | Canvas component schemas from `jsonapi_sdc` |
190
+ | [`@dropsh/plugin-display-builder`](plugins/display-builder/README.md) | Display Builder operation schemas and metadata |
192
191
 
193
- `extendSchema` is called while building the JSON Schema for a target. It receives
194
- the schema produced so far and returns a (possibly) modified schema. Return
195
- `baseSchema` unchanged when the plugin does not apply. `extendOperationSchema`
196
- runs later, per operation (`create` / `update`), when you need to vary the schema
197
- by operation. `ctx: PluginContext` gives you `{ http, auth, baseUrl }` to call the
198
- site if you need live data.
192
+ Plugin APIs are exported from `dropsh/plugin`:
199
193
 
200
194
  ```ts
201
- import type { DropSHPlugin, PluginContext, SchemaOperation } from "dropsh/plugin";
195
+ import type { dropshPlugin } from "dropsh/plugin";
202
196
 
203
- // Marks node/article's `field_legacy_id` read-only and adds a provenance tag.
204
- export function articleRulesPlugin(): DropSHPlugin {
197
+ export function examplePlugin(): dropshPlugin {
205
198
  return {
206
- id: "article-rules",
199
+ id: "example",
207
200
  requiredModules: [],
208
-
209
- async extendSchema(entityType: string, bundle: string, baseSchema: unknown, _ctx: PluginContext) {
210
- if (entityType !== "node" || bundle !== "article") return baseSchema;
211
- const schema = baseSchema as {
212
- properties?: { attributes?: { properties?: Record<string, unknown> } };
213
- };
214
- const attrs = schema.properties?.attributes?.properties;
215
- if (attrs?.field_legacy_id && typeof attrs.field_legacy_id === "object") {
216
- (attrs.field_legacy_id as Record<string, unknown>).readOnly = true;
217
- }
218
- return { ...schema, "x-extended-by": "article-rules" };
219
- },
220
-
221
- // Optional: on create, require a field that is optional on update.
222
- async extendOperationSchema(
223
- entityType: string,
224
- bundle: string,
225
- operation: SchemaOperation,
226
- schema: unknown,
227
- _ctx: PluginContext,
228
- ) {
229
- if (entityType !== "node" || bundle !== "article" || operation !== "create") return schema;
230
- const s = schema as { required?: string[] };
231
- const required = new Set(s.required ?? []);
232
- required.add("field_legacy_id");
233
- return { ...s, required: [...required] };
201
+ async extendSchema(_entityType, _bundle, schema) {
202
+ return schema;
234
203
  },
235
204
  };
236
205
  }
237
206
  ```
238
207
 
239
- Register it:
208
+ Register local or package plugins in `dropsh.config.js`:
240
209
 
241
210
  ```js
242
- import { articleRulesPlugin } from "./plugins/article-rules.js";
211
+ import { basicAuthPlugin } from "dropsh";
212
+ import { examplePlugin } from "./plugins/example.js";
213
+
243
214
  export default {
244
215
  site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
245
- plugins: [basicAuthPlugin(), articleRulesPlugin()],
216
+ plugins: [basicAuthPlugin(), examplePlugin()],
246
217
  };
247
218
  ```
248
219
 
249
- The bundled `@dropsh/plugin-schemata` (authoritative schemas from the Drupal
250
- `schemata` module) and `@dropsh/plugin-canvas` (component schemas) are real
251
- examples of schema plugins — see `plugins/schemata/src/index.ts` and
252
- `plugins/canvas/src/index.ts`.
253
-
254
- ### An auth-provider plugin
255
-
256
- An auth provider is a single `AuthProvider` object exposed via the plugin's
257
- `authProvider` field. It owns one protocol end-to-end: `login` acquires a session
258
- (prompting / browser as needed), `createAdapter` turns a stored session into the
259
- `apply(req)` runtime hook, and `logout` / `status` round it out. The core handles
260
- the picker, the single-session state file, and dispatch — the provider never
261
- touches stdin or disk directly; it uses the injected `AuthContext` / `AdapterRuntime`.
262
-
263
- ```ts
264
- import type {
265
- AdapterRuntime, AuthAdapter, AuthContext, AuthProvider, AuthSession, DropSHPlugin,
266
- } from "dropsh/plugin";
267
- import { ConfigError } from "dropsh/plugin";
268
-
269
- // Static API-key auth: prompts for a token at login, sends it as a header.
270
- export function apiKeyProvider(): AuthProvider {
271
- return {
272
- id: "apikey",
273
- displayName: "API key (X-API-Key header)",
274
- capabilities: { login: true, logout: true, status: true },
275
-
276
- async login(ctx: AuthContext): Promise<AuthSession> {
277
- const key = await ctx.prompt({ label: "API key", secret: true });
278
- if (!key) throw new ConfigError("apikey: a non-empty key is required");
279
- return { api_key: key };
280
- },
220
+ ## Example Skills
281
221
 
282
- async logout(_ctx: AuthContext) {
283
- // No server-side revoke; the core clears the local session file.
284
- },
222
+ A dropsh skill does not need to know every Drupal internal. Keep `SKILL.md` as a
223
+ small orchestrator that decides when the skill applies, which dropsh plugins are
224
+ expected, and which workflow file to load. Put the concrete command sequences,
225
+ schema checks, payload rules, and error handling into separate workflow files.
285
226
 
286
- async status(session: AuthSession | null) {
287
- return { loggedIn: session !== null, provider: "apikey" };
288
- },
227
+ See [docs/examples/skills](docs/examples/skills/README.md) for three example
228
+ skill structures:
289
229
 
290
- createAdapter(session: AuthSession, _rt: AdapterRuntime): AuthAdapter {
291
- const key = session.api_key;
292
- if (typeof key !== "string") throw new ConfigError("apikey: corrupt session");
293
- return {
294
- async apply(req) {
295
- return { ...req, headers: { ...(req.headers ?? {}), "X-API-Key": key } };
296
- },
297
- };
298
- },
299
- };
300
- }
301
-
302
- export function apiKeyPlugin(): DropSHPlugin {
303
- return {
304
- id: "apikey",
305
- requiredModules: [],
306
- authProvider: apiKeyProvider(),
307
- async extendSchema(_entityType, _bundle, schema) {
308
- return schema; // not a schema plugin
309
- },
310
- };
311
- }
312
- ```
313
-
314
- Once registered in `plugins`, it shows up automatically in `dropsh auth login`:
315
-
316
- ```bash
317
- dropsh auth login --provider apikey
318
- ```
319
-
320
- `AuthContext` (passed to `login`/`logout`) provides `baseUrl`, an `http` client,
321
- `prompt({ label, secret })`, `openBrowser(url)`, `stdout(s)`, and `now()`.
322
- `AdapterRuntime` (passed to `createAdapter`) provides `http`, `now()`, and
323
- `save(session)` — call `save` to persist a refreshed session back to the active
324
- slot (the OAuth2 `oauth2_authcode` provider uses this to store a refreshed token).
325
- Throw `AuthError` for expired/invalid sessions so the CLI exits with code 3 and
326
- tells the user to run `dropsh auth login`. The bundled `@dropsh/plugin-oauth2`
327
- (`plugins/oauth2/src/provider.ts`) and the core basic provider
328
- (`src/core/auth/basic.ts`) are full reference implementations.
230
+ - Article publishing over standard JSON:API nodes
231
+ - Canvas pages driven by component schemas
232
+ - Display Builder pages driven by display metadata and override fields
329
233
 
330
234
  ## Development
331
235
 
332
236
  ```bash
333
- pnpm test # Vitest unit suite
334
- pnpm run test:watch
237
+ pnpm test
335
238
  pnpm run typecheck
336
239
  pnpm run build
337
240
  ```
338
241
 
339
- ### Integration tests
340
-
341
- Local-only, requires DDEV. See `tests/integrations/README.md`.
242
+ Integration tests require DDEV:
342
243
 
343
244
  ```bash
344
245
  pnpm run drupal:up
@@ -348,14 +249,10 @@ pnpm run drupal:down
348
249
 
349
250
  ## Publishing
350
251
 
351
- The CLI and all workspace packages are published together with a single command:
252
+ The CLI and workspace packages are published together from the `1.x` branch:
352
253
 
353
254
  ```bash
354
255
  pnpm run release
355
256
  ```
356
257
 
357
- This publishes `dropsh` (workspace root) plus `@dropsh/sdc-client`,
358
- `@dropsh/plugin-schemata`, `@dropsh/plugin-oauth2` and `@dropsh/plugin-canvas`.
359
- `prepublishOnly` runs typecheck + build per package; scoped packages publish with
360
- public access via their `publishConfig`, and pnpm rewrites `workspace:*` deps to the
361
- released version. Log in first with `npm login`.
258
+ Run `npm login` first. `prepublishOnly` runs typecheck and build before publish.
@@ -5,6 +5,15 @@ import { fetchJsonSchema } from "../core/schema/jsonschema-source.js";
5
5
  import { toOperationVariant } from "../core/schema/to-jsonschema.js";
6
6
  import { ValidationError } from "../errors.js";
7
7
  export const SCHEMA_PIPELINE_VERSION = 2;
8
+ /**
9
+ * Per-site schema cache root. Schemas differ between sites, so the cache is
10
+ * namespaced by the site host — two configs pointing at different base_urls
11
+ * from the same working directory must not share cached schemas.
12
+ */
13
+ export function siteCacheRoot(cwd, baseUrl) {
14
+ const host = new URL(baseUrl).host.toLowerCase().replace(/[^a-z0-9.-]/g, "_");
15
+ return join(cwd, ".dropsh/cache", host);
16
+ }
8
17
  function isRecord(value) {
9
18
  return typeof value === "object" && value !== null && !Array.isArray(value);
10
19
  }
@@ -40,7 +49,10 @@ export async function applyOperationSchemaPlugins(schema, args, deps) {
40
49
  }
41
50
  const TARGET_RE = /^[a-z0-9_]+\/[a-z0-9_]+$/;
42
51
  export async function runSchema(args, deps) {
43
- const store = createFileStore({ rootDir: join(deps.cwd, ".dropsh/cache"), warn: deps.warn });
52
+ const store = createFileStore({
53
+ rootDir: siteCacheRoot(deps.cwd, deps.baseUrl),
54
+ warn: deps.warn,
55
+ });
44
56
  if (args.target === undefined) {
45
57
  if (!args.refresh) {
46
58
  const hit = await store.read("catalog.json");
@@ -39,8 +39,5 @@ export function basicAuthPlugin(config = {}) {
39
39
  id: "basic",
40
40
  requiredModules: [],
41
41
  authProvider: basicAuthProvider(config),
42
- async extendSchema(_entityType, _bundle, schema, _ctx) {
43
- return schema;
44
- },
45
42
  };
46
43
  }
@@ -27,6 +27,8 @@ export async function fetchJsonSchema(deps) {
27
27
  let source = heuristicResult.empty ? "heuristic-empty" : "heuristic";
28
28
  // Run each plugin's extendSchema — a plugin may replace the schema entirely (e.g. schemata)
29
29
  for (const plugin of plugins) {
30
+ if (!plugin.extendSchema)
31
+ continue;
30
32
  const extended = await plugin.extendSchema(entity, bundle, schema, ctx);
31
33
  if (extended !== schema) {
32
34
  schema = extended;
package/dist/src/index.js CHANGED
@@ -5,7 +5,7 @@ import { runAuthLogin, runAuthLogout, runAuthStatus } from "./commands/auth.js";
5
5
  import { runCreate } from "./commands/create.js";
6
6
  import { runDelete } from "./commands/delete.js";
7
7
  import { runRead } from "./commands/read.js";
8
- import { applyOperationSchemaPlugins, operationHookPluginIds, runSchema, SCHEMA_PIPELINE_VERSION, schemaCacheMetadataMatches, } from "./commands/schema.js";
8
+ import { applyOperationSchemaPlugins, operationHookPluginIds, runSchema, SCHEMA_PIPELINE_VERSION, schemaCacheMetadataMatches, siteCacheRoot, } from "./commands/schema.js";
9
9
  import { runSearch } from "./commands/search.js";
10
10
  import { runUpdate } from "./commands/update.js";
11
11
  import { runUploadFile } from "./commands/upload-file.js";
@@ -116,7 +116,7 @@ export function buildProgram(opts = {}) {
116
116
  }
117
117
  async function loadOrFetchSchema(ctx, target, op) {
118
118
  const store = createFileStore({
119
- rootDir: `${ctx.cwd}/.dropsh/cache`,
119
+ rootDir: siteCacheRoot(ctx.cwd, ctx.baseUrl),
120
120
  warn: (m) => stderr(`${m}\n`),
121
121
  });
122
122
  const [entity, bundle] = target.split("/", 2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dropsh",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
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": {
@@ -56,12 +56,12 @@
56
56
  "dev": "tsx src/index.ts",
57
57
  "test": "vitest run --passWithNoTests",
58
58
  "test:watch": "vitest",
59
- "test:integration": "pnpm run build && vitest run -c vitest.config.integration.ts",
60
- "typecheck": "tsc --noEmit",
61
- "lint": "biome check src/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
62
- "lint:fix": "biome check --write src/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
59
+ "test:integration": "pnpm run build && pnpm run build:packages && vitest run -c vitest.config.integration.ts",
60
+ "typecheck": "pnpm run build:packages && tsc --noEmit && pnpm -r run typecheck",
61
+ "lint": "biome check src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
62
+ "lint:fix": "biome check --write src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
63
63
  "drupal:up": "bash tests/integrations/bin/drupal-up.sh",
64
64
  "drupal:down": "bash tests/integrations/bin/drupal-down.sh",
65
- "release": "pnpm publish --no-git-checks && pnpm -r publish --no-git-checks"
65
+ "release": "pnpm -r --include-workspace-root publish --no-git-checks"
66
66
  }
67
67
  }
@@ -1,121 +0,0 @@
1
- import { createHash, randomBytes } from "node:crypto";
2
- import { createServer } from "node:http";
3
- import { writeToken } from "../core/auth/token-store.js";
4
- import { loadConfig } from "../core/config.js";
5
- import { createHttpClient } from "../core/http.js";
6
- import { AuthError } from "../errors.js";
7
- const DEFAULT_PORT = 7432;
8
- const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
9
- export function generatePkce() {
10
- const verifier = randomBytes(96).toString("base64url");
11
- const challenge = createHash("sha256").update(verifier).digest("base64url");
12
- return { verifier, challenge };
13
- }
14
- export function generateState() {
15
- return randomBytes(16).toString("base64url");
16
- }
17
- export async function runLogin(deps = {}) {
18
- const stdout = deps.stdout ?? ((s) => process.stdout.write(`${s}\n`));
19
- const configPath = deps.configPath ?? (process.env.DRUPAL_CLI_CONFIG ?? ".drupal-cli.yml");
20
- const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
21
- const now = deps.now ?? Date.now;
22
- const http = deps.http ?? createHttpClient();
23
- const pkce = (deps._generatePkce ?? generatePkce)();
24
- const state = (deps._generateState ?? generateState)();
25
- const cfg = await loadConfig(configPath);
26
- const { auth, base_url: baseUrl } = cfg.site;
27
- if (auth.type !== "oauth2_authcode") {
28
- throw new AuthError("login requires auth.type: oauth2_authcode in config");
29
- }
30
- const clientId = auth.client_id;
31
- if (typeof clientId !== "string" || clientId.length === 0) {
32
- throw new AuthError("oauth2_authcode auth requires client_id");
33
- }
34
- const scope = typeof auth.scope === "string" ? auth.scope : "";
35
- const port = typeof auth.redirect_port === "number" ? auth.redirect_port : DEFAULT_PORT;
36
- const redirectUri = `http://localhost:${port}/callback`;
37
- const baseUrlNorm = baseUrl.replace(/\/$/, "");
38
- const tokenEndpoint = `${baseUrlNorm}/oauth/token`;
39
- const authUrl = new URL(`${baseUrlNorm}/oauth/authorize`);
40
- authUrl.searchParams.set("response_type", "code");
41
- authUrl.searchParams.set("client_id", clientId);
42
- authUrl.searchParams.set("redirect_uri", redirectUri);
43
- authUrl.searchParams.set("code_challenge", pkce.challenge);
44
- authUrl.searchParams.set("code_challenge_method", "S256");
45
- authUrl.searchParams.set("state", state);
46
- if (scope)
47
- authUrl.searchParams.set("scope", scope);
48
- const code = await new Promise((resolve, reject) => {
49
- let settled = false;
50
- const finish = (fn) => {
51
- if (settled)
52
- return;
53
- settled = true;
54
- clearTimeout(timer);
55
- server.close(() => fn());
56
- };
57
- const server = createServer((req, res) => {
58
- const url = new URL(req.url ?? "/", `http://localhost:${port}`);
59
- if (url.pathname !== "/callback") {
60
- res.writeHead(404);
61
- res.end();
62
- return;
63
- }
64
- res.writeHead(200, { "Content-Type": "text/html" });
65
- res.end("<html><body><p>Login successful. You can close this tab.</p></body></html>");
66
- const receivedState = url.searchParams.get("state");
67
- const receivedCode = url.searchParams.get("code");
68
- if (receivedState !== state) {
69
- finish(() => reject(new AuthError("OAuth state mismatch — possible CSRF attack")));
70
- return;
71
- }
72
- if (!receivedCode) {
73
- finish(() => reject(new AuthError("No authorization code received")));
74
- return;
75
- }
76
- finish(() => resolve(receivedCode));
77
- });
78
- server.on("error", (err) => {
79
- finish(() => reject(err));
80
- });
81
- const timer = setTimeout(() => {
82
- finish(() => reject(new AuthError("Login timed out. Run 'drupal-cli login' to try again.")));
83
- }, timeoutMs);
84
- server.listen(port, () => {
85
- stdout("Opening browser for login...");
86
- stdout(`If the browser does not open, visit:\n${authUrl.toString()}`);
87
- const openBrowser = deps.openBrowser ?? (async (url) => {
88
- const { default: open } = await import("open");
89
- await open(url);
90
- });
91
- openBrowser(authUrl.toString()).catch(() => {
92
- // The URL is printed above, so the user still has a manual fallback.
93
- });
94
- });
95
- });
96
- const params = new URLSearchParams({
97
- grant_type: "authorization_code",
98
- code,
99
- code_verifier: pkce.verifier,
100
- client_id: clientId,
101
- redirect_uri: redirectUri,
102
- });
103
- const res = await http.send({
104
- method: "POST",
105
- url: tokenEndpoint,
106
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
107
- body: params.toString(),
108
- });
109
- const body = JSON.parse(res.body);
110
- if (typeof body.access_token !== "string") {
111
- throw new AuthError("Token endpoint returned no access_token");
112
- }
113
- const ttlSec = typeof body.expires_in === "number" ? body.expires_in : 3600;
114
- const expiresAt = now() + ttlSec * 1000 - 5000;
115
- await writeToken(baseUrl, {
116
- access_token: body.access_token,
117
- ...(typeof body.refresh_token === "string" ? { refresh_token: body.refresh_token } : {}),
118
- expires_at: expiresAt,
119
- }, deps.tokenDir);
120
- stdout(`Logged in · token valid until ${new Date(expiresAt).toISOString()}`);
121
- }
@@ -1,13 +0,0 @@
1
- import { AuthError } from "../../errors.js";
2
- export function createApiKeyAuth(cfg) {
3
- const key = cfg.key;
4
- const header = typeof cfg.header === "string" ? cfg.header : "X-API-Key";
5
- if (typeof key !== "string" || key.length === 0) {
6
- throw new AuthError("api_key auth requires key");
7
- }
8
- return {
9
- async apply(req) {
10
- return { ...req, headers: { ...(req.headers ?? {}), [header]: key } };
11
- },
12
- };
13
- }
@@ -1,17 +0,0 @@
1
- import { AuthError } from "../../errors.js";
2
- import { createBasicAuth } from "./basic.js";
3
- import { createOAuth2AuthCodeAuth } from "./oauth2-authcode.js";
4
- import { createOAuth2Auth } from "./oauth2.js";
5
- export function createAuthAdapter(cfg, deps) {
6
- switch (cfg.type) {
7
- case "basic":
8
- return createBasicAuth(cfg);
9
- case "oauth2_password":
10
- case "oauth2_client_credentials":
11
- return createOAuth2Auth(cfg, { http: deps.http, baseUrl: deps.baseUrl });
12
- case "oauth2_authcode":
13
- return createOAuth2AuthCodeAuth(cfg, { http: deps.http, baseUrl: deps.baseUrl });
14
- default:
15
- throw new AuthError(`Unknown auth.type: ${String(cfg.type)}`);
16
- }
17
- }
@@ -1,12 +0,0 @@
1
- import { AuthError } from "../../errors.js";
2
- export function createJwtAuth(cfg) {
3
- const token = cfg.token;
4
- if (typeof token !== "string" || token.length === 0) {
5
- throw new AuthError("jwt auth requires token");
6
- }
7
- return {
8
- async apply(req) {
9
- return { ...req, headers: { ...(req.headers ?? {}), Authorization: `Bearer ${token}` } };
10
- },
11
- };
12
- }
@@ -1,67 +0,0 @@
1
- import { AuthError, HttpError } from "../../errors.js";
2
- import { readToken, writeToken } from "./token-store.js";
3
- function requireString(cfg, key) {
4
- const value = cfg[key];
5
- if (typeof value !== "string" || value.length === 0) {
6
- throw new AuthError(`${cfg.type} auth requires ${key}`);
7
- }
8
- return value;
9
- }
10
- export function createOAuth2AuthCodeAuth(cfg, deps) {
11
- const now = deps.now ?? Date.now;
12
- const clientId = requireString(cfg, "client_id");
13
- const tokenUrl = typeof cfg.token_url === "string" ? cfg.token_url : `${deps.baseUrl.replace(/\/$/, "")}/oauth/token`;
14
- return {
15
- async apply(req) {
16
- const stored = await readToken(deps.baseUrl, deps.tokenDir);
17
- if (!stored) {
18
- throw new AuthError("Session expired. Run 'drupal-cli login' to authenticate.");
19
- }
20
- if (stored.expires_at - 30_000 > now()) {
21
- return {
22
- ...req,
23
- headers: { ...(req.headers ?? {}), Authorization: `Bearer ${stored.access_token}` },
24
- };
25
- }
26
- if (!stored.refresh_token) {
27
- throw new AuthError("Session expired. Run 'drupal-cli login' to authenticate.");
28
- }
29
- try {
30
- const params = new URLSearchParams({
31
- grant_type: "refresh_token",
32
- refresh_token: stored.refresh_token,
33
- client_id: clientId,
34
- });
35
- const res = await deps.http.send({
36
- method: "POST",
37
- url: tokenUrl,
38
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
39
- body: params.toString(),
40
- });
41
- const body = JSON.parse(res.body);
42
- if (typeof body.access_token !== "string") {
43
- throw new AuthError("Token endpoint returned no access_token");
44
- }
45
- const ttlSec = typeof body.expires_in === "number" ? body.expires_in : 3600;
46
- const refreshed = {
47
- access_token: body.access_token,
48
- refresh_token: typeof body.refresh_token === "string" ? body.refresh_token : stored.refresh_token,
49
- expires_at: now() + ttlSec * 1000 - 5000,
50
- };
51
- await writeToken(deps.baseUrl, refreshed, deps.tokenDir);
52
- return {
53
- ...req,
54
- headers: { ...(req.headers ?? {}), Authorization: `Bearer ${refreshed.access_token}` },
55
- };
56
- }
57
- catch (err) {
58
- if (err instanceof AuthError)
59
- throw err;
60
- if (err instanceof HttpError) {
61
- throw new AuthError("Session expired. Run 'drupal-cli login' to authenticate.", { status: err.status, body: err.body });
62
- }
63
- throw new AuthError("Session expired. Run 'drupal-cli login' to authenticate.");
64
- }
65
- },
66
- };
67
- }
@@ -1,64 +0,0 @@
1
- import { AuthError, HttpError } from "../../errors.js";
2
- function requireString(cfg, key) {
3
- const v = cfg[key];
4
- if (typeof v !== "string" || v.length === 0) {
5
- throw new AuthError(`${cfg.type} auth requires ${key}`);
6
- }
7
- return v;
8
- }
9
- export function createOAuth2Auth(cfg, deps) {
10
- const now = deps.now ?? Date.now;
11
- const clientId = requireString(cfg, "client_id");
12
- const clientSecret = requireString(cfg, "client_secret");
13
- const tokenUrl = typeof cfg.token_url === "string" ? cfg.token_url : `${deps.baseUrl.replace(/\/$/, "")}/oauth/token`;
14
- const params = new URLSearchParams({
15
- client_id: clientId,
16
- client_secret: clientSecret,
17
- });
18
- if (cfg.type === "oauth2_password") {
19
- params.set("grant_type", "password");
20
- params.set("username", requireString(cfg, "username"));
21
- params.set("password", requireString(cfg, "password"));
22
- if (typeof cfg.scope === "string")
23
- params.set("scope", cfg.scope);
24
- }
25
- else if (cfg.type === "oauth2_client_credentials") {
26
- params.set("grant_type", "client_credentials");
27
- if (typeof cfg.scope === "string")
28
- params.set("scope", cfg.scope);
29
- }
30
- else {
31
- throw new AuthError(`Unsupported oauth2 grant: ${cfg.type}`);
32
- }
33
- let cached = null;
34
- async function fetchToken() {
35
- try {
36
- const res = await deps.http.send({
37
- method: "POST",
38
- url: tokenUrl,
39
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
40
- body: params.toString(),
41
- });
42
- const body = JSON.parse(res.body);
43
- if (typeof body.access_token !== "string") {
44
- throw new AuthError("Token endpoint returned no access_token");
45
- }
46
- const ttlSec = typeof body.expires_in === "number" ? body.expires_in : 3600;
47
- return { token: body.access_token, expiresAt: now() + ttlSec * 1000 - 5000 };
48
- }
49
- catch (err) {
50
- if (err instanceof HttpError)
51
- throw new AuthError(`Token request failed: HTTP ${err.status}`, { body: err.body });
52
- if (err instanceof AuthError)
53
- throw err;
54
- throw new AuthError(`Token request failed: ${err.message}`);
55
- }
56
- }
57
- return {
58
- async apply(req) {
59
- if (!cached || cached.expiresAt <= now())
60
- cached = await fetchToken();
61
- return { ...req, headers: { ...(req.headers ?? {}), Authorization: `Bearer ${cached.token}` } };
62
- },
63
- };
64
- }
@@ -1,26 +0,0 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
- function hostnameFromUrl(baseUrl) {
5
- return new URL(baseUrl).hostname;
6
- }
7
- export function defaultTokenDir() {
8
- return join(homedir(), ".config", "drupal-cli");
9
- }
10
- export async function readToken(baseUrl, dir) {
11
- const tokenDir = dir ?? defaultTokenDir();
12
- const tokenPath = join(tokenDir, `${hostnameFromUrl(baseUrl)}.json`);
13
- try {
14
- const raw = await readFile(tokenPath, "utf8");
15
- return JSON.parse(raw);
16
- }
17
- catch {
18
- return null;
19
- }
20
- }
21
- export async function writeToken(baseUrl, token, dir) {
22
- const tokenDir = dir ?? defaultTokenDir();
23
- await mkdir(tokenDir, { recursive: true });
24
- const tokenPath = join(tokenDir, `${hostnameFromUrl(baseUrl)}.json`);
25
- await writeFile(tokenPath, JSON.stringify(token, null, 2), { encoding: "utf8", mode: 0o600 });
26
- }
@@ -1,18 +0,0 @@
1
- export function buildQueryString(q) {
2
- const params = new URLSearchParams();
3
- for (const f of q.filter ?? []) {
4
- params.append(`filter[${f.key}][value]`, f.value);
5
- if (f.operator)
6
- params.append(`filter[${f.key}][operator]`, f.operator);
7
- }
8
- if (q.sort)
9
- params.append("sort", q.sort);
10
- if (q.page?.limit !== undefined)
11
- params.append("page[limit]", String(q.page.limit));
12
- if (q.page?.offset !== undefined)
13
- params.append("page[offset]", String(q.page.offset));
14
- if (q.include && q.include.length > 0)
15
- params.append("include", q.include.join(","));
16
- const s = params.toString();
17
- return s.length === 0 ? "" : `?${s}`;
18
- }