dropsh 0.5.3 → 0.5.6

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 (40) hide show
  1. package/README.md +23 -0
  2. package/dist/src/commands/_data.d.ts +1 -0
  3. package/dist/src/commands/auth.d.ts +30 -0
  4. package/dist/src/commands/create.d.ts +14 -0
  5. package/dist/src/commands/delete.d.ts +10 -0
  6. package/dist/src/commands/read.d.ts +10 -0
  7. package/dist/src/commands/schema.d.ts +42 -0
  8. package/dist/src/commands/search.d.ts +23 -0
  9. package/dist/src/commands/search.js +69 -12
  10. package/dist/src/commands/update.d.ts +13 -0
  11. package/dist/src/commands/upload-file.d.ts +11 -0
  12. package/dist/src/core/auth/basic.d.ts +8 -0
  13. package/dist/src/core/auth/registry.d.ts +16 -0
  14. package/dist/src/core/auth/session-store.d.ts +42 -0
  15. package/dist/src/core/auth/types.d.ts +57 -0
  16. package/dist/src/core/cache/file-store.d.ts +9 -0
  17. package/dist/src/core/cli/output.d.ts +13 -0
  18. package/dist/src/core/cli/prompt.d.ts +17 -0
  19. package/dist/src/core/cli/render.d.ts +25 -0
  20. package/dist/src/core/config.d.ts +14 -0
  21. package/dist/src/core/config.js +91 -20
  22. package/dist/src/core/http.d.ts +21 -0
  23. package/dist/src/core/jsonapi/client.d.ts +39 -0
  24. package/dist/src/core/jsonapi/collection.d.ts +31 -0
  25. package/dist/src/core/jsonapi/resource.d.ts +32 -0
  26. package/dist/src/core/jsonapi/types.d.ts +40 -0
  27. package/dist/src/core/plugin.d.ts +65 -0
  28. package/dist/src/core/plugin.js +12 -1
  29. package/dist/src/core/schema/catalog.d.ts +14 -0
  30. package/dist/src/core/schema/jsonschema-source.d.ts +23 -0
  31. package/dist/src/core/schema/sources/heuristic.d.ts +15 -0
  32. package/dist/src/core/schema/sources/heuristic.js +4 -1
  33. package/dist/src/core/schema/to-jsonschema.d.ts +2 -0
  34. package/dist/src/core/schema/validate.d.ts +1 -0
  35. package/dist/src/errors.d.ts +20 -0
  36. package/dist/src/index.d.ts +42 -0
  37. package/dist/src/index.js +34 -1
  38. package/dist/src/plugin-api.d.ts +18 -0
  39. package/dist/src/plugin-api.js +1 -0
  40. package/package.json +10 -3
package/README.md CHANGED
@@ -439,6 +439,29 @@ export default {
439
439
  };
