dropsh 0.0.1 → 0.0.2

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
@@ -6,24 +6,74 @@ This is the foundation CLI. Discovery, schema generation, and the skill layer ar
6
6
 
7
7
  ## Install
8
8
 
9
+ This is a [pnpm](https://pnpm.io) workspace (root CLI + `plugins/*` + `packages/*`).
10
+ The pinned pnpm version is declared in `package.json` (`packageManager`); enable it with
11
+ `corepack enable`.
12
+
9
13
  ```bash
10
- npm install
11
- npm run build
14
+ pnpm install
15
+ pnpm run build
12
16
  ```
13
17
 
14
18
  ## Configure
15
19
 
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.
20
+ Copy `dropsh.config.example.js` to `dropsh.config.js` in your project. The config
21
+ carries only **non-secret connection parameters** — secrets (passwords, client
22
+ secrets, tokens) are never stored in the config. They are prompted at login and
23
+ persisted separately (see [Authentication](#authentication)).
24
+
25
+ ```js
26
+ import { basicAuthPlugin } from "dropsh";
27
+ import { oauth2Plugin } from "@dropsh/plugin-oauth2";
28
+
29
+ export default {
30
+ site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
31
+ defaults: { dry_run: false, timeout_ms: 30000 },
32
+ plugins: [
33
+ basicAuthPlugin(),
34
+ oauth2Plugin({ type: "oauth2_authcode", client_id: "my-client", token_url: "https://my-drupal.example.com/oauth/token" }),
35
+ ],
36
+ };
37
+ ```
38
+
39
+ ## Authentication
40
+
41
+ Authentication is provider-based. Each entry in `plugins` that contributes an auth
42
+ provider (`basicAuthPlugin()`, `oauth2Plugin(...)`) becomes a login option. You log
43
+ in once per host; dropsh stores a single active session and uses it for every
44
+ subsequent command.
45
+
46
+ ```bash
47
+ dropsh auth login # interactive picker over the configured providers
48
+ dropsh auth login --provider basic # skip the picker and use a provider by id
49
+ dropsh auth logout # clear the active session for the host
50
+ dropsh auth status # show the active session
51
+ dropsh auth status --json # machine-readable status
52
+ ```
53
+
54
+ `dropsh auth login` prompts for any secrets the chosen provider needs (basic auth
55
+ prompts username + password; OAuth2 password / client-credentials prompt the client
56
+ secret and, for the password grant, the user password; `oauth2_authcode` opens the
57
+ browser for a PKCE flow). On a non-interactive (non-TTY) shell you must pass
58
+ `--provider <id>`.
59
+
60
+ ### Where secrets live
61
+
62
+ Secrets and tokens are **never** written to `dropsh.config.js`. The active session
63
+ is stored per host at `~/.config/dropsh/<host>.json` with file mode `0600`:
17
64
 
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}
65
+ ```json
66
+ { "activeProvider": "oauth2_authcode", "session": { "access_token": "…", "expires_at": 1717459200000 } }
25
67
  ```
26
68
 
69
+ Only non-secret connection parameters stay in the config:
70
+
71
+ - **basic** — `basicAuthPlugin()` (no fields; an optional `username` may be
72
+ pre-seeded, but the password is always prompted)
73
+ - **oauth2** — `client_id`, `token_url`, optional `scope`; `redirect_port` for
74
+ `oauth2_authcode`; `username` for `oauth2_password`. The `client_secret` and the
75
+ user `password` are prompted at login, never stored in config.
76
+
27
77
  ## Commands
28
78
 
29
79
  All commands write JSON to stdout, structured errors to stderr, and use exit codes 0-5.
@@ -50,19 +100,240 @@ With a target (e.g. `node/article`), prints a JSON Schema document that validate
50
100
 
51
101
  If the site has `drupal/schemata` + `drupal/schemata_json_schema` installed, the schema is authoritative (required fields, constraints). Otherwise, a shallow schema is returned from sample records with a warning on stderr (field names only, no required fields, no constraints). The output carries `x-dropsh-source: "schemata" | "heuristic" | "heuristic-empty"` so consumers can tell how strict the schema is.
52
102
 
53
- Schemas are cached under `.dropsh/cache/` next to your `.dropsh.yml`. The `.dropsh/` directory is gitignored.
103
+ Schemas are cached under `.dropsh/cache/`. Override the config path via `DROPSH_CONFIG` or `--config`.
104
+
105
+ ### Authoritative schemas — `@dropsh/plugin-schemata`
106
+
107
+ Without a schema plugin, the schema is derived from sample records (field names
108
+ only). [`@dropsh/plugin-schemata`](plugins/schemata/README.md) returns the
109
+ authoritative schema (required fields + constraints) from Drupal's `schemata`
110
+ module, which makes client-side validation in `create` / `update` meaningful.
111
+ Requires the Drupal modules `schemata` + `jsonapi_schema`.
112
+
113
+ ```js
114
+ import { schemataPlugin } from "@dropsh/plugin-schemata";
115
+
116
+ export default {
117
+ site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
118
+ plugins: [basicAuthPlugin(), schemataPlugin()],
119
+ };
120
+ ```
121
+
122
+ The output's `x-dropsh-source` is `"schemata"` when authoritative, or
123
+ `"heuristic"` / `"heuristic-empty"` for the sample-derived fallback.
124
+
125
+ ### Canvas schema plugin
126
+
127
+ Canvas support lives in `@dropsh/plugin-canvas` and requires these Drupal modules:
128
+
129
+ - `canvas`
130
+ - `jsonapi_sdc`
131
+
132
+ Register the plugin in `dropsh.config.js`:
133
+
134
+ ```js
135
+ import { basicAuthPlugin } from "dropsh";
136
+ import { canvasPlugin } from "@dropsh/plugin-canvas";
137
+
138
+ export default {
139
+ site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
140
+ plugins: [
141
+ basicAuthPlugin(),
142
+ canvasPlugin(),
143
+ ],
144
+ };
145
+ ```
146
+
147
+ Then ask for the Canvas schema:
148
+
149
+ ```bash
150
+ dropsh schema canvas_page/canvas_page --for=create
151
+ ```
152
+
153
+ The returned JSON Schema includes `x-dropsh-builder: "canvas"`, `x-dropsh-components`, and a typed `attributes.components` array. If `jsonapi_sdc` is missing or inaccessible, the Canvas schema fails instead of returning incomplete component information.
54
154
 
55
155
  ### Client-side validation in `create` / `update`
56
156
 
57
157
  `dropsh create` and `dropsh update` run the payload through the bundle's schema before sending it. A validation failure exits with code 4 (`E_VALIDATION`) and emits the Ajv errors on stderr without issuing an HTTP request. Pass `--no-validate` to skip the check.
58
158
 
159
+ ## Writing plugins
160
+
161
+ A plugin is a function that returns a `DropSHPlugin`. Plugins can do three
162
+ independent things — extend schemas, provide an auth provider, and register CLI
163
+ commands — and a single plugin may combine any of them. Everything you need is
164
+ re-exported from `dropsh/plugin`:
165
+
166
+ ```ts
167
+ import type {
168
+ DropSHPlugin, PluginContext, SchemaOperation, // schema extension
169
+ AuthProvider, AuthContext, AuthSession, AdapterRuntime, AuthAdapter, // auth
170
+ } from "dropsh/plugin";
171
+ import { AuthError, ConfigError, HttpError, createHttpClient } from "dropsh/plugin";
172
+ ```
173
+
174
+ The `DropSHPlugin` shape:
175
+
176
+ ```ts
177
+ interface DropSHPlugin {
178
+ readonly id: string; // unique id, also the auth-provider key
179
+ readonly requiredModules: string[]; // Drupal modules the plugin needs
180
+ authProvider?: AuthProvider; // optional: contributes a login option
181
+ extendSchema(entityType, bundle, baseSchema, ctx): Promise<unknown>;
182
+ extendOperationSchema?(entityType, bundle, operation, schema, ctx): Promise<unknown>;
183
+ registerCommands?(program): void; // optional: add Commander subcommands
184
+ }
185
+ ```
186
+
187
+ A plugin is wired up by adding it to `plugins` in `dropsh.config.js`. Package it
188
+ as its own npm module with `dropsh` as a **peer dependency** (so it shares the
189
+ host's core), or keep it local and import it directly.
190
+
191
+ ### A schema-extension plugin
192
+
193
+ `extendSchema` is called while building the JSON Schema for a target. It receives
194
+ the schema produced so far and returns a (possibly) modified schema. Return
195
+ `baseSchema` unchanged when the plugin does not apply. `extendOperationSchema`
196
+ runs later, per operation (`create` / `update`), when you need to vary the schema
197
+ by operation. `ctx: PluginContext` gives you `{ http, auth, baseUrl }` to call the
198
+ site if you need live data.
199
+
200
+ ```ts
201
+ import type { DropSHPlugin, PluginContext, SchemaOperation } from "dropsh/plugin";
202
+
203
+ // Marks node/article's `field_legacy_id` read-only and adds a provenance tag.
204
+ export function articleRulesPlugin(): DropSHPlugin {
205
+ return {
206
+ id: "article-rules",
207
+ requiredModules: [],
208
+
209
+ async extendSchema(entityType: string, bundle: string, baseSchema: unknown, _ctx: PluginContext) {
210
+ if (entityType !== "node" || bundle !== "article") return baseSchema;
211
+ const schema = baseSchema as {
212
+ properties?: { attributes?: { properties?: Record<string, unknown> } };
213
+ };
214
+ const attrs = schema.properties?.attributes?.properties;
215
+ if (attrs?.field_legacy_id && typeof attrs.field_legacy_id === "object") {
216
+ (attrs.field_legacy_id as Record<string, unknown>).readOnly = true;
217
+ }
218
+ return { ...schema, "x-extended-by": "article-rules" };
219
+ },
220
+
221
+ // Optional: on create, require a field that is optional on update.
222
+ async extendOperationSchema(
223
+ entityType: string,
224
+ bundle: string,
225
+ operation: SchemaOperation,
226
+ schema: unknown,
227
+ _ctx: PluginContext,
228
+ ) {
229
+ if (entityType !== "node" || bundle !== "article" || operation !== "create") return schema;
230
+ const s = schema as { required?: string[] };
231
+ const required = new Set(s.required ?? []);
232
+ required.add("field_legacy_id");
233
+ return { ...s, required: [...required] };
234
+ },
235
+ };
236
+ }
237
+ ```
238
+
239
+ Register it:
240
+
241
+ ```js
242
+ import { articleRulesPlugin } from "./plugins/article-rules.js";
243
+ export default {
244
+ site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
245
+ plugins: [basicAuthPlugin(), articleRulesPlugin()],
246
+ };
247
+ ```
248
+
249
+ The bundled `@dropsh/plugin-schemata` (authoritative schemas from the Drupal
250
+ `schemata` module) and `@dropsh/plugin-canvas` (component schemas) are real
251
+ examples of schema plugins — see `plugins/schemata/src/index.ts` and
252
+ `plugins/canvas/src/index.ts`.
253
+
254
+ ### An auth-provider plugin
255
+
256
+ An auth provider is a single `AuthProvider` object exposed via the plugin's
257
+ `authProvider` field. It owns one protocol end-to-end: `login` acquires a session
258
+ (prompting / browser as needed), `createAdapter` turns a stored session into the
259
+ `apply(req)` runtime hook, and `logout` / `status` round it out. The core handles
260
+ the picker, the single-session state file, and dispatch — the provider never
261
+ touches stdin or disk directly; it uses the injected `AuthContext` / `AdapterRuntime`.
262
+
263
+ ```ts
264
+ import type {
265
+ AdapterRuntime, AuthAdapter, AuthContext, AuthProvider, AuthSession, DropSHPlugin,
266
+ } from "dropsh/plugin";
267
+ import { ConfigError } from "dropsh/plugin";
268
+
269
+ // Static API-key auth: prompts for a token at login, sends it as a header.
270
+ export function apiKeyProvider(): AuthProvider {
271
+ return {
272
+ id: "apikey",
273
+ displayName: "API key (X-API-Key header)",
274
+ capabilities: { login: true, logout: true, status: true },
275
+
276
+ async login(ctx: AuthContext): Promise<AuthSession> {
277
+ const key = await ctx.prompt({ label: "API key", secret: true });
278
+ if (!key) throw new ConfigError("apikey: a non-empty key is required");
279
+ return { api_key: key };
280
+ },
281
+
282
+ async logout(_ctx: AuthContext) {
283
+ // No server-side revoke; the core clears the local session file.
284
+ },
285
+
286
+ async status(session: AuthSession | null) {
287
+ return { loggedIn: session !== null, provider: "apikey" };
288
+ },
289
+
290
+ createAdapter(session: AuthSession, _rt: AdapterRuntime): AuthAdapter {
291
+ const key = session.api_key;
292
+ if (typeof key !== "string") throw new ConfigError("apikey: corrupt session");
293
+ return {
294
+ async apply(req) {
295
+ return { ...req, headers: { ...(req.headers ?? {}), "X-API-Key": key } };
296
+ },
297
+ };
298
+ },
299
+ };
300
+ }
301
+
302
+ export function apiKeyPlugin(): DropSHPlugin {
303
+ return {
304
+ id: "apikey",
305
+ requiredModules: [],
306
+ authProvider: apiKeyProvider(),
307
+ async extendSchema(_entityType, _bundle, schema) {
308
+ return schema; // not a schema plugin
309
+ },
310
+ };
311
+ }
312
+ ```
313
+
314
+ Once registered in `plugins`, it shows up automatically in `dropsh auth login`:
315
+
316
+ ```bash
317
+ dropsh auth login --provider apikey
318
+ ```
319
+
320
+ `AuthContext` (passed to `login`/`logout`) provides `baseUrl`, an `http` client,
321
+ `prompt({ label, secret })`, `openBrowser(url)`, `stdout(s)`, and `now()`.
322
+ `AdapterRuntime` (passed to `createAdapter`) provides `http`, `now()`, and
323
+ `save(session)` — call `save` to persist a refreshed session back to the active
324
+ slot (the OAuth2 `oauth2_authcode` provider uses this to store a refreshed token).
325
+ Throw `AuthError` for expired/invalid sessions so the CLI exits with code 3 and
326
+ tells the user to run `dropsh auth login`. The bundled `@dropsh/plugin-oauth2`
327
+ (`plugins/oauth2/src/provider.ts`) and the core basic provider
328
+ (`src/core/auth/basic.ts`) are full reference implementations.
329
+
59
330
  ## Development
60
331
 
61
332
  ```bash
62
- npm test # Vitest unit suite
63
- npm run test:watch
64
- npm run typecheck
65
- npm run build
333
+ pnpm test # Vitest unit suite
334
+ pnpm run test:watch
335
+ pnpm run typecheck
336
+ pnpm run build
66
337
  ```
67
338
 
68
339
  ### Integration tests
@@ -70,9 +341,21 @@ npm run build
70
341
  Local-only, requires DDEV. See `tests/integrations/README.md`.
71
342
 
72
343
  ```bash
73
- npm run drupal:up
74
- npm run test:integration
75
- npm run drupal:down
344
+ pnpm run drupal:up
345
+ pnpm run test:integration
346
+ pnpm run drupal:down
347
+ ```
348
+
349
+ ## Publishing
350
+
351
+ The CLI and all workspace packages are published together with a single command:
352
+
353
+ ```bash
354
+ pnpm run release
76
355
  ```
77
356
 
78
- See `docs/superpowers/specs/2026-04-21-dropsh-design.md` for the full design.
357
+ This publishes `dropsh` (workspace root) plus `@dropsh/sdc-client`,
358
+ `@dropsh/plugin-schemata`, `@dropsh/plugin-oauth2` and `@dropsh/plugin-canvas`.
359
+ `prepublishOnly` runs typecheck + build per package; scoped packages publish with
360
+ public access via their `publishConfig`, and pnpm rewrites `workspace:*` deps to the
361
+ released version. Log in first with `npm login`.
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,6 +4,40 @@ 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
+ function isRecord(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ export function operationHookPluginIds(plugins) {
12
+ return plugins.filter((plugin) => plugin.extendOperationSchema).map((plugin) => plugin.id);
13
+ }
14
+ export function schemaCacheMetadataMatches(schema, operationHookPlugins) {
15
+ if (!isRecord(schema))
16
+ return false;
17
+ if (schema["x-dropsh-schema-pipeline-version"] !== SCHEMA_PIPELINE_VERSION)
18
+ return false;
19
+ const cachedPlugins = schema["x-dropsh-operation-hook-plugins"];
20
+ if (!Array.isArray(cachedPlugins))
21
+ return false;
22
+ if (cachedPlugins.length !== operationHookPlugins.length)
23
+ return false;
24
+ return cachedPlugins.every((plugin, index) => plugin === operationHookPlugins[index]);
25
+ }
26
+ export async function applyOperationSchemaPlugins(schema, args, deps) {
27
+ let current = schema;
28
+ const extensions = [];
29
+ const ctx = { http: deps.http, auth: deps.auth, baseUrl: deps.baseUrl };
30
+ for (const plugin of deps.plugins) {
31
+ if (!plugin.extendOperationSchema)
32
+ continue;
33
+ const extended = await plugin.extendOperationSchema(args.entity, args.bundle, args.operation, current, ctx);
34
+ if (extended !== current) {
35
+ current = extended;
36
+ extensions.push(plugin.id);
37
+ }
38
+ }
39
+ return { schema: current, extensions };
40
+ }
7
41
  const TARGET_RE = /^[a-z0-9_]+\/[a-z0-9_]+$/;
8
42
  export async function runSchema(args, deps) {
9
43
  const store = createFileStore({ rootDir: join(deps.cwd, ".dropsh/cache"), warn: deps.warn });
@@ -32,9 +66,11 @@ export async function runSchema(args, deps) {
32
66
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
33
67
  const [entity, bundle] = args.target.split("/", 2);
34
68
  const cacheKey = `schema/${entity}--${bundle}.${args.operation}.json`;
69
+ const plugins = deps.plugins ?? [];
70
+ const hookPluginIds = operationHookPluginIds(plugins);
35
71
  if (!args.refresh) {
36
72
  const hit = await store.read(cacheKey);
37
- if (hit !== undefined) {
73
+ if (hit !== undefined && schemaCacheMetadataMatches(hit, hookPluginIds)) {
38
74
  deps.emit(hit);
39
75
  return;
40
76
  }
@@ -47,14 +83,23 @@ export async function runSchema(args, deps) {
47
83
  entity,
48
84
  bundle,
49
85
  warn: deps.warn,
50
- plugins: deps.plugins ?? [],
86
+ plugins,
51
87
  });
52
88
  const transformed = toOperationVariant(raw, args.operation);
89
+ const operationExtended = await applyOperationSchemaPlugins(transformed, { entity, bundle, operation: args.operation }, {
90
+ http: deps.http,
91
+ auth: deps.auth,
92
+ baseUrl: deps.baseUrl,
93
+ plugins,
94
+ });
53
95
  const tagged = {
54
- ...transformed,
96
+ ...operationExtended.schema,
55
97
  "x-dropsh-source": source,
56
98
  "x-dropsh-target": { entity_type: entity, bundle },
57
99
  "x-dropsh-operation": args.operation,
100
+ "x-dropsh-schema-extensions": operationExtended.extensions,
101
+ "x-dropsh-schema-pipeline-version": SCHEMA_PIPELINE_VERSION,
102
+ "x-dropsh-operation-hook-plugins": hookPluginIds,
58
103
  };
59
104
  await store.write(cacheKey, tagged);
60
105
  deps.emit(tagged);
@@ -1,22 +1,44 @@
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 = {}) {
10
+ return {
11
+ id: "basic",
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" };
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 = {}) {
16
38
  return {
17
39
  id: "basic",
18
40
  requiredModules: [],
19
- createAuthAdapter: () => adapter,
41
+ authProvider: basicAuthProvider(config),
20
42
  async extendSchema(_entityType, _bundle, schema, _ctx) {
21
43
  return schema;
22
44
  },
@@ -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
+ }
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, } 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,6 +88,32 @@ 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
119
  rootDir: `${ctx.cwd}/.dropsh/cache`,
@@ -77,8 +121,9 @@ export function buildProgram(opts = {}) {
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.0.2",
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 && vitest run -c vitest.config.integration.ts",
60
+ "typecheck": "tsc --noEmit",
61
+ "lint": "biome check src/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
62
+ "lint:fix": "biome check --write src/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
63
+ "drupal:up": "bash tests/integrations/bin/drupal-up.sh",
64
+ "drupal:down": "bash tests/integrations/bin/drupal-down.sh",
65
+ "release": "pnpm publish --no-git-checks && pnpm -r publish --no-git-checks"
63
66
  }
64
- }
67
+ }