dropsh 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/index.js CHANGED
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
3
  import { Command } from "commander";
4
+ import { readDataArg } from "./commands/_data.js";
4
5
  import { runAuthLogin, runAuthLogout, runAuthStatus, runAuthUse } from "./commands/auth.js";
6
+ import { renderConnectionsTable, runConnectionsList, } from "./commands/connections.js";
5
7
  import { runCreate } from "./commands/create.js";
6
8
  import { runDelete } from "./commands/delete.js";
7
9
  import { runRead } from "./commands/read.js";
8
10
  import { applyOperationSchemaPlugins, operationHookPluginIds, runSchema, SCHEMA_PIPELINE_VERSION, schemaCacheMetadataMatches, siteCacheRoot, } from "./commands/schema.js";
9
11
  import { runSearch } from "./commands/search.js";
10
- import { runUpdate } from "./commands/update.js";
12
+ import { assertUpdateTarget, runUpdate, } from "./commands/update.js";
11
13
  import { runUploadFile } from "./commands/upload-file.js";
12
14
  import { collectProviders, defaultProvider, providerById, sessionlessProvider, } from "./core/auth/registry.js";
13
15
  import { readProfiles, writeProfile } from "./core/auth/session-store.js";
@@ -15,16 +17,17 @@ import { createFileStore } from "./core/cache/file-store.js";
15
17
  import { createOutput } from "./core/cli/output.js";
16
18
  import { createPrompt } from "./core/cli/prompt.js";
17
19
  import { loadConfig } from "./core/config.js";
20
+ import { listConnections, resolveConfigSource, resolveConnectionsDir, } from "./core/connections.js";
18
21
  import { createHttpClient } from "./core/http.js";
19
22
  import { createJsonApiClient } from "./core/jsonapi/client.js";
23
+ import { buildPayloadFromParameters } from "./core/params/from-parameters.js";
24
+ import { describeFields, FIELD_PARAMETER_HELP, renderFieldsTable } from "./core/params/help.js";
25
+ import { parseFieldArgs } from "./core/params/parse-args.js";
20
26
  import { composeRequestHooks } from "./core/request-hooks.js";
21
27
  import { fetchJsonSchema } from "./core/schema/jsonschema-source.js";
22
28
  import { toOperationVariant } from "./core/schema/to-jsonschema.js";
23
29
  import { validatePayload } from "./core/schema/validate.js";
24
- import { AuthError, ConfigError, exitCodeFor } from "./errors.js";
25
- function resolveConfigPath(override) {
26
- return override ?? process.env.DROPSH_CONFIG ?? "dropsh.config.js";
27
- }
30
+ import { AuthError, ConfigError, exitCodeFor, ValidationError } from "./errors.js";
28
31
  // Normalizes a variadic --include option into a flat, trimmed list of field
29
32
  // names: splits any comma-containing entries (so both `--include a,b` and
30
33
  // `--include a b` work) and drops empty strings.
