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 CHANGED
@@ -1,78 +1,258 @@
1
1
  # dropsh
2
2
 
3
- Entity-agnostic helper CLI for Drupal 11 JSON:API. Written in TypeScript, used directly by the `dropsh` Claude skill for editorial publishing workflows.
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
- This is the foundation CLI. Discovery, schema generation, and the skill layer are delivered by follow-up plans.
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
- npm install
11
- npm run build
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
- ## Configure
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
- 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.
134
+ Authentication is configured through plugins. Each plugin that contributes an
135
+ auth provider appears as a login option.
17
136
 
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}
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 exit codes 0-5.
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=<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]
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> [--dry-run]
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
- ### `schema`
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
- Without a target, prints the list of available `<entity_type>/<bundle>` targets on the site.
183
+ Bundled plugins:
45
184
 
46
- With a target (e.g. `node/article`), prints a JSON Schema document that validates a JSON:API request body for that resource.
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
- - `--for=create` (default) / `--for=update` — operation variant (update clears required fields)
49
- - `--refresh` — bypass the cache for this call
192
+ Plugin APIs are exported from `dropsh/plugin`:
50
193
 
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.
194
+ ```ts
195
+ import type { dropshPlugin } from "dropsh/plugin";
52
196
 
53
- Schemas are cached under `.dropsh/cache/` next to your `.dropsh.yml`. The `.dropsh/` directory is gitignored.
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
- ### Client-side validation in `create` / `update`
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
- `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.
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
- npm test # Vitest unit suite
63
- npm run test:watch
64
- npm run typecheck
65
- npm run build
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
- ### Integration tests
250
+ ## Publishing
69
251
 
70
- Local-only, requires DDEV. See `tests/integrations/README.md`.
252
+ The CLI and workspace packages are published together from the `1.x` branch:
71
253
 
72
254
  ```bash
73
- npm run drupal:up
74
- npm run test:integration
75
- npm run drupal:down
255
+ pnpm run release
76
256
  ```
77
257
 
78
- See `docs/superpowers/specs/2026-04-21-dropsh-design.md` for the full design.
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({ rootDir: join(deps.cwd, ".dropsh/cache"), warn: deps.warn });
52
+ const store = createFileStore({
53
+ rootDir: siteCacheRoot(deps.cwd, deps.baseUrl),
54
+ warn: deps.warn,
55
+ });
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: deps.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
- ...transformed,
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
- 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");
2
+ function adapterFromB64(b64) {
8
3
  return {
9
4
  async apply(req) {
10
- return { ...req, headers: { ...(req.headers ?? {}), Authorization: `Basic ${token}` } };
5
+ return { ...req, headers: { ...(req.headers ?? {}), Authorization: `Basic ${b64}` } };
11
6
  },
12
7
  };
13
8
  }
14
- export function basicAuthPlugin(config) {
15
- const adapter = createBasicAuth(config);
9
+ export function basicAuthProvider(config = {}) {
16
10
  return {
17
11
  id: "basic",
18
- requiredModules: [],
19
- createAuthAdapter: () => adapter,
20
- async extendSchema(_entityType, _bundle, schema, _ctx) {
21
- return schema;
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 authPlugin = cfg.plugins.find((p) => p.createAuthAdapter);
27
- if (!authPlugin?.createAuthAdapter) {
28
- throw new ConfigError("No auth plugin configured. Add basicAuthPlugin() or oauth2Plugin() to config.plugins.");
29
- }
30
- const auth = authPlugin.createAuthAdapter();
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: `${ctx.cwd}/.dropsh/cache`,
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
- ...transformed,
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);
@@ -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.1",
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
- "vitest": "^2.1.0"
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
- }