dropsh 0.0.2 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -267
- package/dist/src/commands/schema.js +13 -1
- package/dist/src/core/auth/basic.js +0 -3
- package/dist/src/core/schema/jsonschema-source.js +2 -0
- package/dist/src/index.js +2 -2
- package/dist/src/plugin-api.js +1 -0
- package/package.json +6 -6
- package/dist/src/commands/login.js +0 -121
- package/dist/src/core/auth/api-key.js +0 -13
- package/dist/src/core/auth/factory.js +0 -17
- package/dist/src/core/auth/jwt.js +0 -12
- package/dist/src/core/auth/oauth2-authcode.js +0 -67
- package/dist/src/core/auth/oauth2.js +0 -64
- package/dist/src/core/auth/token-store.js +0 -26
- package/dist/src/core/jsonapi/query.js +0 -18
package/README.md
CHANGED
|
@@ -1,344 +1,245 @@
|
|
|
1
1
|
# dropsh
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
47
|
+
Minimum Drupal setup:
|
|
61
48
|
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
{ "activeProvider": "oauth2_authcode", "session": { "access_token": "…", "expires_at": 1717459200000 } }
|
|
67
|
-
```
|
|
53
|
+
Optional modules unlock stricter schemas or builder-specific support:
|
|
68
54
|
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
67
|
+
### Installing the Drupal modules
|
|
78
68
|
|
|
79
|
-
|
|
69
|
+
JSON:API ships with Drupal core; enable it together with the optional modules
|
|
70
|
+
you need:
|
|
80
71
|
|
|
81
72
|
```bash
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
96
|
+
## Configuration
|
|
102
97
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 {
|
|
105
|
+
import { oauth2Plugin } from "@dropsh/plugin-oauth2";
|
|
106
|
+
import { schemataPlugin } from "@dropsh/plugin-schemata";
|
|
137
107
|
|
|
138
108
|
export default {
|
|
139
|
-
site: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
138
|
+
dropsh auth login
|
|
139
|
+
dropsh auth login --provider basic
|
|
140
|
+
dropsh auth status
|
|
141
|
+
dropsh auth logout
|
|
151
142
|
```
|
|
152
143
|
|
|
153
|
-
|
|
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
|
-
|
|
147
|
+
## Commands
|
|
156
148
|
|
|
157
|
-
|
|
149
|
+
All commands write JSON to stdout, structured errors to stderr, and use stable
|
|
150
|
+
exit codes.
|
|
158
151
|
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
195
|
+
import type { dropshPlugin } from "dropsh/plugin";
|
|
202
196
|
|
|
203
|
-
|
|
204
|
-
export function articleRulesPlugin(): DropSHPlugin {
|
|
197
|
+
export function examplePlugin(): dropshPlugin {
|
|
205
198
|
return {
|
|
206
|
-
id: "
|
|
199
|
+
id: "example",
|
|
207
200
|
requiredModules: [],
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
208
|
+
Register local or package plugins in `dropsh.config.js`:
|
|
240
209
|
|
|
241
210
|
```js
|
|
242
|
-
import {
|
|
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(),
|
|
216
|
+
plugins: [basicAuthPlugin(), examplePlugin()],
|
|
246
217
|
};
|
|
247
218
|
```
|
|
248
219
|
|
|
249
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
},
|
|
227
|
+
See [docs/examples/skills](docs/examples/skills/README.md) for three example
|
|
228
|
+
skill structures:
|
|
289
229
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
334
|
-
pnpm run test:watch
|
|
237
|
+
pnpm test
|
|
335
238
|
pnpm run typecheck
|
|
336
239
|
pnpm run build
|
|
337
240
|
```
|
|
338
241
|
|
|
339
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
|
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");
|
|
@@ -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:
|
|
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/dist/src/plugin-api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { basicAuthPlugin, basicAuthProvider } from "./core/auth/basic.js";
|
|
2
2
|
export { loadConfig } from "./core/config.js";
|
|
3
3
|
export { createHttpClient } from "./core/http.js";
|
|
4
|
+
export { createJsonApiClient } from "./core/jsonapi/client.js";
|
|
4
5
|
export { AuthError, ConfigError, HttpError } from "./errors.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dropsh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Entity-agnostic helper CLI for Drupal 11 JSON:API, used by the dropsh Claude skill for editorial publishing workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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
|
|
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
|
-
}
|