dropsh 0.0.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.
Files changed (37) hide show
  1. package/LICENSE +51 -0
  2. package/README.md +78 -0
  3. package/bin/dropsh +7 -0
  4. package/bin/dropsh-src +7 -0
  5. package/dist/src/commands/_data.js +19 -0
  6. package/dist/src/commands/create.js +14 -0
  7. package/dist/src/commands/delete.js +13 -0
  8. package/dist/src/commands/login.js +121 -0
  9. package/dist/src/commands/read.js +10 -0
  10. package/dist/src/commands/schema.js +61 -0
  11. package/dist/src/commands/search.js +30 -0
  12. package/dist/src/commands/update.js +20 -0
  13. package/dist/src/commands/upload-file.js +30 -0
  14. package/dist/src/core/auth/api-key.js +13 -0
  15. package/dist/src/core/auth/basic.js +24 -0
  16. package/dist/src/core/auth/factory.js +17 -0
  17. package/dist/src/core/auth/jwt.js +12 -0
  18. package/dist/src/core/auth/oauth2-authcode.js +67 -0
  19. package/dist/src/core/auth/oauth2.js +64 -0
  20. package/dist/src/core/auth/token-store.js +26 -0
  21. package/dist/src/core/auth/types.js +1 -0
  22. package/dist/src/core/cache/file-store.js +38 -0
  23. package/dist/src/core/cli/output.js +20 -0
  24. package/dist/src/core/config.js +36 -0
  25. package/dist/src/core/http.js +69 -0
  26. package/dist/src/core/jsonapi/client.js +46 -0
  27. package/dist/src/core/jsonapi/query.js +18 -0
  28. package/dist/src/core/plugin.js +1 -0
  29. package/dist/src/core/schema/catalog.js +29 -0
  30. package/dist/src/core/schema/jsonschema-source.js +46 -0
  31. package/dist/src/core/schema/sources/heuristic.js +100 -0
  32. package/dist/src/core/schema/to-jsonschema.js +51 -0
  33. package/dist/src/core/schema/validate.js +32 -0
  34. package/dist/src/errors.js +49 -0
  35. package/dist/src/index.js +244 -0
  36. package/dist/src/plugin-api.js +4 -0
  37. package/package.json +64 -0
