dropsh 0.5.5 → 0.5.7
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 +23 -0
- package/dist/src/commands/_data.d.ts +1 -0
- package/dist/src/commands/auth.d.ts +30 -0
- package/dist/src/commands/create.d.ts +14 -0
- package/dist/src/commands/delete.d.ts +10 -0
- package/dist/src/commands/read.d.ts +10 -0
- package/dist/src/commands/schema.d.ts +42 -0
- package/dist/src/commands/search.d.ts +23 -0
- package/dist/src/commands/search.js +69 -12
- package/dist/src/commands/update.d.ts +13 -0
- package/dist/src/commands/upload-file.d.ts +11 -0
- package/dist/src/core/auth/basic.d.ts +8 -0
- package/dist/src/core/auth/registry.d.ts +16 -0
- package/dist/src/core/auth/session-store.d.ts +42 -0
- package/dist/src/core/auth/types.d.ts +57 -0
- package/dist/src/core/cache/file-store.d.ts +9 -0
- package/dist/src/core/cli/output.d.ts +13 -0
- package/dist/src/core/cli/prompt.d.ts +17 -0
- package/dist/src/core/cli/render.d.ts +25 -0
- package/dist/src/core/config.d.ts +14 -0
- package/dist/src/core/config.js +106 -20
- package/dist/src/core/http.d.ts +21 -0
- package/dist/src/core/jsonapi/client.d.ts +39 -0
- package/dist/src/core/jsonapi/collection.d.ts +31 -0
- package/dist/src/core/jsonapi/resource.d.ts +32 -0
- package/dist/src/core/jsonapi/types.d.ts +40 -0
- package/dist/src/core/plugin.d.ts +65 -0
- package/dist/src/core/plugin.js +12 -1
- package/dist/src/core/schema/catalog.d.ts +14 -0
- package/dist/src/core/schema/jsonschema-source.d.ts +23 -0
- package/dist/src/core/schema/sources/heuristic.d.ts +15 -0
- package/dist/src/core/schema/sources/heuristic.js +4 -1
- package/dist/src/core/schema/to-jsonschema.d.ts +2 -0
- package/dist/src/core/schema/validate.d.ts +1 -0
- package/dist/src/errors.d.ts +20 -0
- package/dist/src/index.d.ts +42 -0
- package/dist/src/index.js +34 -1
- package/dist/src/plugin-api.d.ts +18 -0
- package/dist/src/plugin-api.js +1 -0
- 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
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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>;
|
package/dist/src/core/config.js
CHANGED
|
@@ -16,19 +16,18 @@ function isPluginDescriptor(e) {
|
|
|
16
16
|
return isRecord(e) && typeof e.plugin === "string";
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
* Resolve a named
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* next to a global dropsh, or via `npx -p
|
|
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
|
|
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
|
-
|
|
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,104 @@ 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
|
-
|
|
56
|
+
const construct = () => factory(entry.with ?? entry.options);
|
|
57
|
+
return { resolvedPath: resolved, exportName, construct };
|
|
56
58
|
}
|
|
57
59
|
/**
|
|
58
|
-
* Expands `plugins[]`
|
|
59
|
-
*
|
|
60
|
-
* (
|
|
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 — even when they share a constant `plugin.id`
|
|
69
|
+
* (DROPSH-12), because descriptor de-dup keys on that identity, not `plugin.id`.
|
|
70
|
+
* Pre-constructed plugin objects (and `composePlugins` children) carry no such
|
|
71
|
+
* identity, so those are still de-duplicated by `plugin.id` in the final list. A
|
|
72
|
+
* dependency cycle is rejected with the offending chain of descriptor names.
|
|
73
|
+
* Pre-constructed plugin objects pass through unchanged (backward compat).
|
|
61
74
|
*/
|
|
62
75
|
async function resolvePlugins(raw, configPath) {
|
|
63
76
|
if (!Array.isArray(raw))
|
|
64
77
|
return [];
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
const baseBases = [
|
|
79
|
+
pathToFileURL(configPath).href,
|
|
80
|
+
pathToFileURL(`${process.cwd()}/`).href,
|
|
81
|
+
import.meta.url,
|
|
82
|
+
];
|
|
83
|
+
const out = [];
|
|
84
|
+
const doneKeys = new Set(); // resolvedPath::export::options — fully expanded
|
|
85
|
+
const doneIds = new Set(); // plugin.id already emitted
|
|
86
|
+
// Descriptors on the current DFS path, keyed by resolved identity; `name` is
|
|
87
|
+
// the descriptor string, kept only to render the cycle-error chain.
|
|
88
|
+
const stack = [];
|
|
89
|
+
// Descriptor-constructed plugins are already de-duplicated by their descriptor
|
|
90
|
+
// identity (`doneKeys`, computed in `expand`), so `emit` must not also collapse
|
|
91
|
+
// them by `plugin.id`: two profiles of the same plugin can legitimately share a
|
|
92
|
+
// constant `plugin.id` while being distinct instances (DROPSH-12). Only
|
|
93
|
+
// pre-constructed plugins (and `composePlugins` children) carry no descriptor
|
|
94
|
+
// identity, so those are still de-duplicated by `plugin.id`.
|
|
95
|
+
const emit = (plugin, dedupById) => {
|
|
96
|
+
if (dedupById) {
|
|
97
|
+
if (doneIds.has(plugin.id))
|
|
98
|
+
return;
|
|
99
|
+
doneIds.add(plugin.id);
|
|
100
|
+
}
|
|
101
|
+
out.push(plugin);
|
|
102
|
+
};
|
|
103
|
+
const expandDeps = async (plugin, bases) => {
|
|
104
|
+
for (const dep of plugin.dependencies ?? [])
|
|
105
|
+
await expand(dep, bases, plugin.id);
|
|
106
|
+
};
|
|
107
|
+
// Expand a pre-constructed plugin (or a nested array of them): a composite —
|
|
108
|
+
// e.g. `composePlugins(a(), b())` — reaches here as an array, and is flattened
|
|
109
|
+
// into separate entries so N children register without hand-merging hooks.
|
|
110
|
+
const expandValue = async (value, bases, dedupById) => {
|
|
111
|
+
if (Array.isArray(value)) {
|
|
112
|
+
for (const sub of value)
|
|
113
|
+
await expand(sub, bases, undefined);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const plugin = value;
|
|
117
|
+
await expandDeps(plugin, bases);
|
|
118
|
+
emit(plugin, dedupById);
|
|
119
|
+
};
|
|
120
|
+
async function expand(entry, bases, declaredBy) {
|
|
121
|
+
if (!isPluginDescriptor(entry)) {
|
|
122
|
+
// Pre-constructed plugin object, or a nested-array composite entry: no
|
|
123
|
+
// descriptor identity, so de-duplicate it by `plugin.id`.
|
|
124
|
+
await expandValue(entry, bases, true);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const { resolvedPath, exportName, construct } = await resolveDescriptor(entry, bases, declaredBy);
|
|
128
|
+
const key = `${resolvedPath}::${exportName}::${JSON.stringify(entry.with ?? entry.options ?? null)}`;
|
|
129
|
+
if (doneKeys.has(key))
|
|
130
|
+
return; // same resolved identity already expanded + emitted — do not re-construct
|
|
131
|
+
if (stack.some((f) => f.key === key))
|
|
132
|
+
throw new ConfigError(`plugin dependency cycle: ${[...stack.map((f) => f.name), entry.plugin].join(" → ")}`);
|
|
133
|
+
// A factory may return one plugin or an array of them (composePlugins); a
|
|
134
|
+
// returned array is flattened into separate entries under this descriptor's
|
|
135
|
+
// identity (one construction, so a shared descriptor still runs once).
|
|
136
|
+
const produced = construct();
|
|
137
|
+
stack.push({ key, name: entry.plugin });
|
|
138
|
+
try {
|
|
139
|
+
// Child descriptors resolve relative to this plugin's module first.
|
|
140
|
+
const childBases = [pathToFileURL(resolvedPath).href, ...baseBases];
|
|
141
|
+
// Descriptor-constructed: its identity is tracked by `doneKeys`, so it is
|
|
142
|
+
// emitted without `plugin.id` de-dup (a returned array's pre-constructed
|
|
143
|
+
// children still de-dup by id via the non-descriptor path above).
|
|
144
|
+
await expandValue(produced, childBases, false);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
stack.pop();
|
|
148
|
+
}
|
|
149
|
+
doneKeys.add(key);
|
|
150
|
+
}
|
|
151
|
+
for (const entry of raw)
|
|
152
|
+
await expand(entry, baseBases, undefined);
|
|
153
|
+
return out;
|
|
68
154
|
}
|
|
69
155
|
export async function loadConfig(filePath) {
|
|
70
156
|
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[];
|
package/dist/src/core/plugin.js
CHANGED
|
@@ -1 +1,12 @@
|
|
|
1
|
-
|
|
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 @@
|
|
|
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";
|
package/dist/src/plugin-api.js
CHANGED
|
@@ -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
|
+
"version": "0.5.7",
|
|
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
|
-
".":
|
|
11
|
-
|
|
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": [
|