dropsh 0.0.1 → 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 +217 -37
- package/bin/dropsh-src +0 -0
- package/dist/src/commands/auth.js +88 -0
- package/dist/src/commands/schema.js +61 -4
- package/dist/src/core/auth/basic.js +32 -13
- package/dist/src/core/auth/registry.js +9 -0
- package/dist/src/core/auth/session-store.js +38 -0
- package/dist/src/core/cli/prompt.js +53 -0
- package/dist/src/core/schema/jsonschema-source.js +2 -0
- package/dist/src/index.js +79 -10
- package/dist/src/plugin-api.js +1 -1
- package/package.json +21 -18
- 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,78 +1,258 @@
|
|
|
1
1
|
# dropsh
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
7
|
|
|
5
|
-
|
|
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
|
|
|
31
|
+
This repository is a pnpm workspace containing the root CLI, packages, and
|
|
32
|
+
plugins.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
corepack enable
|
|
36
|
+
pnpm install
|
|
37
|
+
pnpm run build
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The required Node.js and pinned pnpm versions are declared in `package.json`.
|
|
41
|
+
|
|
42
|
+
## Drupal Prerequisites
|
|
43
|
+
|
|
44
|
+
The base CLI expects a Drupal site with JSON:API enabled and reachable from the
|
|
45
|
+
machine running dropsh.
|
|
46
|
+
|
|
47
|
+
Minimum Drupal setup:
|
|
48
|
+
|
|
49
|
+
- Drupal 10 or 11
|
|
50
|
+
- JSON:API enabled
|
|
51
|
+
- A user or OAuth client with permissions for the entities you want to access
|
|
52
|
+
|
|
53
|
+
Optional modules unlock stricter schemas or builder-specific support:
|
|
54
|
+
|
|
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` |
|
|
61
|
+
|
|
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.
|
|
66
|
+
|
|
67
|
+
### Installing the Drupal modules
|
|
68
|
+
|
|
69
|
+
JSON:API ships with Drupal core; enable it together with the optional modules
|
|
70
|
+
you need:
|
|
71
|
+
|
|
9
72
|
```bash
|
|
10
|
-
|
|
11
|
-
|
|
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"
|
|
85
|
+
```
|
|
86
|
+
|
|
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.
|
|
90
|
+
|
|
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`).
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
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.
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
import { basicAuthPlugin } from "dropsh";
|
|
105
|
+
import { oauth2Plugin } from "@dropsh/plugin-oauth2";
|
|
106
|
+
import { schemataPlugin } from "@dropsh/plugin-schemata";
|
|
107
|
+
|
|
108
|
+
export default {
|
|
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
|
+
},
|
|
117
|
+
plugins: [
|
|
118
|
+
basicAuthPlugin(),
|
|
119
|
+
oauth2Plugin({
|
|
120
|
+
type: "oauth2_authcode",
|
|
121
|
+
client_id: "my-client",
|
|
122
|
+
token_url: "https://my-drupal.example.com/oauth/token",
|
|
123
|
+
}),
|
|
124
|
+
schemataPlugin(),
|
|
125
|
+
],
|
|
126
|
+
};
|
|
12
127
|
```
|
|
13
128
|
|
|
14
|
-
|
|
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
|
|
15
133
|
|
|
16
|
-
|
|
134
|
+
Authentication is configured through plugins. Each plugin that contributes an
|
|
135
|
+
auth provider appears as a login option.
|
|
17
136
|
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
username: ${DRUPAL_USER}
|
|
24
|
-
password: ${DRUPAL_PASSWORD}
|
|
137
|
+
```bash
|
|
138
|
+
dropsh auth login
|
|
139
|
+
dropsh auth login --provider basic
|
|
140
|
+
dropsh auth status
|
|
141
|
+
dropsh auth logout
|
|
25
142
|
```
|
|
26
143
|
|
|
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`.
|
|
146
|
+
|
|
27
147
|
## Commands
|
|
28
148
|
|
|
29
|
-
All commands write JSON to stdout, structured errors to stderr, and use
|
|
149
|
+
All commands write JSON to stdout, structured errors to stderr, and use stable
|
|
150
|
+
exit codes.
|
|
30
151
|
|
|
31
152
|
```bash
|
|
32
153
|
dropsh read <entity_type>/<bundle>/<uuid>
|
|
33
|
-
dropsh search <entity_type> [--bundle=<
|
|
34
|
-
dropsh create <entity_type> --bundle=<
|
|
35
|
-
dropsh update <entity_type>/<bundle>/<uuid> --data=<json|@file> [--dry-run]
|
|
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]
|
|
36
157
|
dropsh delete <entity_type>/<bundle>/<uuid> [--dry-run]
|
|
37
|
-
dropsh upload-file --target=<entity_type>/<bundle>/<uuid>/<field> --file=<path>
|
|
158
|
+
dropsh upload-file --target=<entity_type>/<bundle>/<uuid>/<field> --file=<path>
|
|
38
159
|
dropsh schema [--refresh]
|
|
39
160
|
dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh]
|
|
40
161
|
```
|
|
41
162
|
|
|
42
|
-
|
|
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
|
+
|
|
166
|
+
## Plugins
|
|
167
|
+
|
|
168
|
+
Plugins are regular JavaScript or TypeScript modules that return a `dropshPlugin`.
|
|
169
|
+
A plugin can contribute one or more of these capabilities:
|
|
170
|
+
|
|
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.
|
|
43
182
|
|
|
44
|
-
|
|
183
|
+
Bundled plugins:
|
|
45
184
|
|
|
46
|
-
|
|
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 |
|
|
47
191
|
|
|
48
|
-
|
|
49
|
-
- `--refresh` — bypass the cache for this call
|
|
192
|
+
Plugin APIs are exported from `dropsh/plugin`:
|
|
50
193
|
|
|
51
|
-
|
|
194
|
+
```ts
|
|
195
|
+
import type { dropshPlugin } from "dropsh/plugin";
|
|
52
196
|
|
|
53
|
-
|
|
197
|
+
export function examplePlugin(): dropshPlugin {
|
|
198
|
+
return {
|
|
199
|
+
id: "example",
|
|
200
|
+
requiredModules: [],
|
|
201
|
+
async extendSchema(_entityType, _bundle, schema) {
|
|
202
|
+
return schema;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Register local or package plugins in `dropsh.config.js`:
|
|
209
|
+
|
|
210
|
+
```js
|
|
211
|
+
import { basicAuthPlugin } from "dropsh";
|
|
212
|
+
import { examplePlugin } from "./plugins/example.js";
|
|
213
|
+
|
|
214
|
+
export default {
|
|
215
|
+
site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
|
|
216
|
+
plugins: [basicAuthPlugin(), examplePlugin()],
|
|
217
|
+
};
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Example Skills
|
|
54
221
|
|
|
55
|
-
|
|
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.
|
|
56
226
|
|
|
57
|
-
|
|
227
|
+
See [docs/examples/skills](docs/examples/skills/README.md) for three example
|
|
228
|
+
skill structures:
|
|
229
|
+
|
|
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
|
|
58
233
|
|
|
59
234
|
## Development
|
|
60
235
|
|
|
61
236
|
```bash
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
237
|
+
pnpm test
|
|
238
|
+
pnpm run typecheck
|
|
239
|
+
pnpm run build
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Integration tests require DDEV:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
pnpm run drupal:up
|
|
246
|
+
pnpm run test:integration
|
|
247
|
+
pnpm run drupal:down
|
|
66
248
|
```
|
|
67
249
|
|
|
68
|
-
|
|
250
|
+
## Publishing
|
|
69
251
|
|
|
70
|
-
|
|
252
|
+
The CLI and workspace packages are published together from the `1.x` branch:
|
|
71
253
|
|
|
72
254
|
```bash
|
|
73
|
-
|
|
74
|
-
npm run test:integration
|
|
75
|
-
npm run drupal:down
|
|
255
|
+
pnpm run release
|
|
76
256
|
```
|
|
77
257
|
|
|
78
|
-
|
|
258
|
+
Run `npm login` first. `prepublishOnly` runs typecheck and build before publish.
|
package/bin/dropsh-src
CHANGED
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { loginCapableProviders, providerById } from "../core/auth/registry.js";
|
|
2
|
+
import { clearSession, readSession, writeSession } from "../core/auth/session-store.js";
|
|
3
|
+
import { AuthError, ConfigError } from "../errors.js";
|
|
4
|
+
function authContext(deps) {
|
|
5
|
+
const ctx = {
|
|
6
|
+
baseUrl: deps.baseUrl,
|
|
7
|
+
http: deps.http,
|
|
8
|
+
prompt: deps.prompt,
|
|
9
|
+
openBrowser: deps.openBrowser,
|
|
10
|
+
stdout: deps.stdout,
|
|
11
|
+
now: deps.now,
|
|
12
|
+
};
|
|
13
|
+
if (deps.stateDir !== undefined)
|
|
14
|
+
ctx.stateDir = deps.stateDir;
|
|
15
|
+
return ctx;
|
|
16
|
+
}
|
|
17
|
+
async function pickProvider(deps, requestedId) {
|
|
18
|
+
const capable = loginCapableProviders(deps.providers);
|
|
19
|
+
if (capable.length === 0)
|
|
20
|
+
throw new ConfigError("no login-capable auth provider configured");
|
|
21
|
+
if (requestedId) {
|
|
22
|
+
const found = providerById(capable, requestedId);
|
|
23
|
+
if (!found)
|
|
24
|
+
throw new ConfigError(`unknown provider '${requestedId}'. Available: ${capable.map((p) => p.id).join(", ")}`);
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
27
|
+
if (!deps.isTTY)
|
|
28
|
+
throw new ConfigError("non-interactive: pass --provider <id>");
|
|
29
|
+
deps.stdout("Select an auth provider:\n");
|
|
30
|
+
capable.forEach((p, i) => {
|
|
31
|
+
deps.stdout(` ${i + 1}) ${p.displayName} [${p.id}]\n`);
|
|
32
|
+
});
|
|
33
|
+
const answer = await deps.prompt({ label: "Number" });
|
|
34
|
+
const idx = Number.parseInt(answer, 10) - 1;
|
|
35
|
+
const chosen = capable[idx];
|
|
36
|
+
if (!chosen)
|
|
37
|
+
throw new ConfigError(`invalid selection '${answer}'`);
|
|
38
|
+
return chosen;
|
|
39
|
+
}
|
|
40
|
+
export async function runAuthLogin(args, deps) {
|
|
41
|
+
const provider = await pickProvider(deps, args.provider);
|
|
42
|
+
const session = await provider.login(authContext(deps));
|
|
43
|
+
await writeSession(deps.baseUrl, provider.id, session, deps.stateDir);
|
|
44
|
+
deps.stdout(`logged in via ${provider.displayName}\n`);
|
|
45
|
+
}
|
|
46
|
+
export async function runAuthLogout(_args, deps) {
|
|
47
|
+
const rec = await readSession(deps.baseUrl, deps.stateDir);
|
|
48
|
+
if (!rec) {
|
|
49
|
+
deps.stdout("not logged in\n");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const provider = providerById(deps.providers, rec.activeProvider);
|
|
53
|
+
try {
|
|
54
|
+
if (provider)
|
|
55
|
+
await provider.logout(authContext(deps));
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
// Best-effort revoke: never block local logout on a failing revoke call.
|
|
59
|
+
deps.stderr(`warning: provider logout failed: ${String(err)}\n`);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await clearSession(deps.baseUrl, deps.stateDir);
|
|
63
|
+
}
|
|
64
|
+
deps.stdout("logged out\n");
|
|
65
|
+
}
|
|
66
|
+
export async function runAuthStatus(args, deps) {
|
|
67
|
+
const rec = await readSession(deps.baseUrl, deps.stateDir);
|
|
68
|
+
if (!rec) {
|
|
69
|
+
if (args.json)
|
|
70
|
+
deps.stdout(`${JSON.stringify({ loggedIn: false })}\n`);
|
|
71
|
+
else
|
|
72
|
+
deps.stdout("not logged in\n");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const provider = providerById(deps.providers, rec.activeProvider);
|
|
76
|
+
if (!provider)
|
|
77
|
+
throw new AuthError(`active provider '${rec.activeProvider}' is not configured`);
|
|
78
|
+
const info = await provider.status(rec.session);
|
|
79
|
+
const host = new URL(deps.baseUrl).hostname;
|
|
80
|
+
if (args.json) {
|
|
81
|
+
deps.stdout(`${JSON.stringify({ ...info, host })}\n`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
deps.stdout(`provider: ${provider.displayName} [${provider.id}]\n`);
|
|
85
|
+
deps.stdout(`host: ${host}\n`);
|
|
86
|
+
if (info.expiresAt !== undefined)
|
|
87
|
+
deps.stdout(`token: ${info.state} (until ${new Date(info.expiresAt).toISOString()})\n`);
|
|
88
|
+
}
|
|
@@ -4,9 +4,55 @@ import { fetchCatalog } from "../core/schema/catalog.js";
|
|
|
4
4
|
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
|
+
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
|
+
}
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
export function operationHookPluginIds(plugins) {
|
|
21
|
+
return plugins.filter((plugin) => plugin.extendOperationSchema).map((plugin) => plugin.id);
|
|
22
|
+
}
|
|
23
|
+
export function schemaCacheMetadataMatches(schema, operationHookPlugins) {
|
|
24
|
+
if (!isRecord(schema))
|
|
25
|
+
return false;
|
|
26
|
+
if (schema["x-dropsh-schema-pipeline-version"] !== SCHEMA_PIPELINE_VERSION)
|
|
27
|
+
return false;
|
|
28
|
+
const cachedPlugins = schema["x-dropsh-operation-hook-plugins"];
|
|
29
|
+
if (!Array.isArray(cachedPlugins))
|
|
30
|
+
return false;
|
|
31
|
+
if (cachedPlugins.length !== operationHookPlugins.length)
|
|
32
|
+
return false;
|
|
33
|
+
return cachedPlugins.every((plugin, index) => plugin === operationHookPlugins[index]);
|
|
34
|
+
}
|
|
35
|
+
export async function applyOperationSchemaPlugins(schema, args, deps) {
|
|
36
|
+
let current = schema;
|
|
37
|
+
const extensions = [];
|
|
38
|
+
const ctx = { http: deps.http, auth: deps.auth, baseUrl: deps.baseUrl };
|
|
39
|
+
for (const plugin of deps.plugins) {
|
|
40
|
+
if (!plugin.extendOperationSchema)
|
|
41
|
+
continue;
|
|
42
|
+
const extended = await plugin.extendOperationSchema(args.entity, args.bundle, args.operation, current, ctx);
|
|
43
|
+
if (extended !== current) {
|
|
44
|
+
current = extended;
|
|
45
|
+
extensions.push(plugin.id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { schema: current, extensions };
|
|
49
|
+
}
|
|
7
50
|
const TARGET_RE = /^[a-z0-9_]+\/[a-z0-9_]+$/;
|
|
8
51
|
export async function runSchema(args, deps) {
|
|
9
|
-
const store = createFileStore({
|
|
52
|
+
const store = createFileStore({
|
|
53
|
+
rootDir: siteCacheRoot(deps.cwd, deps.baseUrl),
|
|
54
|
+
warn: deps.warn,
|
|
55
|
+
});
|
|
10
56
|
if (args.target === undefined) {
|
|
11
57
|
if (!args.refresh) {
|
|
12
58
|
const hit = await store.read("catalog.json");
|
|
@@ -32,9 +78,11 @@ export async function runSchema(args, deps) {
|
|
|
32
78
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
33
79
|
const [entity, bundle] = args.target.split("/", 2);
|
|
34
80
|
const cacheKey = `schema/${entity}--${bundle}.${args.operation}.json`;
|
|
81
|
+
const plugins = deps.plugins ?? [];
|
|
82
|
+
const hookPluginIds = operationHookPluginIds(plugins);
|
|
35
83
|
if (!args.refresh) {
|
|
36
84
|
const hit = await store.read(cacheKey);
|
|
37
|
-
if (hit !== undefined) {
|
|
85
|
+
if (hit !== undefined && schemaCacheMetadataMatches(hit, hookPluginIds)) {
|
|
38
86
|
deps.emit(hit);
|
|
39
87
|
return;
|
|
40
88
|
}
|
|
@@ -47,14 +95,23 @@ export async function runSchema(args, deps) {
|
|
|
47
95
|
entity,
|
|
48
96
|
bundle,
|
|
49
97
|
warn: deps.warn,
|
|
50
|
-
plugins
|
|
98
|
+
plugins,
|
|
51
99
|
});
|
|
52
100
|
const transformed = toOperationVariant(raw, args.operation);
|
|
101
|
+
const operationExtended = await applyOperationSchemaPlugins(transformed, { entity, bundle, operation: args.operation }, {
|
|
102
|
+
http: deps.http,
|
|
103
|
+
auth: deps.auth,
|
|
104
|
+
baseUrl: deps.baseUrl,
|
|
105
|
+
plugins,
|
|
106
|
+
});
|
|
53
107
|
const tagged = {
|
|
54
|
-
...
|
|
108
|
+
...operationExtended.schema,
|
|
55
109
|
"x-dropsh-source": source,
|
|
56
110
|
"x-dropsh-target": { entity_type: entity, bundle },
|
|
57
111
|
"x-dropsh-operation": args.operation,
|
|
112
|
+
"x-dropsh-schema-extensions": operationExtended.extensions,
|
|
113
|
+
"x-dropsh-schema-pipeline-version": SCHEMA_PIPELINE_VERSION,
|
|
114
|
+
"x-dropsh-operation-hook-plugins": hookPluginIds,
|
|
58
115
|
};
|
|
59
116
|
await store.write(cacheKey, tagged);
|
|
60
117
|
deps.emit(tagged);
|
|
@@ -1,24 +1,43 @@
|
|
|
1
1
|
import { ConfigError } from "../../errors.js";
|
|
2
|
-
|
|
3
|
-
if (typeof cfg.username !== "string" || cfg.username.length === 0)
|
|
4
|
-
throw new ConfigError("basicAuthPlugin: username required");
|
|
5
|
-
if (typeof cfg.password !== "string" || cfg.password.length === 0)
|
|
6
|
-
throw new ConfigError("basicAuthPlugin: password required");
|
|
7
|
-
const token = Buffer.from(`${cfg.username}:${cfg.password}`).toString("base64");
|
|
2
|
+
function adapterFromB64(b64) {
|
|
8
3
|
return {
|
|
9
4
|
async apply(req) {
|
|
10
|
-
return { ...req, headers: { ...(req.headers ?? {}), Authorization: `Basic ${
|
|
5
|
+
return { ...req, headers: { ...(req.headers ?? {}), Authorization: `Basic ${b64}` } };
|
|
11
6
|
},
|
|
12
7
|
};
|
|
13
8
|
}
|
|
14
|
-
export function
|
|
15
|
-
const adapter = createBasicAuth(config);
|
|
9
|
+
export function basicAuthProvider(config = {}) {
|
|
16
10
|
return {
|
|
17
11
|
id: "basic",
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
async
|
|
21
|
-
|
|
12
|
+
displayName: "Basic auth (username / password)",
|
|
13
|
+
capabilities: { login: true, logout: true, status: true },
|
|
14
|
+
async login(ctx) {
|
|
15
|
+
const username = config.username ?? (await ctx.prompt({ label: "Username" }));
|
|
16
|
+
if (!username)
|
|
17
|
+
throw new ConfigError("basic auth: username required");
|
|
18
|
+
const password = await ctx.prompt({ label: "Password", secret: true });
|
|
19
|
+
if (!password)
|
|
20
|
+
throw new ConfigError("basic auth: password required");
|
|
21
|
+
return { basic_b64: Buffer.from(`${username}:${password}`).toString("base64") };
|
|
22
|
+
},
|
|
23
|
+
async logout() {
|
|
24
|
+
// No server-side revoke for basic auth; the core clears the session.
|
|
25
|
+
},
|
|
26
|
+
async status(session) {
|
|
27
|
+
return { loggedIn: session !== null, provider: "basic" };
|
|
22
28
|
},
|
|
29
|
+
createAdapter(session) {
|
|
30
|
+
const b64 = session.basic_b64;
|
|
31
|
+
if (typeof b64 !== "string")
|
|
32
|
+
throw new ConfigError("basic auth: corrupt session");
|
|
33
|
+
return adapterFromB64(b64);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function basicAuthPlugin(config = {}) {
|
|
38
|
+
return {
|
|
39
|
+
id: "basic",
|
|
40
|
+
requiredModules: [],
|
|
41
|
+
authProvider: basicAuthProvider(config),
|
|
23
42
|
};
|
|
24
43
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function collectProviders(plugins) {
|
|
2
|
+
return plugins.map((p) => p.authProvider).filter((p) => p !== undefined);
|
|
3
|
+
}
|
|
4
|
+
export function loginCapableProviders(providers) {
|
|
5
|
+
return providers.filter((p) => p.capabilities.login);
|
|
6
|
+
}
|
|
7
|
+
export function providerById(providers, id) {
|
|
8
|
+
return providers.find((p) => p.id === id);
|
|
9
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export function defaultStateDir() {
|
|
5
|
+
return join(homedir(), ".config", "dropsh");
|
|
6
|
+
}
|
|
7
|
+
function hostnameFromUrl(baseUrl) {
|
|
8
|
+
return new URL(baseUrl).hostname;
|
|
9
|
+
}
|
|
10
|
+
function sessionPath(baseUrl, dir) {
|
|
11
|
+
return join(dir, `${hostnameFromUrl(baseUrl)}.json`);
|
|
12
|
+
}
|
|
13
|
+
export async function readSession(baseUrl, dir) {
|
|
14
|
+
const path = sessionPath(baseUrl, dir ?? defaultStateDir());
|
|
15
|
+
try {
|
|
16
|
+
const raw = await readFile(path, "utf8");
|
|
17
|
+
return JSON.parse(raw);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function writeSession(baseUrl, activeProvider, session, dir) {
|
|
24
|
+
const stateDir = dir ?? defaultStateDir();
|
|
25
|
+
await mkdir(stateDir, { recursive: true });
|
|
26
|
+
const record = { activeProvider, session };
|
|
27
|
+
const path = sessionPath(baseUrl, stateDir);
|
|
28
|
+
await writeFile(path, JSON.stringify(record, null, 2), {
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
mode: 0o600,
|
|
31
|
+
});
|
|
32
|
+
// writeFile's `mode` only applies when creating a new file; overwriting an
|
|
33
|
+
// existing file keeps its old (possibly looser) permissions, so tighten explicitly.
|
|
34
|
+
await chmod(path, 0o600);
|
|
35
|
+
}
|
|
36
|
+
export async function clearSession(baseUrl, dir) {
|
|
37
|
+
await rm(sessionPath(baseUrl, dir ?? defaultStateDir()), { force: true });
|
|
38
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
/**
|
|
3
|
+
* Build a prompt function. Secret prompts use raw mode + manual key
|
|
4
|
+
* accumulation on a real TTY so the input is never echoed. On a non-TTY
|
|
5
|
+
* stream (tests/pipes) input is read as a line so the value is still captured.
|
|
6
|
+
*/
|
|
7
|
+
export function createPrompt(deps = {}) {
|
|
8
|
+
const input = deps.input ?? process.stdin;
|
|
9
|
+
const write = deps.write ?? ((s) => process.stderr.write(s));
|
|
10
|
+
const isTTY = deps.isTTY ?? Boolean(input.isTTY);
|
|
11
|
+
return ({ label, secret }) => new Promise((resolve, reject) => {
|
|
12
|
+
write(`${label}: `);
|
|
13
|
+
if (secret && isTTY) {
|
|
14
|
+
const stdin = input;
|
|
15
|
+
const prevRaw = stdin.isRaw === true;
|
|
16
|
+
let buf = "";
|
|
17
|
+
stdin.setRawMode?.(true);
|
|
18
|
+
stdin.resume();
|
|
19
|
+
const cleanup = () => {
|
|
20
|
+
stdin.setRawMode?.(prevRaw);
|
|
21
|
+
stdin.pause();
|
|
22
|
+
stdin.off("data", onData);
|
|
23
|
+
};
|
|
24
|
+
const onData = (d) => {
|
|
25
|
+
for (const ch of d.toString("utf8")) {
|
|
26
|
+
if (ch === "\n" || ch === "\r") {
|
|
27
|
+
cleanup();
|
|
28
|
+
write("\n");
|
|
29
|
+
resolve(buf);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (ch === "\u0003") {
|
|
33
|
+
cleanup();
|
|
34
|
+
reject(new Error("input cancelled"));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (ch === "\u007f" || ch === "\b")
|
|
38
|
+
buf = buf.slice(0, -1);
|
|
39
|
+
else
|
|
40
|
+
buf += ch;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
stdin.on("data", onData);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Non-secret, or non-TTY (tests/pipes): read a single line.
|
|
47
|
+
const rl = createInterface({ input });
|
|
48
|
+
rl.once("line", (line) => {
|
|
49
|
+
rl.close();
|
|
50
|
+
resolve(line.replace(/\r?\n$/, ""));
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -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
|
@@ -1,33 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
+
import { runAuthLogin, runAuthLogout, runAuthStatus } from "./commands/auth.js";
|
|
4
5
|
import { runCreate } from "./commands/create.js";
|
|
5
6
|
import { runDelete } from "./commands/delete.js";
|
|
6
7
|
import { runRead } from "./commands/read.js";
|
|
7
|
-
import { runSchema } from "./commands/schema.js";
|
|
8
|
+
import { applyOperationSchemaPlugins, operationHookPluginIds, runSchema, SCHEMA_PIPELINE_VERSION, schemaCacheMetadataMatches, siteCacheRoot, } from "./commands/schema.js";
|
|
8
9
|
import { runSearch } from "./commands/search.js";
|
|
9
10
|
import { runUpdate } from "./commands/update.js";
|
|
10
11
|
import { runUploadFile } from "./commands/upload-file.js";
|
|
12
|
+
import { collectProviders, providerById } from "./core/auth/registry.js";
|
|
13
|
+
import { readSession, writeSession } from "./core/auth/session-store.js";
|
|
11
14
|
import { createFileStore } from "./core/cache/file-store.js";
|
|
12
15
|
import { createOutput } from "./core/cli/output.js";
|
|
16
|
+
import { createPrompt } from "./core/cli/prompt.js";
|
|
13
17
|
import { loadConfig } from "./core/config.js";
|
|
14
18
|
import { createHttpClient } from "./core/http.js";
|
|
15
19
|
import { createJsonApiClient } from "./core/jsonapi/client.js";
|
|
16
20
|
import { fetchJsonSchema } from "./core/schema/jsonschema-source.js";
|
|
17
21
|
import { toOperationVariant } from "./core/schema/to-jsonschema.js";
|
|
18
22
|
import { validatePayload } from "./core/schema/validate.js";
|
|
19
|
-
import { ConfigError, exitCodeFor } from "./errors.js";
|
|
23
|
+
import { AuthError, ConfigError, exitCodeFor } from "./errors.js";
|
|
20
24
|
function resolveConfigPath(override) {
|
|
21
25
|
return override ?? process.env.DROPSH_CONFIG ?? "dropsh.config.js";
|
|
22
26
|
}
|
|
27
|
+
export async function resolveAuth(deps) {
|
|
28
|
+
const rec = await readSession(deps.baseUrl, deps.stateDir);
|
|
29
|
+
if (!rec)
|
|
30
|
+
throw new AuthError("Not authenticated. Run 'dropsh auth login'.");
|
|
31
|
+
const provider = providerById(collectProviders(deps.plugins), rec.activeProvider);
|
|
32
|
+
if (!provider)
|
|
33
|
+
throw new ConfigError(`active provider '${rec.activeProvider}' is not configured`);
|
|
34
|
+
return provider.createAdapter(rec.session, {
|
|
35
|
+
http: deps.http,
|
|
36
|
+
now: deps.now,
|
|
37
|
+
save: (session) => writeSession(deps.baseUrl, provider.id, session, deps.stateDir),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
23
40
|
async function defaultContext(configPath) {
|
|
24
41
|
const cfg = await loadConfig(configPath);
|
|
25
42
|
const http = createHttpClient({ timeoutMs: cfg.defaults.timeout_ms });
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
const auth = await resolveAuth({
|
|
44
|
+
baseUrl: cfg.site.base_url,
|
|
45
|
+
plugins: cfg.plugins,
|
|
46
|
+
http,
|
|
47
|
+
now: Date.now,
|
|
48
|
+
});
|
|
31
49
|
const client = createJsonApiClient({
|
|
32
50
|
baseUrl: cfg.site.base_url,
|
|
33
51
|
prefix: cfg.site.jsonapi_prefix,
|
|
@@ -70,15 +88,42 @@ export function buildProgram(opts = {}) {
|
|
|
70
88
|
setExitCode(exitCodeFor(err));
|
|
71
89
|
}
|
|
72
90
|
}
|
|
91
|
+
async function run2(fn) {
|
|
92
|
+
try {
|
|
93
|
+
await fn();
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
output.fail(err);
|
|
97
|
+
setExitCode(exitCodeFor(err));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function authDeps() {
|
|
101
|
+
const cfg = await loadConfig(resolveConfigPath(program.opts().config));
|
|
102
|
+
return {
|
|
103
|
+
baseUrl: cfg.site.base_url,
|
|
104
|
+
providers: collectProviders(cfg.plugins),
|
|
105
|
+
stdout,
|
|
106
|
+
stderr,
|
|
107
|
+
prompt: createPrompt(),
|
|
108
|
+
openBrowser: async (url) => {
|
|
109
|
+
const { default: open } = await import("open");
|
|
110
|
+
await open(url);
|
|
111
|
+
},
|
|
112
|
+
http: createHttpClient({ timeoutMs: cfg.defaults.timeout_ms }),
|
|
113
|
+
now: Date.now,
|
|
114
|
+
isTTY: Boolean(process.stdin.isTTY),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
73
117
|
async function loadOrFetchSchema(ctx, target, op) {
|
|
74
118
|
const store = createFileStore({
|
|
75
|
-
rootDir:
|
|
119
|
+
rootDir: siteCacheRoot(ctx.cwd, ctx.baseUrl),
|
|
76
120
|
warn: (m) => stderr(`${m}\n`),
|
|
77
121
|
});
|
|
78
122
|
const [entity, bundle] = target.split("/", 2);
|
|
79
123
|
const key = `schema/${entity}--${bundle}.${op}.json`;
|
|
124
|
+
const hookPluginIds = operationHookPluginIds(ctx.plugins);
|
|
80
125
|
const hit = await store.read(key);
|
|
81
|
-
if (hit !== undefined)
|
|
126
|
+
if (hit !== undefined && schemaCacheMetadataMatches(hit, hookPluginIds))
|
|
82
127
|
return hit;
|
|
83
128
|
const { schema: raw, source } = await fetchJsonSchema({
|
|
84
129
|
http: ctx.http,
|
|
@@ -91,11 +136,20 @@ export function buildProgram(opts = {}) {
|
|
|
91
136
|
plugins: ctx.plugins,
|
|
92
137
|
});
|
|
93
138
|
const transformed = toOperationVariant(raw, op);
|
|
139
|
+
const operationExtended = await applyOperationSchemaPlugins(transformed, { entity, bundle, operation: op }, {
|
|
140
|
+
http: ctx.http,
|
|
141
|
+
auth: ctx.auth,
|
|
142
|
+
baseUrl: ctx.baseUrl,
|
|
143
|
+
plugins: ctx.plugins,
|
|
144
|
+
});
|
|
94
145
|
const tagged = {
|
|
95
|
-
...
|
|
146
|
+
...operationExtended.schema,
|
|
96
147
|
"x-dropsh-source": source,
|
|
97
148
|
"x-dropsh-target": { entity_type: entity, bundle },
|
|
98
149
|
"x-dropsh-operation": op,
|
|
150
|
+
"x-dropsh-schema-extensions": operationExtended.extensions,
|
|
151
|
+
"x-dropsh-schema-pipeline-version": SCHEMA_PIPELINE_VERSION,
|
|
152
|
+
"x-dropsh-operation-hook-plugins": hookPluginIds,
|
|
99
153
|
};
|
|
100
154
|
await store.write(key, tagged);
|
|
101
155
|
return tagged;
|
|
@@ -209,6 +263,21 @@ export function buildProgram(opts = {}) {
|
|
|
209
263
|
plugins: ctx.plugins,
|
|
210
264
|
}));
|
|
211
265
|
});
|
|
266
|
+
const auth = program.command("auth").description("Manage authentication");
|
|
267
|
+
auth
|
|
268
|
+
.command("login")
|
|
269
|
+
.description("Log in via an auth provider")
|
|
270
|
+
.option("--provider <id>", "skip the picker and use this provider id")
|
|
271
|
+
.action((o) => run2(async () => runAuthLogin(o.provider ? { provider: o.provider } : {}, await authDeps())));
|
|
272
|
+
auth
|
|
273
|
+
.command("logout")
|
|
274
|
+
.description("Clear the active session")
|
|
275
|
+
.action(() => run2(async () => runAuthLogout({}, await authDeps())));
|
|
276
|
+
auth
|
|
277
|
+
.command("status")
|
|
278
|
+
.description("Show the active session")
|
|
279
|
+
.option("--json", "machine-readable output")
|
|
280
|
+
.action((o) => run2(async () => runAuthStatus(o, await authDeps())));
|
|
212
281
|
if (opts.plugins) {
|
|
213
282
|
for (const plugin of opts.plugins) {
|
|
214
283
|
plugin.registerCommands?.(program);
|
package/dist/src/plugin-api.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { basicAuthPlugin } from "./core/auth/basic.js";
|
|
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
4
|
export { AuthError, ConfigError, HttpError } from "./errors.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dropsh",
|
|
3
|
-
"version": "0.0
|
|
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": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./dist/src/index.js",
|
|
11
|
-
"./plugin": "./dist/src/plugin-api.js"
|
|
11
|
+
"./plugin": "./dist/src/plugin-api.js",
|
|
12
|
+
"./package.json": "./package.json"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"bin",
|
|
@@ -16,20 +17,6 @@
|
|
|
16
17
|
"README.md",
|
|
17
18
|
"LICENSE"
|
|
18
19
|
],
|
|
19
|
-
"scripts": {
|
|
20
|
-
"build": "tsc",
|
|
21
|
-
"build:plugins": "npm --prefix plugins/schemata run build && npm --prefix plugins/oauth2 run build",
|
|
22
|
-
"dev": "tsx src/index.ts",
|
|
23
|
-
"test": "vitest run --passWithNoTests",
|
|
24
|
-
"test:watch": "vitest",
|
|
25
|
-
"test:integration": "npm run build && vitest run -c vitest.config.integration.ts",
|
|
26
|
-
"typecheck": "tsc --noEmit",
|
|
27
|
-
"lint": "biome check src/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
|
|
28
|
-
"lint:fix": "biome check --write src/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
|
|
29
|
-
"drupal:up": "bash tests/integrations/bin/drupal-up.sh",
|
|
30
|
-
"drupal:down": "bash tests/integrations/bin/drupal-down.sh",
|
|
31
|
-
"prepublishOnly": "npm run typecheck && npm run build"
|
|
32
|
-
},
|
|
33
20
|
"keywords": [
|
|
34
21
|
"drupal",
|
|
35
22
|
"drupal11",
|
|
@@ -56,9 +43,25 @@
|
|
|
56
43
|
"@types/node": "^20.14.0",
|
|
57
44
|
"tsx": "^4.19.0",
|
|
58
45
|
"typescript": "^5.6.0",
|
|
59
|
-
"
|
|
46
|
+
"vite": "^6.4.3",
|
|
47
|
+
"vitest": "^4.1.8"
|
|
60
48
|
},
|
|
61
49
|
"engines": {
|
|
62
50
|
"node": ">=20"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsc",
|
|
54
|
+
"build:packages": "pnpm --filter \"./packages/*\" run build",
|
|
55
|
+
"build:plugins": "pnpm --filter \"./plugins/*\" run build",
|
|
56
|
+
"dev": "tsx src/index.ts",
|
|
57
|
+
"test": "vitest run --passWithNoTests",
|
|
58
|
+
"test:watch": "vitest",
|
|
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
|
+
"drupal:up": "bash tests/integrations/bin/drupal-up.sh",
|
|
64
|
+
"drupal:down": "bash tests/integrations/bin/drupal-down.sh",
|
|
65
|
+
"release": "pnpm -r --include-workspace-root publish --no-git-checks"
|
|
63
66
|
}
|
|
64
|
-
}
|
|
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
|
-
}
|