440
440
  ```
441
441
 
442
+ `dropsh/plugin` ships its own TypeScript declarations (as do the
443
+ `@dropsh/plugin-*` packages), so `import { DropSHPlugin } from "dropsh/plugin"`
444
+ is typed with no ambient `declare module`.
445
+
446
+ ### Composing plugins
447
+
448
+ `composePlugins(...children)` bundles several plugins into one flat list, so an
449
+ aggregator plugin can present N children from a **single** `plugins[]` entry
450
+ without hand-merging their hooks. dropsh flattens a nested array — or an array
451
+ returned by a plugin factory — into separate entries; each child's own
452
+ `dependencies` still expand first, and duplicate ids are de-duplicated.
453
+
454
+ ```ts
455
+ import { composePlugins } from "dropsh/plugin";
456
+ import { markdownPlugin } from "@dropsh/plugin-markdown";
457
+ import { jsonapiSchemaPlugin } from "@dropsh/plugin-jsonapi-schema";
458
+
459
+ export function essentials() {
460
+ return composePlugins(markdownPlugin(), jsonapiSchemaPlugin());
461
+ }
462
+ // config: plugins: [essentials()] ← one entry, flattened into N children
463
+ ```
464
+
442
465
  ## Example Skills
443
466
 
444
467
  A dropsh skill does not need to know every Drupal internal. Keep `SKILL.md` as a
@@ -0,0 +1 @@
1
+ export declare function readDataArg(raw: string): Promise<unknown>;
@@ -0,0 +1,30 @@
1
+ import type { AuthContext, AuthProvider } from "../core/auth/types.js";
2
+ export interface AuthDeps {
3
+ baseUrl: string;
4
+ providers: AuthProvider[];
5
+ stdout: (s: string) => void;
6
+ stderr: (s: string) => void;
7
+ prompt: (opts: {
8
+ label: string;
9
+ secret?: boolean;
10
+ }) => Promise<string>;
11
+ openBrowser: (url: string) => Promise<void>;
12
+ http: AuthContext["http"];
13
+ now: () => number;
14
+ isTTY: boolean;
15
+ stateDir?: string;
16
+ }
17
+ export declare function runAuthLogin(args: {
18
+ provider?: string;
19
+ }, deps: AuthDeps): Promise<void>;
20
+ export declare function runAuthUse(args: {
21
+ profile: string;
22
+ }, deps: AuthDeps): Promise<void>;
23
+ export declare function runAuthLogout(args: {
24
+ profile?: string;
25
+ all?: boolean;
26
+ }, deps: AuthDeps): Promise<void>;
27
+ export declare function runAuthStatus(args: {
28
+ json?: boolean;
29
+ profile?: string;
30
+ }, deps: AuthDeps): Promise<void>;
@@ -0,0 +1,14 @@
1
+ import type { JsonApiClient } from "../core/jsonapi/client.js";
2
+ export interface CreateArgs {
3
+ entityType: string;
4
+ bundle: string;
5
+ dataArg: string;
6
+ dryRun?: boolean;
7
+ noValidate?: boolean;
8
+ }
9
+ export interface CreateDeps {
10
+ client: JsonApiClient;
11
+ emit: (v: unknown) => void | Promise<void>;
12
+ validate?: (payload: unknown, target: string) => void | Promise<void>;
13
+ }
14
+ export declare function runCreate(args: CreateArgs, deps: CreateDeps): Promise<void>;
@@ -0,0 +1,10 @@
1
+ import type { JsonApiClient } from "../core/jsonapi/client.js";
2
+ export interface DeleteArgs {
3
+ target: string;
4
+ dryRun?: boolean;
5
+ }
6
+ export interface DeleteDeps {
7
+ client: JsonApiClient;
8
+ emit: (v: unknown) => void;
9
+ }
10
+ export declare function runDelete(args: DeleteArgs, deps: DeleteDeps): Promise<void>;
@@ -0,0 +1,10 @@
1
+ import type { JsonApiClient } from "../core/jsonapi/client.js";
2
+ export interface ReadArgs {
3
+ target: string;
4
+ include?: string[];
5
+ }
6
+ export interface ReadDeps {
7
+ client: JsonApiClient;
8
+ emit: (v: unknown) => void | Promise<void>;
9
+ }
10
+ export declare function runRead(args: ReadArgs, deps: ReadDeps): Promise<void>;
@@ -0,0 +1,42 @@
1
+ import type { AuthAdapter } from "../core/auth/types.js";
2
+ import type { HttpClient } from "../core/http.js";
3
+ import type { DropSHPlugin } from "../core/plugin.js";
4
+ import { type Operation } from "../core/schema/to-jsonschema.js";
5
+ export interface SchemaArgs {
6
+ target?: string;
7
+ operation: Operation;
8
+ refresh: boolean;
9
+ }
10
+ export interface SchemaDeps {
11
+ http: HttpClient;
12
+ auth: AuthAdapter;
13
+ baseUrl: string;
14
+ jsonapiPrefix: string;
15
+ cwd: string;
16
+ emit: (v: unknown) => void;
17
+ warn: (m: string) => void;
18
+ plugins?: DropSHPlugin[];
19
+ }
20
+ export declare const SCHEMA_PIPELINE_VERSION = 2;
21
+ /**
22
+ * Per-site schema cache root. Schemas differ between sites, so the cache is
23
+ * namespaced by the site host — two configs pointing at different base_urls
24
+ * from the same working directory must not share cached schemas.
25
+ */
26
+ export declare function siteCacheRoot(cwd: string, baseUrl: string): string;
27
+ export declare function operationHookPluginIds(plugins: DropSHPlugin[]): string[];
28
+ export declare function schemaCacheMetadataMatches(schema: unknown, operationHookPlugins: string[]): boolean;
29
+ export declare function applyOperationSchemaPlugins(schema: unknown, args: {
30
+ entity: string;
31
+ bundle: string;
32
+ operation: Operation;
33
+ }, deps: {
34
+ http: HttpClient;
35
+ auth: AuthAdapter;
36
+ baseUrl: string;
37
+ plugins: DropSHPlugin[];
38
+ }): Promise<{
39
+ schema: unknown;
40
+ extensions: string[];
41
+ }>;
42
+ export declare function runSchema(args: SchemaArgs, deps: SchemaDeps): Promise<void>;
@@ -0,0 +1,23 @@
1
+ import type { JsonApiClient } from "../core/jsonapi/client.js";
2
+ export interface SearchArgs {
3
+ entityType: string;
4
+ bundle?: string;
5
+ filters: string[];
6
+ limit: number;
7
+ offset?: number;
8
+ /** JSON:API sort field; a leading `-` requests descending order. */
9
+ sort?: string;
10
+ include?: string[];
11
+ }
12
+ export interface SearchDeps {
13
+ client: JsonApiClient;
14
+ emit: (v: unknown) => void | Promise<void>;
15
+ }
16
+ export interface ParsedFilter {
17
+ key: string;
18
+ /** Filter value, or `null` for value-less operators (IS NULL / IS NOT NULL). */
19
+ value: string | null;
20
+ operator?: string;
21
+ }
22
+ export declare function parseFilterFlag(raw: string): ParsedFilter;
23
+ export declare function runSearch(args: SearchArgs, deps: SearchDeps): Promise<void>;
@@ -1,29 +1,86 @@
1
1
  import { DrupalJsonApiParams } from "drupal-jsonapi-params";
2
2
  import { ValidationError } from "../errors.js";
3
+ /**
4
+ * JSON:API filter operators recognised in the `key:op:value` form. Only when
5
+ * segment 2 of a `--filter` matches one of these is it treated as an operator;
6
+ * otherwise the remainder is kept verbatim as the value (so a value containing
7
+ * a colon — e.g. a URL — parses as key:value, not key:op:value).
8
+ *
9
+ * `!=` is accepted as an alias of `<>`; it is normalised to `<>` before the
10
+ * request is built (Drupal's allowed-operators list has `<>`, not `!=`).
11
+ */
12
+ const KNOWN_OPERATORS = new Set([
13
+ "=",
14
+ "<>",
15
+ "!=",
16
+ ">",
17
+ ">=",
18
+ "<",
19
+ "<=",
20
+ "CONTAINS",
21
+ "STARTS_WITH",
22
+ "ENDS_WITH",
23
+ "IN",
24
+ "NOT IN",
25
+ "BETWEEN",
26
+ "NOT BETWEEN",
27
+ "IS NULL",
28
+ "IS NOT NULL",
29
+ ]);
30
+ /** Operators that take no value; expressed as `key:IS NULL` / `key:IS NOT NULL`. */
31
+ const VALUELESS_OPERATORS = new Set(["IS NULL", "IS NOT NULL"]);
32
+ /**
33
+ * Operators whose value is a list; the `--filter` value is split on commas into
34
+ * an array (e.g. `key:IN:a,b,c`). Drupal rejects a scalar for these.
35
+ */
36
+ const MULTI_VALUE_OPERATORS = new Set(["IN", "NOT IN", "BETWEEN", "NOT BETWEEN"]);
3
37
  export function parseFilterFlag(raw) {
4
38
  const parts = raw.split(":");
5
- if (parts.length === 2) {
6
- const [key, value] = parts;
7
- return { key, value };
39
+ if (parts.length < 2) {
40
+ throw new ValidationError(`--filter must be key:value or key:op:value, got "${raw}"`);
8
41
  }
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 };
42
+ const key = parts[0];
43
+ const rest = parts.slice(1).join(":");
44
+ // key:IS NULL / key:IS NOT NULL — value-less operator.
45
+ if (VALUELESS_OPERATORS.has(rest)) {
46
+ return { key, operator: rest, value: null };
14
47
  }
15
- throw new ValidationError(`--filter must be key:value or key:op:value, got "${raw}"`);
48
+ // key:op:value — only when segment 2 is a known operator.
49
+ if (parts.length >= 3 && KNOWN_OPERATORS.has(parts[1])) {
50
+ return { key, operator: parts[1], value: parts.slice(2).join(":") };
51
+ }
52
+ // key:value — value kept verbatim (may contain colons, e.g. a URL).
53
+ return { key, value: rest };
16
54
  }
17
55
  export async function runSearch(args, deps) {
18
56
  const params = new DrupalJsonApiParams();
19
57
  for (const raw of args.filters) {
20
58
  const f = parseFilterFlag(raw);
21
- if (f.operator)
22
- params.addFilter(f.key, f.value, f.operator);
23
- else
59
+ if (f.operator === undefined) {
24
60
  params.addFilter(f.key, f.value);
61
+ continue;
62
+ }
63
+ // `!=` is an alias of `<>`; Drupal's allowed operators do not include `!=`.
64
+ const operator = f.operator === "!=" ? "<>" : f.operator;
65
+ // List operators (IN/NOT IN/BETWEEN/NOT BETWEEN) take an array value.
66
+ const value = f.value !== null && MULTI_VALUE_OPERATORS.has(operator) ? f.value.split(",") : f.value;
67
+ params.addFilter(f.key, value, operator);
25
68
  }
26
69
  params.addPageLimit(args.limit);
70
+ if (args.offset !== undefined) {
71
+ if (!Number.isInteger(args.offset) || args.offset < 0) {
72
+ throw new ValidationError(`--offset must be a non-negative integer, got "${args.offset}"`);
73
+ }
74
+ params.addPageOffset(args.offset);
75
+ }
76
+ if (args.sort) {
77
+ const descending = args.sort.startsWith("-");
78
+ const field = descending ? args.sort.slice(1) : args.sort;
79
+ if (field === "") {
80
+ throw new ValidationError(`--sort requires a field name, got "${args.sort}"`);
81
+ }
82
+ params.addSort(field, descending ? "DESC" : "ASC");
83
+ }
27
84
  if (args.include?.length)
28
85
  params.addInclude(args.include);
29
86
  const path = args.bundle ? `${args.entityType}/${args.bundle}` : args.entityType;
@@ -0,0 +1,13 @@
1
+ import type { JsonApiClient } from "../core/jsonapi/client.js";
2
+ export interface UpdateArgs {
3
+ target: string;
4
+ dataArg: string;
5
+ dryRun?: boolean;
6
+ noValidate?: boolean;
7
+ }
8
+ export interface UpdateDeps {
9
+ client: JsonApiClient;
10
+ emit: (v: unknown) => void | Promise<void>;
11
+ validate?: (payload: unknown, target: string) => void | Promise<void>;
12
+ }
13
+ export declare function runUpdate(args: UpdateArgs, deps: UpdateDeps): Promise<void>;
@@ -0,0 +1,11 @@
1
+ import type { JsonApiClient } from "../core/jsonapi/client.js";
2
+ export interface UploadArgs {
3
+ target: string;
4
+ file: string;
5
+ dryRun?: boolean;
6
+ }
7
+ export interface UploadDeps {
8
+ client: JsonApiClient;
9
+ emit: (v: unknown) => void;
10
+ }
11
+ export declare function runUploadFile(args: UploadArgs, deps: UploadDeps): Promise<void>;
@@ -0,0 +1,8 @@
1
+ import type { DropSHPlugin } from "../plugin.js";
2
+ import type { AuthProvider } from "./types.js";
3
+ export interface BasicAuthConfig {
4
+ /** Optional pre-seeded username; password is always prompted at login. */
5
+ username?: string;
6
+ }
7
+ export declare function basicAuthProvider(config?: BasicAuthConfig): AuthProvider;
8
+ export declare function basicAuthPlugin(config?: BasicAuthConfig): DropSHPlugin;
@@ -0,0 +1,16 @@
1
+ import type { DropSHPlugin } from "../plugin.js";
2
+ import type { AuthProvider } from "./types.js";
3
+ export declare function collectProviders(plugins: DropSHPlugin[]): AuthProvider[];
4
+ /**
5
+ * The provider declaring `default: true`, used as the fallback profile when none
6
+ * is active or explicitly selected. Throws if more than one claims the default.
7
+ */
8
+ export declare function defaultProvider(providers: AuthProvider[]): AuthProvider | undefined;
9
+ export declare function loginCapableProviders(providers: AuthProvider[]): AuthProvider[];
10
+ export declare function providerById(providers: AuthProvider[], id: string): AuthProvider | undefined;
11
+ /**
12
+ * A session-less provider (e.g. static Basic) is the sole configured provider
13
+ * that declares no login capability: it carries credentials inline and needs no
14
+ * stored session. Used by both resolveAuth and authStatus.
15
+ */
16
+ export declare function sessionlessProvider(providers: AuthProvider[]): AuthProvider | undefined;
@@ -0,0 +1,42 @@
1
+ import type { AuthSession } from "./types.js";
2
+ /** One stored auth identity: the provider that owns it plus its opaque session. */
3
+ export interface ProfileRecord {
4
+ provider: string;
5
+ session: AuthSession;
6
+ }
7
+ /** On-disk shape (v2): a host holds many named profiles + an active pointer. */
8
+ export interface ProfilesFile {
9
+ version: 2;
10
+ /** Persistent default profile for this host; undefined until `auth use`/`login`. */
11
+ active?: string;
12
+ /** Keyed by profile name (== provider id in the current model). */
13
+ profiles: Record<string, ProfileRecord>;
14
+ }
15
+ /** Legacy (v1) shape kept for reading old files and for the compat wrappers below. */
16
+ export interface SessionRecord {
17
+ activeProvider: string;
18
+ session: AuthSession;
19
+ }
20
+ export declare function defaultStateDir(): string;
21
+ /**
22
+ * Read the whole profiles file, migrating a legacy `{ activeProvider, session }`
23
+ * record in memory to the v2 shape. No write-time migration is needed: the next
24
+ * `writeProfile` persists v2 naturally.
25
+ */
26
+ export declare function readProfiles(baseUrl: string, dir?: string): Promise<ProfilesFile | null>;
27
+ export declare function readProfile(baseUrl: string, name: string, dir?: string): Promise<ProfileRecord | null>;
28
+ /**
29
+ * Upsert a single profile slot, merging into the existing file so other slots and
30
+ * the `active` pointer are preserved. Used both by `auth login` and by adapter
31
+ * token renewals (which must never disturb sibling profiles).
32
+ */
33
+ export declare function writeProfile(baseUrl: string, name: string, provider: string, session: AuthSession, dir?: string): Promise<void>;
34
+ /** Set the persistent active pointer. Errors if the slot has no stored session. */
35
+ export declare function setActive(baseUrl: string, name: string, dir?: string): Promise<void>;
36
+ /** Remove a single profile; unset `active` if it pointed there; delete the file if empty. */
37
+ export declare function clearProfile(baseUrl: string, name: string, dir?: string): Promise<void>;
38
+ /** Remove every profile for this host (deletes the file). */
39
+ export declare function clearAll(baseUrl: string, dir?: string): Promise<void>;
40
+ export declare function readSession(baseUrl: string, dir?: string): Promise<SessionRecord | null>;
41
+ export declare function writeSession(baseUrl: string, activeProvider: string, session: AuthSession, dir?: string): Promise<void>;
42
+ export declare function clearSession(baseUrl: string, dir?: string): Promise<void>;
@@ -0,0 +1,57 @@
1
+ import type { HttpClient, HttpRequest } from "../http.js";
2
+ export interface AuthAdapter {
3
+ apply(req: HttpRequest): Promise<HttpRequest>;
4
+ /**
5
+ * Force a credential refresh after the server rejected the current token (401),
6
+ * then persist it. Returns true if renewal succeeded (caller retries the request
7
+ * once), false if it could not renew (caller surfaces the original 401).
8
+ * Providers that cannot renew (e.g. basic auth) omit this.
9
+ */
10
+ renew?(): Promise<boolean>;
11
+ }
12
+ /** Provider-specific opaque session payload persisted to the state dir. */
13
+ export type AuthSession = Record<string, unknown>;
14
+ export interface AuthStatusInfo {
15
+ loggedIn: boolean;
16
+ provider?: string;
17
+ host?: string;
18
+ expiresAt?: number;
19
+ state?: "valid" | "expired";
20
+ sessionless?: boolean;
21
+ }
22
+ /** I/O the core supplies to a provider during login/logout so providers stay testable. */
23
+ export interface AuthContext {
24
+ baseUrl: string;
25
+ http: HttpClient;
26
+ /** State dir override (tests). Undefined → provider uses the default (~/.config/dropsh). */
27
+ stateDir?: string;
28
+ prompt(opts: {
29
+ label: string;
30
+ secret?: boolean;
31
+ }): Promise<string>;
32
+ openBrowser(url: string): Promise<void>;
33
+ stdout(s: string): void;
34
+ now(): number;
35
+ }
36
+ /** Runtime helpers handed to createAdapter so a provider can refresh + persist its session. */
37
+ export interface AdapterRuntime {
38
+ http: HttpClient;
39
+ now(): number;
40
+ /** Persist a refreshed session back to the active session slot. */
41
+ save(session: AuthSession): Promise<void>;
42
+ }
43
+ export interface AuthProvider {
44
+ readonly id: string;
45
+ readonly displayName: string;
46
+ /** When true, this provider is the fallback profile if none is active/selected. */
47
+ readonly default?: boolean;
48
+ readonly capabilities: {
49
+ login: boolean;
50
+ logout: boolean;
51
+ status: boolean;
52
+ };
53
+ login(ctx: AuthContext): Promise<AuthSession>;
54
+ logout(ctx: AuthContext): Promise<void>;
55
+ status(session: AuthSession | null): Promise<AuthStatusInfo>;
56
+ createAdapter(session: AuthSession | undefined, rt: AdapterRuntime): AuthAdapter;
57
+ }
@@ -0,0 +1,9 @@
1
+ export interface FileStore {
2
+ read<T>(relPath: string): Promise<T | undefined>;
3
+ write(relPath: string, value: unknown): Promise<void>;
4
+ }
5
+ export interface FileStoreOptions {
6
+ rootDir: string;
7
+ warn: (message: string) => void;
8
+ }
9
+ export declare function createFileStore(opts: FileStoreOptions): FileStore;
@@ -0,0 +1,13 @@
1
+ import { type AnyRenderer, type RenderContext, type RenderServices } from "./render.js";
2
+ export interface Output {
3
+ emit(value: unknown, ctx?: RenderContext, services?: RenderServices): void | Promise<void>;
4
+ fail(err: unknown): void;
5
+ hasFormat(id: string): boolean;
6
+ }
7
+ export interface OutputOptions {
8
+ stdout: (s: string) => void;
9
+ stderr: (s: string) => void;
10
+ renderers?: AnyRenderer[];
11
+ getFormat?: () => string;
12
+ }
13
+ export declare function createOutput(opts: OutputOptions): Output;
@@ -0,0 +1,17 @@
1
+ import type { Readable } from "node:stream";
2
+ export interface PromptDeps {
3
+ input?: Readable;
4
+ write?: (s: string) => void;
5
+ /** TTY check override (tests). Defaults to input.isTTY. */
6
+ isTTY?: boolean;
7
+ }
8
+ export type PromptFn = (opts: {
9
+ label: string;
10
+ secret?: boolean;
11
+ }) => Promise<string>;
12
+ /**
13
+ * Build a prompt function. Secret prompts use raw mode + manual key
14
+ * accumulation on a real TTY so the input is never echoed. On a non-TTY
15
+ * stream (tests/pipes) input is read as a line so the value is still captured.
16
+ */
17
+ export declare function createPrompt(deps?: PromptDeps): PromptFn;
@@ -0,0 +1,25 @@
1
+ import type { JsonApiClient } from "../jsonapi/client.js";
2
+ import type { JsonApiDocument, JsonApiResource } from "../jsonapi/types.js";
3
+ export declare function indexIncluded(doc: JsonApiDocument): Map<string, JsonApiResource>;
4
+ export interface RenderContext {
5
+ command: "read" | "search" | "create" | "update";
6
+ target?: string;
7
+ entityType?: string;
8
+ bundle?: string;
9
+ viewMode?: string;
10
+ }
11
+ export interface RenderServices {
12
+ client: JsonApiClient;
13
+ baseUrl: string;
14
+ }
15
+ export interface Renderer {
16
+ readonly id: string;
17
+ render(doc: JsonApiDocument, ctx: RenderContext): string;
18
+ }
19
+ export interface InteractiveRenderer {
20
+ readonly id: string;
21
+ readonly interactive: true;
22
+ run(doc: JsonApiDocument, ctx: RenderContext, services?: RenderServices): Promise<void>;
23
+ }
24
+ export type AnyRenderer = Renderer | InteractiveRenderer;
25
+ export declare function isInteractive(r: AnyRenderer): r is InteractiveRenderer;
@@ -0,0 +1,14 @@
1
+ import type { DropSHPlugin } from "./plugin.js";
2
+ export interface SiteConfig {
3
+ base_url: string;
4
+ jsonapi_prefix: string;
5
+ }
6
+ export interface Config {
7
+ site: SiteConfig;
8
+ defaults: {
9
+ dry_run: boolean;
10
+ timeout_ms: number;
11
+ };
12
+ plugins: DropSHPlugin[];
13
+ }
14
+ export declare function loadConfig(filePath: string): Promise<Config>;
@@ -16,19 +16,18 @@ function isPluginDescriptor(e) {
16
16
  return isRecord(e) && typeof e.plugin === "string";
17
17
  }
18
18
  /**
19
- * Resolve a named plugin package the way ESLint resolves plugins: by string
20
- * name, relative to the config file first, then the cwd, then dropsh's own
21
- * install (so a plugin installed alongside dropsh — a dropsh dep, or globally
22
- * next to a global dropsh, or via `npx -p`— is found). The config never imports
23
- * the package itself.
19
+ * Resolve a single named-plugin descriptor to its module path + export the way
20
+ * ESLint resolves plugins: by string name against a list of base modules, first
21
+ * match wins (so a plugin installed alongside dropsh — a dropsh dep, or globally
22
+ * next to a global dropsh, or via `npx -p` — is found). The config never imports
23
+ * the package itself. This does **not** call the plugin factory: the resolved
24
+ * path + export + options form the identity the graph expander de-duplicates on,
25
+ * so construction is deferred to a cache miss (a shared/diamond dependency's
26
+ * factory must run exactly once — AC-3). Returns a `construct` thunk the caller
27
+ * invokes only when the resolved identity is new.
24
28
  */
25
- async function loadNamedPlugin(entry, configPath) {
29
+ async function resolveDescriptor(entry, bases, declaredBy) {
26
30
  const name = entry.plugin;
27
- const bases = [
28
- pathToFileURL(configPath).href,
29
- pathToFileURL(`${process.cwd()}/`).href,
30
- import.meta.url,
31
- ];
32
31
  let resolved;
33
32
  for (const base of bases) {
34
33
  try {
@@ -39,8 +38,10 @@ async function loadNamedPlugin(entry, configPath) {
39
38
  // try the next base
40
39
  }
41
40
  }
42
- if (resolved === undefined)
43
- throw new ConfigError(`cannot resolve plugin '${name}'. Install it next to dropsh (project dep, dropsh dep, global next to a global dropsh, or 'npx -p dropsh -p ${name}').`);
41
+ if (resolved === undefined) {
42
+ const from = declaredBy ? ` (declared by '${declaredBy}')` : "";
43
+ throw new ConfigError(`cannot resolve plugin '${name}'${from}. Install it next to dropsh (project dep, dropsh dep, global next to a global dropsh, or 'npx -p dropsh -p ${name}').`);
44
+ }
44
45
  let mod;
45
46
  try {
46
47
  mod = (await import(pathToFileURL(resolved).href));
@@ -52,19 +53,89 @@ async function loadNamedPlugin(entry, configPath) {
52
53
  const factory = mod[exportName];
53
54
  if (typeof factory !== "function")
54
55
  throw new ConfigError(`plugin '${name}' has no callable export '${exportName}'`);
55
- return factory(entry.with ?? entry.options);
56
+ const construct = () => factory(entry.with ?? entry.options);
57
+ return { resolvedPath: resolved, exportName, construct };
56
58
  }
57
59
  /**
58
- * Expands `plugins[]` entries: named-package descriptors are resolved + built
59
- * into real plugins; already-constructed plugins pass through unchanged
60
- * (backward compat).
60
+ * Expands `plugins[]` into a flat plugin list: named-package descriptors are
61
+ * resolved + built, and each built plugin's `dependencies` are expanded
62
+ * *before* it (post-order DFS). A descriptor's identity is its resolved module
63
+ * path + export + options (`with`/`options`) — that single key drives both
64
+ * de-duplication and on-path cycle detection, and construction happens only on a
65
+ * cache miss, so a shared dependency's factory runs exactly once (AC-3). Because
66
+ * the key is the *resolved* module, one relative string naming different files
67
+ * on a path is not a cycle, and two same-package entries with different options
68
+ * are two distinct plugins. `id` collisions are still de-duplicated in the final
69
+ * list. A dependency cycle is rejected with the offending chain of descriptor
70
+ * names. Pre-constructed plugin objects pass through unchanged (backward compat).
61
71
  */
62
72
  async function resolvePlugins(raw, configPath) {
63
73
  if (!Array.isArray(raw))
64
74
  return [];
65
- return Promise.all(raw.map((entry) => isPluginDescriptor(entry)
66
- ? loadNamedPlugin(entry, configPath)
67
- : Promise.resolve(entry)));
75
+ const baseBases = [
76
+ pathToFileURL(configPath).href,
77
+ pathToFileURL(`${process.cwd()}/`).href,
78
+ import.meta.url,
79
+ ];
80
+ const out = [];
81
+ const doneKeys = new Set(); // resolvedPath::export::options — fully expanded
82
+ const doneIds = new Set(); // plugin.id already emitted
83
+ // Descriptors on the current DFS path, keyed by resolved identity; `name` is
84
+ // the descriptor string, kept only to render the cycle-error chain.
85
+ const stack = [];
86
+ const emit = (plugin) => {
87
+ if (doneIds.has(plugin.id))
88
+ return;
89
+ doneIds.add(plugin.id);
90
+ out.push(plugin);
91
+ };
92
+ const expandDeps = async (plugin, bases) => {
93
+ for (const dep of plugin.dependencies ?? [])
94
+ await expand(dep, bases, plugin.id);
95
+ };
96
+ // Expand a pre-constructed plugin (or a nested array of them): a composite —
97
+ // e.g. `composePlugins(a(), b())` — reaches here as an array, and is flattened
98
+ // into separate entries so N children register without hand-merging hooks.
99
+ const expandValue = async (value, bases) => {
100
+ if (Array.isArray(value)) {
101
+ for (const sub of value)
102
+ await expand(sub, bases, undefined);
103
+ return;
104
+ }
105
+ const plugin = value;
106
+ await expandDeps(plugin, bases);
107
+ emit(plugin);
108
+ };
109
+ async function expand(entry, bases, declaredBy) {
110
+ if (!isPluginDescriptor(entry)) {
111
+ // Pre-constructed plugin object, or a nested-array composite entry.
112
+ await expandValue(entry, bases);
113
+ return;
114
+ }
115
+ const { resolvedPath, exportName, construct } = await resolveDescriptor(entry, bases, declaredBy);
116
+ const key = `${resolvedPath}::${exportName}::${JSON.stringify(entry.with ?? entry.options ?? null)}`;
117
+ if (doneKeys.has(key))
118
+ return; // same resolved identity already expanded + emitted — do not re-construct
119
+ if (stack.some((f) => f.key === key))
120
+ throw new ConfigError(`plugin dependency cycle: ${[...stack.map((f) => f.name), entry.plugin].join(" → ")}`);
121
+ // A factory may return one plugin or an array of them (composePlugins); a
122
+ // returned array is flattened into separate entries under this descriptor's
123
+ // identity (one construction, so a shared descriptor still runs once).
124
+ const produced = construct();
125
+ stack.push({ key, name: entry.plugin });
126
+ try {
127
+ // Child descriptors resolve relative to this plugin's module first.
128
+ const childBases = [pathToFileURL(resolvedPath).href, ...baseBases];
129
+ await expandValue(produced, childBases);
130
+ }
131
+ finally {
132
+ stack.pop();
133
+ }
134
+ doneKeys.add(key);
135
+ }
136
+ for (const entry of raw)
137
+ await expand(entry, baseBases, undefined);
138
+ return out;
68
139
  }
69
140
  export async function loadConfig(filePath) {
70
141
  const configPath = resolve(filePath);
@@ -0,0 +1,21 @@
1
+ export interface HttpRequest {
2
+ method: "GET" | "POST" | "PATCH" | "DELETE";
3
+ url: string;
4
+ headers?: Record<string, string>;
5
+ body?: string | Uint8Array;
6
+ }
7
+ export interface HttpResponse {
8
+ status: number;
9
+ headers: Record<string, string>;
10
+ body: string;
11
+ }
12
+ export interface HttpClient {
13
+ send(req: HttpRequest): Promise<HttpResponse>;
14
+ }
15
+ export interface HttpOptions {
16
+ fetch?: typeof fetch;
17
+ timeoutMs?: number;
18
+ maxRetries?: number;
19
+ retryDelayMs?: number;
20
+ }
21
+ export declare function createHttpClient(opts?: HttpOptions): HttpClient;
@@ -0,0 +1,39 @@
1
+ import type { DrupalJsonApiParams } from "drupal-jsonapi-params";
2
+ import type { AuthAdapter } from "../auth/types.js";
3
+ import type { HttpClient } from "../http.js";
4
+ import { type Collection } from "./collection.js";
5
+ import { type Resource } from "./resource.js";
6
+ /** Attributes/relationships payload accepted by create/update/upsert. */
7
+ export interface ResourceWriteBody {
8
+ attributes?: Record<string, unknown>;
9
+ relationships?: Record<string, unknown>;
10
+ }
11
+ export interface JsonApiClient {
12
+ get(path: string, params?: DrupalJsonApiParams): Promise<unknown>;
13
+ post(path: string, body: unknown): Promise<unknown>;
14
+ patch(path: string, body: unknown): Promise<unknown>;
15
+ delete(path: string): Promise<unknown>;
16
+ upload(path: string, filename: string, data: Uint8Array | Buffer): Promise<unknown>;
17
+ /** Start a fluent query for the given resource type. */
18
+ collection(type: string): Collection;
19
+ /** Fetch a single resource by id. */
20
+ resource(type: string, id: string): Promise<Resource>;
21
+ /** Create a resource (POST `{ data: { type, ...body } }`). */
22
+ create(type: string, body: ResourceWriteBody): Promise<Resource>;
23
+ /** Update a resource (PATCH `{ data: { type, id, ...body } }`). */
24
+ update(type: string, id: string, body: ResourceWriteBody): Promise<Resource>;
25
+ /** Update the resource matching `match`, or create one if none exists. */
26
+ upsert(type: string, match: {
27
+ path: string;
28
+ value: string;
29
+ }, body: ResourceWriteBody): Promise<Resource>;
30
+ /** Resolve the current authenticated user's uuid from the root document. */
31
+ me(): Promise<string>;
32
+ }
33
+ export interface JsonApiOptions {
34
+ baseUrl: string;
35
+ prefix: string;
36
+ http: HttpClient;
37
+ auth: AuthAdapter;
38
+ }
39
+ export declare function createJsonApiClient(opts: JsonApiOptions): JsonApiClient;
@@ -0,0 +1,31 @@
1
+ import { DrupalJsonApiParams } from "drupal-jsonapi-params";
2
+ import { type Resource } from "./resource.js";
3
+ export type FilterOp = "=" | "<>" | ">" | ">=" | "<" | "<=" | "STARTS_WITH" | "CONTAINS";
4
+ export interface Collection {
5
+ where(path: string, op: FilterOp, value: string | number): Collection;
6
+ /** Match any of the given values (JSON:API `IN` operator). */
7
+ whereIn(path: string, values: Array<string | number>): Collection;
8
+ /** Require the field to be empty (JSON:API `IS NULL` operator). */
9
+ notExists(path: string): Collection;
10
+ /** Restrict the sparse fieldset for this collection's resource type. */
11
+ fields(names: string[]): Collection;
12
+ sort(path: string, dir?: "ASC" | "DESC"): Collection;
13
+ page(limit: number): Collection;
14
+ list(): Promise<Resource[]>;
15
+ /** First match, or `null`. Applies `page(1)` to keep the request minimal. */
16
+ first(): Promise<Resource | null>;
17
+ /**
18
+ * Number of matching resources.
19
+ *
20
+ * CAVEAT: this counts the rows returned by a single `list()` request and is
21
+ * therefore bounded by the active page limit (default JSON:API page size is
22
+ * 50). It does not read JSON:API `meta.count` and will under-report when the
23
+ * result set exceeds the page limit. Use it only for small/known-bounded sets.
24
+ */
25
+ count(): Promise<number>;
26
+ }
27
+ /** Minimal client surface a {@link Collection} needs — keeps it decoupled. */
28
+ export interface CollectionClient {
29
+ get(path: string, params?: DrupalJsonApiParams): Promise<unknown>;
30
+ }
31
+ export declare function createCollection(client: CollectionClient, spec: string, params?: DrupalJsonApiParams): Collection;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Ergonomic accessors over a raw JSON:API resource object.
3
+ *
4
+ * A {@link Resource} wraps the `data` member of a JSON:API document and exposes
5
+ * typed helpers for attributes and relationships so callers stop digging through
6
+ * nested `attributes`/`relationships` structures by hand.
7
+ */
8
+ export interface JsonApiResourceIdentifier {
9
+ id: string;
10
+ type: string;
11
+ }
12
+ export interface JsonApiResourceObject {
13
+ id: string;
14
+ type: string;
15
+ attributes?: Record<string, unknown>;
16
+ relationships?: Record<string, {
17
+ data: JsonApiResourceIdentifier | JsonApiResourceIdentifier[] | null;
18
+ }>;
19
+ }
20
+ export interface Resource {
21
+ readonly id: string;
22
+ readonly type: string;
23
+ /** Read a single attribute, typed by the caller. Returns `undefined` if absent. */
24
+ attr<T = unknown>(key: string): T | undefined;
25
+ /** Related id for a to-one relationship, or `null` if missing/empty. */
26
+ rel(key: string): string | null;
27
+ /** Related ids for a to-many relationship. Returns `[]` if missing/empty. */
28
+ rels(key: string): string[];
29
+ /** The underlying raw JSON:API resource object. */
30
+ readonly raw: JsonApiResourceObject;
31
+ }
32
+ export declare function toResource(obj: JsonApiResourceObject): Resource;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * JSON:API resource type/path normalization.
3
+ *
4
+ * Drupal JSON:API addresses resources by a `entity/bundle` path (e.g.
5
+ * `node/article`) but identifies them in document envelopes by a `entity--bundle`
6
+ * type (e.g. `node--article`). This helper accepts either form plus a convenient
7
+ * single-segment shorthand and produces both representations.
8
+ */
9
+ export interface ResolvedType {
10
+ /** URL path segment, e.g. `node/article`. */
11
+ path: string;
12
+ /** JSON:API resource type, e.g. `node--article`. */
13
+ type: string;
14
+ }
15
+ /**
16
+ * Resolve a loose type specifier into its `{ path, type }` pair.
17
+ *
18
+ * Accepted forms:
19
+ * - `'gaia_run'` -> `{ path: 'gaia_run/gaia_run', type: 'gaia_run--gaia_run' }`
20
+ * - `'entity/bundle'` -> `{ path: 'entity/bundle', type: 'entity--bundle' }`
21
+ * - `'entity--bundle'` -> `{ path: 'entity/bundle', type: 'entity--bundle' }`
22
+ */
23
+ export declare function resolveType(spec: string): ResolvedType;
24
+ /**
25
+ * JSON:API document envelope shapes used by the render layer. The ergonomic
26
+ * client returns `unknown`; renderers narrow to these via a runtime guard.
27
+ */
28
+ export interface JsonApiResource {
29
+ type: string;
30
+ id: string;
31
+ attributes?: Record<string, unknown>;
32
+ relationships?: Record<string, unknown>;
33
+ links?: Record<string, unknown>;
34
+ }
35
+ export interface JsonApiDocument {
36
+ data: JsonApiResource | JsonApiResource[];
37
+ included?: JsonApiResource[];
38
+ meta?: Record<string, unknown>;
39
+ links?: Record<string, unknown>;
40
+ }
@@ -0,0 +1,65 @@
1
+ import type { Command } from "commander";
2
+ import type { AuthAdapter, AuthProvider } from "./auth/types.js";
3
+ import type { AnyRenderer } from "./cli/render.js";
4
+ import type { HttpClient } from "./http.js";
5
+ import type { Operation } from "./schema/to-jsonschema.js";
6
+ export interface PluginContext {
7
+ http: HttpClient;
8
+ auth: AuthAdapter;
9
+ baseUrl: string;
10
+ /**
11
+ * JSON:API path prefix (e.g. "/jsonapi"). Present when the schema pipeline
12
+ * builds the context; plugins that target routes under the JSON:API prefix
13
+ * should fall back to "/jsonapi" when it is absent.
14
+ */
15
+ jsonapiPrefix?: string;
16
+ /**
17
+ * Emit a user-visible warning (goes to stderr). Plugins use this to surface
18
+ * a broken/unreachable authoritative-schema endpoint instead of silently
19
+ * falling back to the heuristic schema.
20
+ */
21
+ warn?: (message: string) => void;
22
+ }
23
+ export type SchemaOperation = Operation;
24
+ /**
25
+ * Import-free descriptor naming a plugin package as a string. Same shape used
26
+ * by the top-level `plugins[]` config; a plugin returns these in `dependencies`
27
+ * to have dropsh auto-load further plugins.
28
+ */
29
+ export interface PluginDescriptor {
30
+ plugin: string;
31
+ with?: unknown;
32
+ options?: unknown;
33
+ export?: string;
34
+ }
35
+ export interface DropSHPlugin {
36
+ readonly id: string;
37
+ readonly requiredModules: string[];
38
+ /**
39
+ * Plugins this plugin depends on. dropsh resolves each descriptor and inserts
40
+ * the resulting plugin into the flat plugin list *before* this plugin, so a
41
+ * dependency's renderers / schema hooks are available when this plugin runs.
42
+ * De-duplicated across the whole graph; cycles are rejected at config load.
43
+ */
44
+ dependencies?: PluginDescriptor[];
45
+ /** Provider-based auth (login/logout/status/createAdapter). */
46
+ authProvider?: AuthProvider;
47
+ /**
48
+ * Optional schema extension. Plugins that contribute other capabilities
49
+ * (auth, commands, or consumer-defined slots) can omit it.
50
+ */
51
+ extendSchema?(entityType: string, bundle: string, baseSchema: unknown, ctx: PluginContext): Promise<unknown>;
52
+ extendOperationSchema?(entityType: string, bundle: string, operation: SchemaOperation, operationSchema: unknown, ctx: PluginContext): Promise<unknown>;
53
+ registerCommands?(program: Command): void;
54
+ renderers?: AnyRenderer[];
55
+ }
56
+ /**
57
+ * Combine several child plugins into one flat plugin list. An aggregator's
58
+ * factory returns `composePlugins(childA(), childB(), …)` and places the result
59
+ * as a single `plugins[]` entry (a nested array) or a single named-descriptor
60
+ * export; dropsh flattens it into separate plugin entries. This lets one config
61
+ * entry pull in N plugins without hand-merging their hooks or presence-guarding
62
+ * hook presence — each child stays a distinct entry, indistinguishable from N
63
+ * separate entries. Nested arrays (composed composites) are flattened one level.
64
+ */
65
+ export declare function composePlugins(...children: (DropSHPlugin | DropSHPlugin[])[]): DropSHPlugin[];
@@ -1 +1,12 @@
1
- export {};
1
+ /**
2
+ * Combine several child plugins into one flat plugin list. An aggregator's
3
+ * factory returns `composePlugins(childA(), childB(), …)` and places the result
4
+ * as a single `plugins[]` entry (a nested array) or a single named-descriptor
5
+ * export; dropsh flattens it into separate plugin entries. This lets one config
6
+ * entry pull in N plugins without hand-merging their hooks or presence-guarding
7
+ * hook presence — each child stays a distinct entry, indistinguishable from N
8
+ * separate entries. Nested arrays (composed composites) are flattened one level.
9
+ */
10
+ export function composePlugins(...children) {
11
+ return children.flat();
12
+ }
@@ -0,0 +1,14 @@
1
+ import type { AuthAdapter } from "../auth/types.js";
2
+ import type { HttpClient } from "../http.js";
3
+ export interface CatalogEntry {
4
+ entity_type: string;
5
+ bundle: string;
6
+ label: string;
7
+ }
8
+ export interface CatalogDeps {
9
+ http: HttpClient;
10
+ auth: AuthAdapter;
11
+ baseUrl: string;
12
+ jsonapiPrefix: string;
13
+ }
14
+ export declare function fetchCatalog(deps: CatalogDeps): Promise<CatalogEntry[]>;
@@ -0,0 +1,23 @@
1
+ import type { AuthAdapter } from "../auth/types.js";
2
+ import type { HttpClient } from "../http.js";
3
+ import type { DropSHPlugin } from "../plugin.js";
4
+ export type SchemaSource = "heuristic" | "heuristic-empty" | (string & {});
5
+ export interface JsonSchemaResult {
6
+ schema: unknown;
7
+ source: SchemaSource;
8
+ target: {
9
+ entity_type: string;
10
+ bundle: string;
11
+ };
12
+ }
13
+ export interface JsonSchemaDeps {
14
+ http: HttpClient;
15
+ auth: AuthAdapter;
16
+ baseUrl: string;
17
+ jsonapiPrefix: string;
18
+ entity: string;
19
+ bundle: string;
20
+ warn: (message: string) => void;
21
+ plugins?: DropSHPlugin[];
22
+ }
23
+ export declare function fetchJsonSchema(deps: JsonSchemaDeps): Promise<JsonSchemaResult>;
@@ -0,0 +1,15 @@
1
+ import type { AuthAdapter } from "../../auth/types.js";
2
+ import type { HttpClient } from "../../http.js";
3
+ export interface HeuristicDeps {
4
+ http: HttpClient;
5
+ auth: AuthAdapter;
6
+ baseUrl: string;
7
+ jsonapiPrefix: string;
8
+ entity: string;
9
+ bundle: string;
10
+ }
11
+ export interface HeuristicResult {
12
+ schema: unknown;
13
+ empty: boolean;
14
+ }
15
+ export declare function fetchHeuristic(deps: HeuristicDeps): Promise<HeuristicResult>;
@@ -19,8 +19,11 @@ function mergeTypes(types) {
19
19
  const arr = [...types].filter((t) => t !== undefined);
20
20
  if (arr.length === 0)
21
21
  return {};
22
+ // A field that is `null` in every sample carries no type information — leave
23
+ // it permissive rather than locking it to `type: null` (which would reject any
24
+ // real value written later).
22
25
  if (arr.length === 1)
23
- return { type: arr[0] };
26
+ return arr[0] === "null" ? {} : { type: arr[0] };
24
27
  return { type: arr };
25
28
  }
26
29
  export async function fetchHeuristic(deps) {
@@ -0,0 +1,2 @@
1
+ export type Operation = "create" | "update";
2
+ export declare function toOperationVariant(schema: unknown, op: Operation): unknown;
@@ -0,0 +1 @@
1
+ export declare function validatePayload(schema: unknown, payload: unknown, target: string): void;
@@ -0,0 +1,20 @@
1
+ export declare class CliError extends Error {
2
+ readonly code: string;
3
+ readonly details: Record<string, unknown>;
4
+ constructor(code: string, message: string, details?: Record<string, unknown>);
5
+ }
6
+ export declare class ConfigError extends CliError {
7
+ constructor(message: string, details?: Record<string, unknown>);
8
+ }
9
+ export declare class AuthError extends CliError {
10
+ constructor(message: string, details?: Record<string, unknown>);
11
+ }
12
+ export declare class ValidationError extends CliError {
13
+ constructor(message: string, details?: Record<string, unknown>);
14
+ }
15
+ export declare class HttpError extends CliError {
16
+ readonly status: number;
17
+ readonly body: unknown;
18
+ constructor(status: number, message: string, body?: unknown);
19
+ }
20
+ export declare function exitCodeFor(err: unknown): number;
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import type { AuthAdapter, AuthStatusInfo } from "./core/auth/types.js";
4
+ import type { HttpClient } from "./core/http.js";
5
+ import { type JsonApiClient } from "./core/jsonapi/client.js";
6
+ import type { DropSHPlugin } from "./core/plugin.js";
7
+ export interface CommandContext {
8
+ client: JsonApiClient;
9
+ http: HttpClient;
10
+ auth: AuthAdapter;
11
+ baseUrl: string;
12
+ jsonapiPrefix: string;
13
+ cwd: string;
14
+ plugins: DropSHPlugin[];
15
+ }
16
+ export interface ProgramOptions {
17
+ contextFactory?: () => Promise<CommandContext>;
18
+ stdout?: (s: string) => void;
19
+ stderr?: (s: string) => void;
20
+ setExitCode?: (code: number) => void;
21
+ plugins?: DropSHPlugin[];
22
+ }
23
+ export declare function normalizeInclude(raw: string[] | undefined): string[];
24
+ export interface ResolveAuthDeps {
25
+ baseUrl: string;
26
+ plugins: DropSHPlugin[];
27
+ http: HttpClient;
28
+ now: () => number;
29
+ stateDir?: string;
30
+ /** Explicit profile from --auth-profile / $DROPSH_AUTH_PROFILE (highest precedence). */
31
+ profile?: string;
32
+ }
33
+ export declare function resolveAuth(deps: ResolveAuthDeps): Promise<AuthAdapter>;
34
+ export interface AuthStatusDeps {
35
+ baseUrl: string;
36
+ plugins: DropSHPlugin[];
37
+ stateDir?: string;
38
+ profile?: string;
39
+ }
40
+ export declare function authStatus(deps: AuthStatusDeps): Promise<AuthStatusInfo>;
41
+ export declare function buildProgram(opts?: ProgramOptions): Command;
42
+ export declare function main(): Promise<void>;
package/dist/src/index.js CHANGED
@@ -283,13 +283,46 @@ export function buildProgram(opts = {}) {
283
283
  .description("Search entities with filters")
284
284
  .option("--bundle <bundle>")
285
285
  .option("--filter <kv...>", "filter in key:value or key:op:value form", [])
286
- .option("--limit <n>", "max results", (v) => parseInt(v, 10), 50)
286
+ .option("--limit <n>", "max results per page", (v) => parseInt(v, 10), 50)
287
+ .option("--offset <n>", "skip the first n results (page[offset])", (v) => parseInt(v, 10))
288
+ .option("--sort <field>", "sort by field; prefix with - for descending")
287
289
  .option("--include <fields...>", "related fields to include (JSON:API include)")
290
+ .addHelpText("after", `
291
+ Filter operators (--filter key:op:value):
292
+ = equals (default when op is omitted: key:value)
293
+ <>, != not equals (!= is an alias of <>)
294
+ <, <=, >, >= comparisons
295
+ CONTAINS substring match
296
+ STARTS_WITH prefix match
297
+ ENDS_WITH suffix match
298
+ IN, NOT IN value is a comma-separated list (--filter key:IN:a,b,c)
299
+ BETWEEN value is a comma-separated low,high pair
300
+ NOT BETWEEN (--filter age:BETWEEN:18,65)
301
+ IS NULL field is empty (value-less: --filter key:IS NULL)
302
+ IS NOT NULL field is not empty (value-less: --filter key:IS NOT NULL)
303
+
304
+ An operator is recognised only when it is a known operator above; a value
305
+ containing a colon (e.g. a URL) is kept intact, so key:value still works:
306
+ --filter link:https://example.com/x -> link = "https://example.com/x"
307
+
308
+ Paging & sort:
309
+ --limit <n> max results per page (default 50)
310
+ --offset <n> skip the first n results
311
+ --sort <field> sort ascending; prefix - for descending (e.g. --sort -created)
312
+
313
+ Example:
314
+ dropsh search gaia_ticket --bundle gaia_ticket \\
315
+ --filter title:CONTAINS:search --filter conductor_id:IS NOT NULL \\
316
+ --sort -created --limit 20 --offset 20`)
288
317
  .action((entityType, o) => {
289
318
  // biome-ignore lint/suspicious/noExplicitAny: optional bundle/include added conditionally
290
319
  const args = { entityType, filters: o.filter, limit: o.limit };
291
320
  if (o.bundle !== undefined)
292
321
  args.bundle = o.bundle;
322
+ if (o.offset !== undefined)
323
+ args.offset = o.offset;
324
+ if (o.sort !== undefined)
325
+ args.sort = o.sort;
293
326
  const include = normalizeInclude(o.include);
294
327
  if (include.length > 0)
295
328
  args.include = include;
@@ -0,0 +1,18 @@
1
+ export type { BasicAuthConfig } from "./core/auth/basic.js";
2
+ export { basicAuthPlugin, basicAuthProvider } from "./core/auth/basic.js";
3
+ export type { AdapterRuntime, AuthAdapter, AuthContext, AuthProvider, AuthSession, AuthStatusInfo, } from "./core/auth/types.js";
4
+ export type { AnyRenderer, InteractiveRenderer, RenderContext, Renderer, RenderServices, } from "./core/cli/render.js";
5
+ export { indexIncluded, isInteractive } from "./core/cli/render.js";
6
+ export type { Config, SiteConfig } from "./core/config.js";
7
+ export { loadConfig } from "./core/config.js";
8
+ export type { HttpClient, HttpRequest } from "./core/http.js";
9
+ export { createHttpClient } from "./core/http.js";
10
+ export type { JsonApiClient, JsonApiOptions, ResourceWriteBody, } from "./core/jsonapi/client.js";
11
+ export { createJsonApiClient } from "./core/jsonapi/client.js";
12
+ export type { Collection, FilterOp } from "./core/jsonapi/collection.js";
13
+ export type { JsonApiResourceObject, Resource, } from "./core/jsonapi/resource.js";
14
+ export type { JsonApiDocument, JsonApiResource } from "./core/jsonapi/types.js";
15
+ export type { DropSHPlugin, PluginContext, PluginDescriptor, SchemaOperation, } from "./core/plugin.js";
16
+ export { composePlugins } from "./core/plugin.js";
17
+ export { AuthError, ConfigError, HttpError } from "./errors.js";
18
+ export type { CommandContext } from "./index.js";
@@ -3,4 +3,5 @@ export { indexIncluded, isInteractive } from "./core/cli/render.js";
3
3
  export { loadConfig } from "./core/config.js";
4
4
  export { createHttpClient } from "./core/http.js";
5
5
  export { createJsonApiClient } from "./core/jsonapi/client.js";
6
+ export { composePlugins } from "./core/plugin.js";
6
7
  export { AuthError, ConfigError, HttpError } from "./errors.js";
package/package.json CHANGED
@@ -1,14 +1,21 @@
1
1
  {
2
2
  "name": "dropsh",
3
- "version": "0.5.3",
3
+ "version": "0.5.6",
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": {
7
7
  "dropsh": "./bin/dropsh"
8
8
  },
9
+ "types": "./dist/src/index.d.ts",
9
10
  "exports": {
10
- ".": "./dist/src/index.js",
11
- "./plugin": "./dist/src/plugin-api.js",
11
+ ".": {
12
+ "types": "./dist/src/index.d.ts",
13
+ "default": "./dist/src/index.js"
14
+ },
15
+ "./plugin": {
16
+ "types": "./dist/src/plugin-api.d.ts",
17
+ "default": "./dist/src/plugin-api.js"
18
+ },
12
19
  "./package.json": "./package.json"
13
20
  },
14
21
  "files": [