package/LICENSE ADDED
@@ -0,0 +1,51 @@
1
+ drupal-cli is dual-licensed. You may use this software under the terms of
2
+ either the MIT License or the GNU General Public License version 2 or any
3
+ later version, at your option.
4
+
5
+ SPDX-License-Identifier: (MIT OR GPL-2.0-or-later)
6
+
7
+ ================================================================================
8
+ MIT License
9
+ ================================================================================
10
+
11
+ Copyright (c) 2026 Christian Wiedemann
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+
31
+ ================================================================================
32
+ GNU General Public License, version 2 or later
33
+ ================================================================================
34
+
35
+ The full text of the GNU GPL v2 is available at:
36
+ https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt
37
+
38
+ The full text of any later GPL version is available at:
39
+ https://www.gnu.org/licenses/
40
+
41
+ Copyright (C) 2026 Christian Wiedemann
42
+
43
+ This program is free software; you can redistribute it and/or modify it under
44
+ the terms of the GNU General Public License as published by the Free Software
45
+ Foundation; either version 2 of the License, or (at your option) any later
46
+ version.
47
+
48
+ This program is distributed in the hope that it will be useful, but WITHOUT
49
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
50
+ FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
51
+ details.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # dropsh
2
+
3
+ Entity-agnostic helper CLI for Drupal 11 JSON:API. Written in TypeScript, used directly by the `dropsh` Claude skill for editorial publishing workflows.
4
+
5
+ This is the foundation CLI. Discovery, schema generation, and the skill layer are delivered by follow-up plans.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install
11
+ npm run build
12
+ ```
13
+
14
+ ## Configure
15
+
16
+ Copy `.dropsh.yml.example` to `.dropsh.yml` in your project and set the auth fields. Secrets are referenced as `${ENV_VAR}` and expanded at load time.
17
+
18
+ ```yaml
19
+ site:
20
+ base_url: https://my-drupal.example.com
21
+ auth:
22
+ type: basic
23
+ username: ${DRUPAL_USER}
24
+ password: ${DRUPAL_PASSWORD}
25
+ ```
26
+
27
+ ## Commands
28
+
29
+ All commands write JSON to stdout, structured errors to stderr, and use exit codes 0-5.
30
+
31
+ ```bash
32
+ dropsh read <entity_type>/<bundle>/<uuid>
33
+ dropsh search <entity_type> [--bundle=<b>] [--filter=key:value]… [--limit=N]
34
+ dropsh create <entity_type> --bundle=<b> --data=<json|@file> [--dry-run] [--no-validate]
35
+ dropsh update <entity_type>/<bundle>/<uuid> --data=<json|@file> [--dry-run] [--no-validate]
36
+ dropsh delete <entity_type>/<bundle>/<uuid> [--dry-run]
37
+ dropsh upload-file --target=<entity_type>/<bundle>/<uuid>/<field> --file=<path> [--dry-run]
38
+ dropsh schema [--refresh]
39
+ dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh]
40
+ ```
41
+
42
+ ### `schema`
43
+
44
+ Without a target, prints the list of available `<entity_type>/<bundle>` targets on the site.
45
+
46
+ With a target (e.g. `node/article`), prints a JSON Schema document that validates a JSON:API request body for that resource.
47
+
48
+ - `--for=create` (default) / `--for=update` — operation variant (update clears required fields)
49
+ - `--refresh` — bypass the cache for this call
50
+
51
+ If the site has `drupal/schemata` + `drupal/schemata_json_schema` installed, the schema is authoritative (required fields, constraints). Otherwise, a shallow schema is returned from sample records with a warning on stderr (field names only, no required fields, no constraints). The output carries `x-dropsh-source: "schemata" | "heuristic" | "heuristic-empty"` so consumers can tell how strict the schema is.
52
+
53
+ Schemas are cached under `.dropsh/cache/` next to your `.dropsh.yml`. The `.dropsh/` directory is gitignored.
54
+
55
+ ### Client-side validation in `create` / `update`
56
+
57
+ `dropsh create` and `dropsh update` run the payload through the bundle's schema before sending it. A validation failure exits with code 4 (`E_VALIDATION`) and emits the Ajv errors on stderr without issuing an HTTP request. Pass `--no-validate` to skip the check.
58
+
59
+ ## Development
60
+
61
+ ```bash
62
+ npm test # Vitest unit suite
63
+ npm run test:watch
64
+ npm run typecheck
65
+ npm run build
66
+ ```
67
+
68
+ ### Integration tests
69
+
70
+ Local-only, requires DDEV. See `tests/integrations/README.md`.
71
+
72
+ ```bash
73
+ npm run drupal:up
74
+ npm run test:integration
75
+ npm run drupal:down
76
+ ```
77
+
78
+ See `docs/superpowers/specs/2026-04-21-dropsh-design.md` for the full design.
package/bin/dropsh ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import("../dist/src/index.js")
3
+ .then((m) => m.main())
4
+ .catch((err) => {
5
+ console.error(err);
6
+ process.exit(1);
7
+ });
package/bin/dropsh-src ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import("../src/index.js")
3
+ .then((m) => m.main())
4
+ .catch((err) => {
5
+ console.error(err);
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,19 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { ValidationError } from "../errors.js";
3
+ export async function readDataArg(raw) {
4
+ let text = raw;
5
+ if (raw.startsWith("@")) {
6
+ try {
7
+ text = await readFile(raw.slice(1), "utf8");
8
+ }
9
+ catch (err) {
10
+ throw new ValidationError(`Cannot read --data file ${raw.slice(1)}: ${err.message}`);
11
+ }
12
+ }
13
+ try {
14
+ return JSON.parse(text);
15
+ }
16
+ catch (err) {
17
+ throw new ValidationError(`--data is not valid JSON: ${err.message}`);
18
+ }
19
+ }
@@ -0,0 +1,14 @@
1
+ import { readDataArg } from "./_data.js";
2
+ export async function runCreate(args, deps) {
3
+ const payload = await readDataArg(args.dataArg);
4
+ const target = `${args.entityType}/${args.bundle}`;
5
+ if (!args.noValidate && deps.validate) {
6
+ await deps.validate(payload, target);
7
+ }
8
+ if (args.dryRun) {
9
+ deps.emit({ dry_run: true, method: "POST", path: target, payload });
10
+ return;
11
+ }
12
+ const res = await deps.client.post(target, payload);
13
+ deps.emit(res);
14
+ }
@@ -0,0 +1,13 @@
1
+ import { ValidationError } from "../errors.js";
2
+ const TARGET_RE = /^[a-z0-9_]+\/[a-z0-9_]+\/[a-f0-9-]{8,}$/;
3
+ export async function runDelete(args, deps) {
4
+ if (!TARGET_RE.test(args.target)) {
5
+ throw new ValidationError(`target must be <entity_type>/<bundle>/<uuid>, got "${args.target}"`);
6
+ }
7
+ if (args.dryRun) {
8
+ deps.emit({ dry_run: true, method: "DELETE", path: args.target });
9
+ return;
10
+ }
11
+ const res = await deps.client.delete(args.target);
12
+ deps.emit(res);
13
+ }
@@ -0,0 +1,121 @@
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
+ }
@@ -0,0 +1,10 @@
1
+ import { ValidationError } from "../errors.js";
2
+ // Allow short test UUIDs (e.g. "u1") and full Drupal UUIDs (36 chars)
3
+ const TARGET_RE = /^[a-z0-9_]+\/[a-z0-9_]+\/[a-z0-9_-]+$/;
4
+ export async function runRead(args, deps) {
5
+ if (!TARGET_RE.test(args.target)) {
6
+ throw new ValidationError(`target must be <entity_type>/<bundle>/<uuid>, got "${args.target}"`);
7
+ }
8
+ const res = await deps.client.get(args.target);
9
+ deps.emit(res);
10
+ }
@@ -0,0 +1,61 @@
1
+ import { join } from "node:path";
2
+ import { createFileStore } from "../core/cache/file-store.js";
3
+ import { fetchCatalog } from "../core/schema/catalog.js";
4
+ import { fetchJsonSchema } from "../core/schema/jsonschema-source.js";
5
+ import { toOperationVariant } from "../core/schema/to-jsonschema.js";
6
+ import { ValidationError } from "../errors.js";
7
+ const TARGET_RE = /^[a-z0-9_]+\/[a-z0-9_]+$/;
8
+ export async function runSchema(args, deps) {
9
+ const store = createFileStore({ rootDir: join(deps.cwd, ".dropsh/cache"), warn: deps.warn });
10
+ if (args.target === undefined) {
11
+ if (!args.refresh) {
12
+ const hit = await store.read("catalog.json");
13
+ if (hit !== undefined) {
14
+ deps.emit(hit);
15
+ return;
16
+ }
17
+ }
18
+ const fresh = await fetchCatalog({
19
+ http: deps.http,
20
+ auth: deps.auth,
21
+ baseUrl: deps.baseUrl,
22
+ jsonapiPrefix: deps.jsonapiPrefix,
23
+ });
24
+ await store.write("catalog.json", fresh);
25
+ deps.emit(fresh);
26
+ return;
27
+ }
28
+ if (!TARGET_RE.test(args.target)) {
29
+ throw new ValidationError(`target must be '<entity_type>/<bundle>', got '${args.target}'`);
30
+ }
31
+ // TARGET_RE guarantees two segments; non-null assertions are safe here.
32
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
33
+ const [entity, bundle] = args.target.split("/", 2);
34
+ const cacheKey = `schema/${entity}--${bundle}.${args.operation}.json`;
35
+ if (!args.refresh) {
36
+ const hit = await store.read(cacheKey);
37
+ if (hit !== undefined) {
38
+ deps.emit(hit);
39
+ return;
40
+ }
41
+ }
42
+ const { schema: raw, source } = await fetchJsonSchema({
43
+ http: deps.http,
44
+ auth: deps.auth,
45
+ baseUrl: deps.baseUrl,
46
+ jsonapiPrefix: deps.jsonapiPrefix,
47
+ entity,
48
+ bundle,
49
+ warn: deps.warn,
50
+ plugins: deps.plugins ?? [],
51
+ });
52
+ const transformed = toOperationVariant(raw, args.operation);
53
+ const tagged = {
54
+ ...transformed,
55
+ "x-dropsh-source": source,
56
+ "x-dropsh-target": { entity_type: entity, bundle },
57
+ "x-dropsh-operation": args.operation,
58
+ };
59
+ await store.write(cacheKey, tagged);
60
+ deps.emit(tagged);
61
+ }
@@ -0,0 +1,30 @@
1
+ import { DrupalJsonApiParams } from "drupal-jsonapi-params";
2
+ import { ValidationError } from "../errors.js";
3
+ export function parseFilterFlag(raw) {
4
+ const parts = raw.split(":");
5
+ if (parts.length === 2) {
6
+ const [key, value] = parts;
7
+ return { key, value };
8
+ }
9
+ if (parts.length >= 3) {
10
+ const key = parts[0];
11
+ const operator = parts[1];
12
+ const value = parts.slice(2).join(":");
13
+ return { key, operator, value };
14
+ }
15
+ throw new ValidationError(`--filter must be key:value or key:op:value, got "${raw}"`);
16
+ }
17
+ export async function runSearch(args, deps) {
18
+ const params = new DrupalJsonApiParams();
19
+ for (const raw of args.filters) {
20
+ const f = parseFilterFlag(raw);
21
+ if (f.operator)
22
+ params.addFilter(f.key, f.value, f.operator);
23
+ else
24
+ params.addFilter(f.key, f.value);
25
+ }
26
+ params.addPageLimit(args.limit);
27
+ const path = args.bundle ? `${args.entityType}/${args.bundle}` : args.entityType;
28
+ const res = await deps.client.get(path, params);
29
+ deps.emit(res);
30
+ }
@@ -0,0 +1,20 @@
1
+ import { ValidationError } from "../errors.js";
2
+ import { readDataArg } from "./_data.js";
3
+ const TARGET_RE = /^[a-z0-9_]+\/[a-z0-9_]+\/[a-f0-9-]{8,}$/;
4
+ export async function runUpdate(args, deps) {
5
+ if (!TARGET_RE.test(args.target)) {
6
+ throw new ValidationError(`target must be <entity_type>/<bundle>/<uuid>, got "${args.target}"`);
7
+ }
8
+ const payload = await readDataArg(args.dataArg);
9
+ const [entity, bundle] = args.target.split("/", 3);
10
+ const schemaTarget = `${entity}/${bundle}`;
11
+ if (!args.noValidate && deps.validate) {
12
+ await deps.validate(payload, schemaTarget);
13
+ }
14
+ if (args.dryRun) {
15
+ deps.emit({ dry_run: true, method: "PATCH", path: args.target, payload });
16
+ return;
17
+ }
18
+ const res = await deps.client.patch(args.target, payload);
19
+ deps.emit(res);
20
+ }
@@ -0,0 +1,30 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ValidationError } from "../errors.js";
4
+ const TARGET_RE = /^[a-z0-9_]+\/[a-z0-9_]+\/[a-f0-9-]{8,}\/[a-z0-9_]+$/;
5
+ export async function runUploadFile(args, deps) {
6
+ if (!TARGET_RE.test(args.target)) {
7
+ throw new ValidationError(`target must be <entity_type>/<bundle>/<uuid>/<field>, got "${args.target}"`);
8
+ }
9
+ let fileSize = 0;
10
+ try {
11
+ fileSize = (await stat(args.file)).size;
12
+ }
13
+ catch {
14
+ throw new ValidationError(`file does not exist: ${args.file}`);
15
+ }
16
+ const filename = path.basename(args.file);
17
+ if (args.dryRun) {
18
+ deps.emit({
19
+ dry_run: true,
20
+ method: "POST-upload",
21
+ path: args.target,
22
+ filename,
23
+ bytes: fileSize,
24
+ });
25
+ return;
26
+ }
27
+ const data = await readFile(args.file);
28
+ const res = await deps.client.upload(args.target, filename, data);
29
+ deps.emit(res);
30
+ }
@@ -0,0 +1,13 @@
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
+ }
@@ -0,0 +1,24 @@
1
+ import { ConfigError } from "../../errors.js";
2
+ export function createBasicAuth(cfg) {
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");
8
+ return {
9
+ async apply(req) {
10
+ return { ...req, headers: { ...(req.headers ?? {}), Authorization: `Basic ${token}` } };
11
+ },
12
+ };
13
+ }
14
+ export function basicAuthPlugin(config) {
15
+ const adapter = createBasicAuth(config);
16
+ return {
17
+ id: "basic",
18
+ requiredModules: [],
19
+ createAuthAdapter: () => adapter,
20
+ async extendSchema(_entityType, _bundle, schema, _ctx) {
21
+ return schema;
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,17 @@
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
+ }
@@ -0,0 +1,12 @@
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
+ }
@@ -0,0 +1,67 @@
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
+ }