@@ -149,7 +152,9 @@ export function buildProgram(opts = {}) {
149
152
  .name("dropsh")
150
153
  .description("Entity-agnostic CLI for Drupal 11 JSON:API")
151
154
  .version("0.0.0")
152
- .option("--config <path>", "path to config file (overrides DROPSH_CONFIG)")
155
+ .option("--config <path>", "path to config file (overrides every id-based selector)")
156
+ .option("--connection <id>", "named connection to use: <connections-dir>/<id>.js (overrides $DROPSH_CONFIG)")
157
+ .option("--connections-dir <path>", "where named connections live (overrides $DROPSH_CONNECTIONS_DIR)")
153
158
  .option("--auth-profile <id>", "auth profile to use (overrides the active profile and $DROPSH_AUTH_PROFILE)")
154
159
  .option("--format <id>", "output format: json (default) or a renderer id", "json")
155
160
  .option("--view-mode <name>", "entity view mode for interactive formats", "default");
@@ -159,11 +164,28 @@ export function buildProgram(opts = {}) {
159
164
  ((c) => {
160
165
  process.exitCode = c;
161
166
  });
167
+ /** The connections directory for this invocation: `--connections-dir` > env > default. */
168
+ function currentConnectionsDir() {
169
+ return resolveConnectionsDir({ flag: program.opts().connectionsDir });
170
+ }
171
+ /**
172
+ * The config file this invocation resolves to, and why. Every caller — the
173
+ * command context, the auth wiring, and `connections list` — reads this one
174
+ * answer, so what the listing claims and what the next command does cannot
175
+ * disagree.
176
+ */
177
+ function currentConfigSource() {
178
+ const o = program.opts();
179
+ return resolveConfigSource({
180
+ config: o.config,
181
+ connection: o.connection,
182
+ connectionsDir: currentConnectionsDir(),
183
+ });
184
+ }
162
185
  const contextFactory = opts.contextFactory ??
163
186
  (() => {
164
- const o = program.opts();
165
- const profile = o.authProfile ?? process.env.DROPSH_AUTH_PROFILE;
166
- return defaultContext(resolveConfigPath(o.config), profile);
187
+ const profile = program.opts().authProfile ?? process.env.DROPSH_AUTH_PROFILE;
188
+ return defaultContext(currentConfigSource().path, profile);
167
189
  });
168
190
  const renderers = (opts.plugins ?? []).flatMap((p) => p.renderers ?? []);
169
191
  const output = createOutput({
@@ -201,6 +223,73 @@ export function buildProgram(opts = {}) {
201
223
  throw new ConfigError(`Unknown format '${f}'. Available: ${["json", ...renderers.map((r) => r.id)].join(", ")}`);
202
224
  }
203
225
  }
226
+ /**
227
+ * Print a resolved schema as its field listing: a table for a human, or the
228
+ * same data verbatim on an explicit `--format json`. Shared by `create`,
229
+ * `update` and `schema`, so the three cannot drift.
230
+ */
231
+ function emitFieldListing(schema, rctx) {
232
+ const described = describeFields(schema);
233
+ if (program.getOptionValueSource("format") === "default") {
234
+ stdout(`${renderFieldsTable(described)}\n`);
235
+ }
236
+ else {
237
+ output.emit(described, rctx);
238
+ }
239
+ }
240
+ /**
241
+ * The write-command wiring `create` and `update` share: decide `--data` vs
242
+ * field parameters, fetch the operation schema at most once, build the payload
243
+ * from the parameters, and hand back the args/deps both `runCreate` and
244
+ * `runUpdate` accept. The two commands differ only in how they identify their
245
+ * target, which is why that part stays in each action.
246
+ */
247
+ async function writeWiring(input) {
248
+ const { ctx, options } = input;
249
+ // --fields is a discovery request, not a write: resolve the schema through
250
+ // the normal fetch-and-cache pipeline, print the parameters, send nothing.
251
+ // It reads as a table because a human asked what the fields are; an explicit
252
+ // --format json hands back the same data verbatim for a caller to consume.
253
+ if (options.fields === true) {
254
+ emitFieldListing(await loadOrFetchSchema(ctx, input.schemaTarget, input.operation), input.rctx);
255
+ return "listed";
256
+ }
257
+ // Exactly one route produces the document, and it is settled here so the
258
+ // command itself only ever receives a finished payload.
259
+ const fields = parseFieldArgs(input.tokens);
260
+ if (options.data !== undefined && fields.length > 0) {
261
+ throw new ValidationError("--data and field parameters are mutually exclusive; use one or the other");
262
+ }
263
+ if (options.data === undefined && fields.length === 0) {
264
+ throw new ValidationError("provide either --data or field parameters (--<field> <value>)");
265
+ }
266
+ let cached;
267
+ const schema = async () => (cached ??= await loadOrFetchSchema(ctx, input.schemaTarget, input.operation));
268
+ let payload;
269
+ if (options.data !== undefined) {
270
+ payload = await readDataArg(options.data);
271
+ }
272
+ else {
273
+ input.guard?.();
274
+ payload = buildPayloadFromParameters({
275
+ schema: await schema(),
276
+ parameters: fields,
277
+ operation: input.operation,
278
+ ...(input.id !== undefined ? { id: input.id } : {}),
279
+ resourceType: input.resourceType,
280
+ });
281
+ }
282
+ const args = { payload };
283
+ if (options.dryRun !== undefined)
284
+ args.dryRun = options.dryRun;
285
+ if (options.validate === false)
286
+ args.noValidate = true;
287
+ const deps = { client: ctx.client, emit: (v) => output.emit(v, input.rctx) };
288
+ if (!args.noValidate) {
289
+ deps.validate = async (payload, t) => validatePayload(await schema(), payload, t);
290
+ }
291
+ return { args, deps };
292
+ }
204
293
  function assertJsonOnly(command) {
205
294
  const f = currentFormat();
206
295
  if (f !== "json") {
@@ -208,7 +297,7 @@ export function buildProgram(opts = {}) {
208
297
  }
209
298
  }
210
299
  async function authDeps() {
211
- const cfg = await loadConfig(resolveConfigPath(program.opts().config));
300
+ const cfg = await loadConfig(currentConfigSource().path);
212
301
  return {
213
302
  baseUrl: cfg.site.base_url,
214
303
  providers: collectProviders(cfg.plugins),
@@ -347,54 +436,61 @@ Example:
347
436
  .command("create <entity_type>")
348
437
  .description("Create an entity of given type/bundle")
349
438
  .requiredOption("--bundle <bundle>")
350
- .requiredOption("--data <json>", "inline JSON or @path")
439
+ .option("--data <json>", "inline JSON or @path")
351
440
  .option("--dry-run")
352
441
  .option("--no-validate", "skip client-side schema validation")
353
- .action((entityType, o) => {
354
- const args = { entityType, bundle: o.bundle, dataArg: o.data };
355
- if (o.dryRun !== undefined)
356
- args.dryRun = o.dryRun;
357
- if (o.validate === false)
358
- args.noValidate = true;
442
+ .option("--fields", "list this bundle's field parameters and exit")
443
+ .allowUnknownOption()
444
+ .addHelpText("after", FIELD_PARAMETER_HELP)
445
+ .action((entityType, o, cmd) => {
359
446
  return run(async (ctx) => {
360
- const rctx = { command: "create", entityType, bundle: o.bundle };
361
- const deps = { client: ctx.client, emit: (v) => output.emit(v, rctx) };
362
- if (!args.noValidate) {
363
- deps.validate = async (payload, target) => {
364
- const schema = await loadOrFetchSchema(ctx, target, "create");
365
- validatePayload(schema, payload, target);
366
- };
367
- }
368
- await runCreate(args, deps);
447
+ const wiring = await writeWiring({
448
+ ctx,
449
+ operation: "create",
450
+ schemaTarget: `${entityType}/${o.bundle}`,
451
+ resourceType: `${entityType}--${o.bundle}`,
452
+ tokens: cmd.args.slice(1),
453
+ rctx: { command: "create", entityType, bundle: o.bundle },
454
+ options: o,
455
+ });
456
+ if (wiring === "listed")
457
+ return;
458
+ await runCreate({ entityType, bundle: o.bundle, ...wiring.args }, wiring.deps);
369
459
  }, assertRenderable);
370
460
  });
371
461
  program
372
462
  .command("update <target>")
373
463
  .description("Update an existing entity")
374
- .requiredOption("--data <json>", "inline JSON or @path")
464
+ .option("--data <json>", "inline JSON or @path")
375
465
  .option("--dry-run")
376
466
  .option("--no-validate", "skip client-side schema validation")
377
- .action((target, o) => {
378
- const args = { target, dataArg: o.data };
379
- if (o.dryRun !== undefined)
380
- args.dryRun = o.dryRun;
381
- if (o.validate === false)
382
- args.noValidate = true;
467
+ .option("--fields", "list this bundle's field parameters and exit")
468
+ .allowUnknownOption()
469
+ .addHelpText("after", FIELD_PARAMETER_HELP)
470
+ .action((target, o, cmd) => {
383
471
  return run(async (ctx) => {
384
- const [entityType, bundle] = target.split("/");
472
+ const [entityType, bundle, id] = target.split("/");
385
473
  const rctx = { command: "update", target };
386
474
  if (entityType !== undefined)
387
475
  rctx.entityType = entityType;
388
476
  if (bundle !== undefined)
389
477
  rctx.bundle = bundle;
390
- const deps = { client: ctx.client, emit: (v) => output.emit(v, rctx) };
391
- if (!args.noValidate) {
392
- deps.validate = async (payload, t) => {
393
- const schema = await loadOrFetchSchema(ctx, t, "update");
394
- validatePayload(schema, payload, t);
395
- };
396
- }
397
- await runUpdate(args, deps);
478
+ const wiring = await writeWiring({
479
+ ctx,
480
+ operation: "update",
481
+ schemaTarget: `${entityType}/${bundle}`,
482
+ resourceType: `${entityType}--${bundle}`,
483
+ tokens: cmd.args.slice(1),
484
+ rctx,
485
+ options: o,
486
+ ...(id !== undefined ? { id } : {}),
487
+ // Guard the target before building, so a malformed one is reported by
488
+ // its own message rather than as a missing data.id.
489
+ guard: () => assertUpdateTarget(target),
490
+ });
491
+ if (wiring === "listed")
492
+ return;
493
+ await runUpdate({ target, ...wiring.args }, wiring.deps);
398
494
  }, assertRenderable);
399
495
  });
400
496
  program
@@ -426,22 +522,51 @@ Example:
426
522
  .description("Catalog (no target) or JSON Schema for <entity>/<bundle>")
427
523
  .option("--for <op>", "create|update", "create")
428
524
  .option("--refresh", "bypass cache for this call")
525
+ .option("--fields", "list the target's field parameters instead of its schema")
429
526
  .action((target, o) => {
430
527
  const operation = o.for === "update" ? "update" : "create";
431
528
  const schemaArgs = target !== undefined
432
529
  ? { target, operation, refresh: Boolean(o.refresh) }
433
530
  : { operation, refresh: Boolean(o.refresh) };
434
- return run((ctx) => runSchema(schemaArgs, {
435
- http: ctx.http,
436
- auth: ctx.auth,
437
- baseUrl: ctx.baseUrl,
438
- jsonapiPrefix: ctx.jsonapiPrefix,
439
- cwd: ctx.cwd,
440
- emit: output.emit,
441
- warn: (m) => stderr(`${m}\n`),
442
- plugins: ctx.plugins,
443
- }), () => assertJsonOnly("schema"));
531
+ return run((ctx) => {
532
+ if (o.fields === true && target === undefined) {
533
+ throw new ValidationError("--fields needs a target: dropsh schema <entity_type>/<bundle> --fields");
534
+ }
535
+ // --fields is the same schema, viewed as its parameters: swapping the
536
+ // emitter inherits runSchema's caching and --refresh untouched.
537
+ return runSchema(schemaArgs, {
538
+ http: ctx.http,
539
+ auth: ctx.auth,
540
+ baseUrl: ctx.baseUrl,
541
+ jsonapiPrefix: ctx.jsonapiPrefix,
542
+ cwd: ctx.cwd,
543
+ emit: o.fields === true ? (schema) => emitFieldListing(schema) : output.emit,
544
+ warn: (m) => stderr(`${m}\n`),
545
+ plugins: ctx.plugins,
546
+ });
547
+ }, () => assertJsonOnly("schema"));
444
548
  });
549
+ const connections = program.command("connections").description("Manage named connections");
550
+ connections
551
+ .command("list")
552
+ .description("List the connections in the resolved connections directory")
553
+ .action(() =>
554
+ // Builds no CommandContext, so it works with no cwd config present.
555
+ run2(async () => {
556
+ await runConnectionsList({ dir: currentConnectionsDir(), resolved: currentConfigSource() }, {
557
+ list: listConnections,
558
+ // A table because a human asked what the connections are; an
559
+ // explicit --format json hands back the same data verbatim.
560
+ emit: (v) => {
561
+ if (program.getOptionValueSource("format") === "default") {
562
+ stdout(`${renderConnectionsTable(v)}\n`);
563
+ }
564
+ else {
565
+ output.emit(v);
566
+ }
567
+ },
568
+ });
569
+ }));
445
570
  const auth = program.command("auth").description("Manage authentication");
446
571
  auth
447
572
  .command("login")
@@ -472,17 +597,49 @@ Example:
472
597
  program.exitOverride();
473
598
  return program;
474
599
  }
475
- function earlyConfigArg(argv) {
600
+ /**
601
+ * The config selectors as they appear in raw argv, for the pre-parse plugin
602
+ * bootstrap — which runs before commander sees any option. It feeds the same
603
+ * resolver the commands use, so the early and the late resolution cannot answer
604
+ * differently and load plugins from a different connection than the command
605
+ * being bootstrapped.
606
+ */
607
+ export function earlyConfigArgs(argv) {
476
608
  const { values } = parseArgs({
477
609
  args: argv.slice(2),
478
- options: { config: { type: "string" } },
610
+ options: {
611
+ config: { type: "string" },
612
+ connection: { type: "string" },
613
+ "connections-dir": { type: "string" },
614
+ },
479
615
  strict: false,
480
616
  });
481
- return typeof values.config === "string" ? values.config : undefined;
617
+ const str = (v) => (typeof v === "string" ? v : undefined);
618
+ const config = str(values.config);
619
+ const connection = str(values.connection);
620
+ const connectionsDir = str(values["connections-dir"]);
621
+ return {
622
+ ...(config !== undefined ? { config } : {}),
623
+ ...(connection !== undefined ? { connection } : {}),
624
+ ...(connectionsDir !== undefined ? { connectionsDir } : {}),
625
+ };
482
626
  }
483
627
  export async function main() {
484
- const configPath = resolveConfigPath(earlyConfigArg(process.argv));
485
- const cfg = await loadConfig(configPath).catch(() => null);
628
+ const early = earlyConfigArgs(process.argv);
629
+ // An unresolvable selector is not reported here: the bootstrap only collects
630
+ // plugins, and commander's own resolution raises the error with its exit code.
631
+ const configPath = (() => {
632
+ try {
633
+ return resolveConfigSource({
634
+ ...early,
635
+ connectionsDir: resolveConnectionsDir({ flag: early.connectionsDir }),
636
+ }).path;
637
+ }
638
+ catch {
639
+ return null;
640
+ }
641
+ })();
642
+ const cfg = configPath === null ? null : await loadConfig(configPath).catch(() => null);
486
643
  const plugins = cfg?.plugins ?? [];
487
644
  const program = buildProgram({ plugins });
488
645
  try {
@@ -12,6 +12,11 @@ export { createJsonApiClient } from "./core/jsonapi/client.js";
12
12
  export type { Collection, FilterOp } from "./core/jsonapi/collection.js";
13
13
  export type { JsonApiResourceObject, Resource, } from "./core/jsonapi/resource.js";
14
14
  export type { JsonApiDocument, JsonApiResource } from "./core/jsonapi/types.js";
15
+ export type { BuildPayloadInput } from "./core/params/from-parameters.js";
16
+ export { buildPayloadFromParameters } from "./core/params/from-parameters.js";
17
+ export type { RawParameter } from "./core/params/parse-args.js";
18
+ export type { FieldDescriptor, SchemaFieldIndex } from "./core/params/schema-fields.js";
19
+ export { indexSchemaFields, propertiesOf } from "./core/params/schema-fields.js";
15
20
  export type { DropSHPlugin, PluginContext, PluginDescriptor, RequestContext, SchemaOperation, } from "./core/plugin.js";
16
21
  export { composePlugins } from "./core/plugin.js";
17
22
  export { AuthError, ConfigError, HttpError, PluginError } from "./errors.js";
@@ -3,5 +3,7 @@ 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 { buildPayloadFromParameters } from "./core/params/from-parameters.js";
7
+ export { indexSchemaFields, propertiesOf } from "./core/params/schema-fields.js";
6
8
  export { composePlugins } from "./core/plugin.js";
7
9
  export { AuthError, ConfigError, HttpError, PluginError } from "./errors.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dropsh",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Entity-agnostic helper CLI for Drupal 11 JSON:API, used by the dropsh Claude skill for editorial publishing workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -66,8 +66,8 @@
66
66
  "test:watch": "vitest",
67
67
  "test:integration": "pnpm run build && pnpm run build:packages && vitest run -c vitest.config.integration.ts",
68
68
  "typecheck": "pnpm run build:packages && tsc --noEmit && pnpm -r run typecheck",
69
- "lint": "biome check src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
70
- "lint:fix": "biome check --write src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
69
+ "lint": "biome check src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/params/ tests/integrations/schema/ tests/integrations/*.ts",
70
+ "lint:fix": "biome check --write src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/params/ tests/integrations/schema/ tests/integrations/*.ts",
71
71
  "drupal:up": "bash tests/integrations/bin/drupal-up.sh",
72
72
  "drupal:down": "bash tests/integrations/bin/drupal-down.sh",
73
73
  "init-worktree": "node scripts/init-worktree.mjs",