dropsh 0.6.1 → 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/README.md CHANGED
@@ -171,7 +171,8 @@ Each descriptor's `with` is passed to the plugin factory (legacy alias:
171
171
  descriptor names `dropsh/plugin` with `export: "basicAuthPlugin"`.
172
172
 
173
173
  Use `dropsh.config.example.js` as a fuller starting point. Override the config
174
- path with `--config <path>` or `DROPSH_CONFIG`.
174
+ path with `--config <path>` or `DROPSH_CONFIG`, or address a config file by name
175
+ with `--connection <id>` — see [Named connections](#named-connections--one-config-file-per-site).
175
176
 
176
177
  <details>
177
178
  <summary>Workspace variant — importing factories directly</summary>
@@ -204,6 +205,66 @@ to the config file, so it only works when the packages are installed there.
204
205
 
205
206
  </details>
206
207
 
208
+ ### Named connections — one config file per site
209
+
210
+ A *named connection* is a config file addressed by id instead of by path. A
211
+ connection is a file: `<connections-dir>/<id>.js`, carrying exactly the shape
212
+ `dropsh.config.js` has (`site`, `defaults`, `plugins`), and its basename is its
213
+ id. There is no registry, index or map — adding a connection is adding a file.
214
+ Only `.js` files are connections.
215
+
216
+ ```bash
217
+ dropsh --connection staging read node/article/<uuid>
218
+ ```
219
+
220
+ **The connections directory** defaults to `~/.config/dropsh/connections/`,
221
+ beside the per-host auth store. It is overridable, and the flag wins over the
222
+ environment variable:
223
+
224
+ | # | Selector | Kind |
225
+ |---|----------|------|
226
+ | 1 | `--connections-dir <path>` | flag |
227
+ | 2 | `$DROPSH_CONNECTIONS_DIR` | environment |
228
+ | 3 | `~/.config/dropsh/connections` | default |
229
+
230
+ **Which config file a run uses**, highest first:
231
+
232
+ | # | Selector | Kind |
233
+ |---|----------|------|
234
+ | 1 | `--config <path>` | names a **file** |
235
+ | 2 | `--connection <id>` | names an **entry** |
236
+ | 3 | `$DROPSH_CONFIG` | names a **file** |
237
+ | 4 | `$DROPSH_CONNECTION` | names an **entry** |
238
+ | 5 | `./dropsh.config.js` | the cwd default |
239
+
240
+ `--config` and `--connection` given together is not an error: `--config` names a
241
+ file and wins.
242
+
243
+ An unknown id is a hard error — non-zero exit, naming the id, the directory
244
+ searched and the available ids. Nothing is prompted and nothing is created, so
245
+ an unattended run fails rather than hangs:
246
+
247
+ ```
248
+ Unknown connection 'stagng'. Available in /home/me/.config/dropsh/connections: production, staging.
249
+ ```
250
+
251
+ **List them** with `dropsh connections list`: one row per connection with its
252
+ `site.base_url`, a `*` on the one the current invocation would resolve to, and
253
+ the resolved path stated instead when that is a path rather than an id. A
254
+ connection whose file cannot be read is listed with its error in place of the
255
+ URL and the listing still exits 0. Listing reads `site` only, so a connection
256
+ naming an uninstalled plugin package cannot abort the listing of the others.
257
+
258
+ ```bash
259
+ dropsh connections list
260
+ dropsh connections list --format json
261
+ ```
262
+
263
+ Auth is unchanged by connections: the session store keys on the host of
264
+ `site.base_url`, so two connections against different hosts keep their tokens
265
+ apart and two against the same host share them. `--auth-profile` keeps selecting
266
+ the identity *within* whichever connection is in force.
267
+
207
268
  ## Authentication
208
269
 
209
270
  Authentication is configured through plugins. Each plugin that contributes an
@@ -301,8 +362,13 @@ dropsh delete <entity_type>/<bundle>/<uuid> [--dry-run]
301
362
  dropsh upload-file --target=<entity_type>/<bundle>/<uuid>/<field> --file=<path> [--dry-run]
302
363
  dropsh schema [--refresh]
303
364
  dropsh schema <entity_type>/<bundle> [--for=create|update] [--refresh] [--fields]
365
+ dropsh connections list
304
366
  ```
305
367
 
368
+ Every command accepts the global `--config <path>`, `--connection <id>` and
369
+ `--connections-dir <path>` selectors described under
370
+ [Named connections](#named-connections--one-config-file-per-site).
371
+
306
372
  `create` and `update` validate payloads against the current schema by default.
307
373
  Pass `--no-validate` when you intentionally want to skip local validation.
308
374
 
@@ -0,0 +1,37 @@
1
+ import type { ConfigSource, ConnectionEntry } from "../core/connections.js";
2
+ export interface ConnectionsListArgs {
3
+ /** The resolved connections directory. */
4
+ dir: string;
5
+ /** What this invocation would resolve to — the row it marks, when it names an id. */
6
+ resolved: ConfigSource;
7
+ }
8
+ export interface ConnectionsListDeps {
9
+ list: (dir: string) => Promise<ConnectionEntry[]>;
10
+ emit: (v: unknown) => void | Promise<void>;
11
+ }
12
+ /** One listed connection, as it is emitted. */
13
+ export interface ConnectionsListRow {
14
+ id: string;
15
+ base_url?: string;
16
+ error?: string;
17
+ /** True on the single connection this invocation would resolve to. */
18
+ current: boolean;
19
+ }
20
+ export interface ConnectionsListResult {
21
+ directory: string;
22
+ resolved: ConfigSource;
23
+ connections: ConnectionsListRow[];
24
+ }
25
+ /**
26
+ * List the connections in the resolved directory. A broken connection is a row
27
+ * carrying its error, not an abort: the listing succeeded and reports exactly
28
+ * what is on disk. When the invocation resolves to a path rather than an id —
29
+ * `--config`, `$DROPSH_CONFIG`, or the cwd default — no row is marked and the
30
+ * resolved path stands on its own in `resolved`.
31
+ */
32
+ export declare function runConnectionsList(args: ConnectionsListArgs, deps: ConnectionsListDeps): Promise<ConnectionsListResult>;
33
+ /**
34
+ * The listing as a human reads it: one row per connection, a `*` on the one in
35
+ * force, and the resolved path stated below when no id is in force.
36
+ */
37
+ export declare function renderConnectionsTable(result: ConnectionsListResult): string;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * List the connections in the resolved directory. A broken connection is a row
3
+ * carrying its error, not an abort: the listing succeeded and reports exactly
4
+ * what is on disk. When the invocation resolves to a path rather than an id —
5
+ * `--config`, `$DROPSH_CONFIG`, or the cwd default — no row is marked and the
6
+ * resolved path stands on its own in `resolved`.
7
+ */
8
+ export async function runConnectionsList(args, deps) {
9
+ const entries = await deps.list(args.dir);
10
+ const result = {
11
+ directory: args.dir,
12
+ resolved: args.resolved,
13
+ connections: entries.map((e) => ({
14
+ id: e.id,
15
+ ...(e.baseUrl !== undefined ? { base_url: e.baseUrl } : {}),
16
+ ...(e.error !== undefined ? { error: e.error } : {}),
17
+ current: e.id === args.resolved.id,
18
+ })),
19
+ };
20
+ await deps.emit(result);
21
+ return result;
22
+ }
23
+ /**
24
+ * The listing as a human reads it: one row per connection, a `*` on the one in
25
+ * force, and the resolved path stated below when no id is in force.
26
+ */
27
+ export function renderConnectionsTable(result) {
28
+ const lines = [`Connections in ${result.directory}`];
29
+ if (result.connections.length === 0) {
30
+ lines.push(" (none)");
31
+ }
32
+ const width = Math.max(0, ...result.connections.map((c) => c.id.length));
33
+ for (const c of result.connections) {
34
+ const marker = c.current ? "*" : " ";
35
+ const value = c.base_url ?? `! ${c.error ?? "unreadable"}`;
36
+ lines.push(`${marker} ${c.id.padEnd(width)} ${value}`);
37
+ }
38
+ if (result.resolved.id === undefined) {
39
+ lines.push(`Resolved config: ${result.resolved.path} (${result.resolved.source})`);
40
+ }
41
+ return lines.join("\n");
42
+ }
@@ -1,4 +1,6 @@
1
+ import { defaultStateDir } from "../paths.js";
1
2
  import type { AuthSession } from "./types.js";
3
+ export { defaultStateDir };
2
4
  /** One stored auth identity: the provider that owns it plus its opaque session. */
3
5
  export interface ProfileRecord {
4
6
  provider: string;
@@ -17,7 +19,6 @@ export interface SessionRecord {
17
19
  activeProvider: string;
18
20
  session: AuthSession;
19
21
  }
20
- export declare function defaultStateDir(): string;
21
22
  /**
22
23
  * Read the whole profiles file, migrating a legacy `{ activeProvider, session }`
23
24
  * record in memory to the v2 shape. No write-time migration is needed: the next
@@ -1,10 +1,10 @@
1
1
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
2
  import { join } from "node:path";
4
3
  import { ConfigError } from "../../errors.js";
5
- export function defaultStateDir() {
6
- return join(homedir(), ".config", "dropsh");
7
- }
4
+ import { defaultStateDir } from "../paths.js";
5
+ // Re-exported so every existing importer of the auth store keeps working while
6
+ // the path itself lives in one place (`src/core/paths.ts`).
7
+ export { defaultStateDir };
8
8
  function hostnameFromUrl(baseUrl) {
9
9
  return new URL(baseUrl).hostname;
10
10
  }
@@ -11,4 +11,6 @@ export interface Config {
11
11
  };
12
12
  plugins: DropSHPlugin[];
13
13
  }
14
+ /** The `site` section of a config file, read without resolving its plugins. */
15
+ export declare function loadConfigSite(filePath: string): Promise<SiteConfig>;
14
16
  export declare function loadConfig(filePath: string): Promise<Config>;
@@ -152,7 +152,14 @@ async function resolvePlugins(raw, configPath) {
152
152
  await expand(entry, baseBases, undefined);
153
153
  return out;
154
154
  }
155
- export async function loadConfig(filePath) {
155
+ /**
156
+ * Import a config module and validate its `site` section — everything a config
157
+ * source has to be valid for, short of resolving `plugins[]`. Both `loadConfig`
158
+ * and the connection listing go through here, so the two cannot diverge on what
159
+ * a valid `site` is, and the listing can read many files without constructing
160
+ * any plugin.
161
+ */
162
+ async function importConfig(filePath) {
156
163
  const configPath = resolve(filePath);
157
164
  let mod;
158
165
  try {
@@ -169,13 +176,25 @@ export async function loadConfig(filePath) {
169
176
  throw new ConfigError("site section missing");
170
177
  if (typeof site.base_url !== "string" || site.base_url.length === 0)
171
178
  throw new ConfigError("site.base_url required");
172
- const defaults = isRecord(raw.defaults) ? raw.defaults : {};
173
- const plugins = await resolvePlugins(raw.plugins, configPath);
174
179
  return {
180
+ configPath,
181
+ raw,
175
182
  site: {
176
183
  base_url: site.base_url,
177
184
  jsonapi_prefix: typeof site.jsonapi_prefix === "string" ? site.jsonapi_prefix : "/jsonapi",
178
185
  },
186
+ };
187
+ }
188
+ /** The `site` section of a config file, read without resolving its plugins. */
189
+ export async function loadConfigSite(filePath) {
190
+ return (await importConfig(filePath)).site;
191
+ }
192
+ export async function loadConfig(filePath) {
193
+ const { configPath, raw, site } = await importConfig(filePath);
194
+ const defaults = isRecord(raw.defaults) ? raw.defaults : {};
195
+ const plugins = await resolvePlugins(raw.plugins, configPath);
196
+ return {
197
+ site,
179
198
  defaults: {
180
199
  dry_run: defaults.dry_run === true,
181
200
  timeout_ms: typeof defaults.timeout_ms === "number" ? defaults.timeout_ms : 30000,
@@ -0,0 +1,58 @@
1
+ /** The name of the config file dropsh falls back to in the current directory. */
2
+ export declare const CWD_CONFIG_FILE = "dropsh.config.js";
3
+ /** Which selector produced the resolved config path. */
4
+ export type ConfigSourceKind = "flag-config" | "flag-connection" | "env-config" | "env-connection" | "cwd";
5
+ /** The chosen config file plus the provenance of that choice. */
6
+ export interface ConfigSource {
7
+ path: string;
8
+ source: ConfigSourceKind;
9
+ /** The connection id, when the winning selector named an entry rather than a file. */
10
+ id?: string;
11
+ }
12
+ export interface ResolveConnectionsDirInput {
13
+ /** `--connections-dir <path>`. */
14
+ flag?: string | undefined;
15
+ env?: NodeJS.ProcessEnv;
16
+ }
17
+ /**
18
+ * The connections directory: `--connections-dir` > `$DROPSH_CONNECTIONS_DIR` >
19
+ * `~/.config/dropsh/connections`. Resolved independently of which config
20
+ * selector wins, because `connections list` reports the directory either way.
21
+ */
22
+ export declare function resolveConnectionsDir(input?: ResolveConnectionsDirInput): string;
23
+ export interface ResolveConfigSourceInput {
24
+ /** `--config <path>`. */
25
+ config?: string | undefined;
26
+ /** `--connection <id>`. */
27
+ connection?: string | undefined;
28
+ connectionsDir?: string;
29
+ env?: NodeJS.ProcessEnv;
30
+ cwd?: string;
31
+ }
32
+ /**
33
+ * Resolve the config file for this invocation, highest selector first:
34
+ * `--config` > `--connection` > `$DROPSH_CONFIG` > `$DROPSH_CONNECTION` >
35
+ * `./dropsh.config.js`. `--config` and `--connection` together is not an error:
36
+ * `--config` names a *file* and that is its stated meaning, so it wins silently.
37
+ *
38
+ * Returns the path *and* its provenance, so every caller — the command context,
39
+ * the auth wiring, the pre-parse plugin bootstrap and `connections list` — reads
40
+ * the same answer instead of re-deriving the chain.
41
+ */
42
+ export declare function resolveConfigSource(input?: ResolveConfigSourceInput): ConfigSource;
43
+ /** One row of `connections list`: a connection's id and either its base URL or its error. */
44
+ export interface ConnectionEntry {
45
+ id: string;
46
+ baseUrl?: string;
47
+ error?: string;
48
+ }
49
+ /**
50
+ * Every `.js` file in `dir`, by basename, sorted, with its `site.base_url`. A
51
+ * file that cannot be imported, or that declares no usable `site`, is a row
52
+ * carrying its error rather than an abort: the listing reports exactly what is
53
+ * on disk. Plugins are never constructed, so one connection naming an
54
+ * uninstalled package cannot take the others down with it.
55
+ */
56
+ export declare function listConnections(dir: string): Promise<ConnectionEntry[]>;
57
+ /** Sorted ids of the `.js` files in `dir`; `[]` when the directory is absent. */
58
+ export declare function listConnectionIds(dir: string): Promise<string[]>;
@@ -0,0 +1,115 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import { basename, join } from "node:path";
4
+ import { ConfigError } from "../errors.js";
5
+ import { loadConfigSite } from "./config.js";
6
+ import { defaultConnectionsDir } from "./paths.js";
7
+ /** The name of the config file dropsh falls back to in the current directory. */
8
+ export const CWD_CONFIG_FILE = "dropsh.config.js";
9
+ /**
10
+ * The connections directory: `--connections-dir` > `$DROPSH_CONNECTIONS_DIR` >
11
+ * `~/.config/dropsh/connections`. Resolved independently of which config
12
+ * selector wins, because `connections list` reports the directory either way.
13
+ */
14
+ export function resolveConnectionsDir(input = {}) {
15
+ const env = input.env ?? process.env;
16
+ return input.flag ?? env.DROPSH_CONNECTIONS_DIR ?? defaultConnectionsDir();
17
+ }
18
+ function connectionPath(dir, id) {
19
+ return join(dir, `${id}.js`);
20
+ }
21
+ function toIds(entries) {
22
+ return entries
23
+ .filter((name) => name.endsWith(".js"))
24
+ .map((name) => basename(name, ".js"))
25
+ .sort();
26
+ }
27
+ /**
28
+ * Sorted ids of the `.js` files in `dir`; `[]` when the directory is absent.
29
+ * Synchronous because it is only reached on the unknown-id error path, and the
30
+ * resolver itself has to stay synchronous for the commander option wiring.
31
+ */
32
+ function listConnectionIdsSync(dir) {
33
+ try {
34
+ return toIds(readdirSync(dir));
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ }
40
+ /**
41
+ * Resolve the config file for this invocation, highest selector first:
42
+ * `--config` > `--connection` > `$DROPSH_CONFIG` > `$DROPSH_CONNECTION` >
43
+ * `./dropsh.config.js`. `--config` and `--connection` together is not an error:
44
+ * `--config` names a *file* and that is its stated meaning, so it wins silently.
45
+ *
46
+ * Returns the path *and* its provenance, so every caller — the command context,
47
+ * the auth wiring, the pre-parse plugin bootstrap and `connections list` — reads
48
+ * the same answer instead of re-deriving the chain.
49
+ */
50
+ export function resolveConfigSource(input = {}) {
51
+ const env = input.env ?? process.env;
52
+ const dir = input.connectionsDir ?? resolveConnectionsDir({ env });
53
+ const cwd = input.cwd ?? process.cwd();
54
+ if (input.config)
55
+ return { path: input.config, source: "flag-config" };
56
+ if (input.connection) {
57
+ return {
58
+ path: requireConnection(dir, input.connection),
59
+ source: "flag-connection",
60
+ id: input.connection,
61
+ };
62
+ }
63
+ if (env.DROPSH_CONFIG)
64
+ return { path: env.DROPSH_CONFIG, source: "env-config" };
65
+ if (env.DROPSH_CONNECTION) {
66
+ const id = env.DROPSH_CONNECTION;
67
+ return { path: requireConnection(dir, id), source: "env-connection", id };
68
+ }
69
+ return { path: join(cwd, CWD_CONFIG_FILE), source: "cwd" };
70
+ }
71
+ /**
72
+ * The path of connection `id`, or a `ConfigError` naming the id, the directory
73
+ * searched and the available ids. Nothing prompts and nothing is created: an
74
+ * unattended dispatch must fail rather than hang.
75
+ */
76
+ function requireConnection(dir, id) {
77
+ const path = connectionPath(dir, id);
78
+ if (existsSync(path))
79
+ return path;
80
+ const available = listConnectionIdsSync(dir);
81
+ const tail = available.length > 0
82
+ ? `Available in ${dir}: ${available.join(", ")}.`
83
+ : `no connections found in ${dir}.`;
84
+ throw new ConfigError(`Unknown connection '${id}'. ${tail}`);
85
+ }
86
+ /**
87
+ * Every `.js` file in `dir`, by basename, sorted, with its `site.base_url`. A
88
+ * file that cannot be imported, or that declares no usable `site`, is a row
89
+ * carrying its error rather than an abort: the listing reports exactly what is
90
+ * on disk. Plugins are never constructed, so one connection naming an
91
+ * uninstalled package cannot take the others down with it.
92
+ */
93
+ export async function listConnections(dir) {
94
+ const ids = await listConnectionIds(dir);
95
+ return Promise.all(ids.map(async (id) => {
96
+ try {
97
+ const site = await loadConfigSite(connectionPath(dir, id));
98
+ return { id, baseUrl: site.base_url };
99
+ }
100
+ catch (err) {
101
+ return { id, error: err instanceof Error ? err.message : String(err) };
102
+ }
103
+ }));
104
+ }
105
+ /** Sorted ids of the `.js` files in `dir`; `[]` when the directory is absent. */
106
+ export async function listConnectionIds(dir) {
107
+ let entries;
108
+ try {
109
+ entries = await readdir(dir);
110
+ }
111
+ catch {
112
+ return [];
113
+ }
114
+ return toIds(entries);
115
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The user-global dropsh state directory. Single source of that path: the
3
+ * per-host auth store and the connections directory both hang off it, so the
4
+ * two cannot drift onto different roots.
5
+ */
6
+ export declare function defaultStateDir(): string;
7
+ /** Default location of the named-connection files, one `<id>.js` per connection. */
8
+ export declare function defaultConnectionsDir(): string;
@@ -0,0 +1,14 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ /**
4
+ * The user-global dropsh state directory. Single source of that path: the
5
+ * per-host auth store and the connections directory both hang off it, so the
6
+ * two cannot drift onto different roots.
7
+ */
8
+ export function defaultStateDir() {
9
+ return join(homedir(), ".config", "dropsh");
10
+ }
11
+ /** Default location of the named-connection files, one `<id>.js` per connection. */
12
+ export function defaultConnectionsDir() {
13
+ return join(defaultStateDir(), "connections");
14
+ }
@@ -39,4 +39,16 @@ export interface AuthStatusDeps {
39
39
  }
40
40
  export declare function authStatus(deps: AuthStatusDeps): Promise<AuthStatusInfo>;
41
41
  export declare function buildProgram(opts?: ProgramOptions): Command;
42
+ /**
43
+ * The config selectors as they appear in raw argv, for the pre-parse plugin
44
+ * bootstrap — which runs before commander sees any option. It feeds the same
45
+ * resolver the commands use, so the early and the late resolution cannot answer
46
+ * differently and load plugins from a different connection than the command
47
+ * being bootstrapped.
48
+ */
49
+ export declare function earlyConfigArgs(argv: string[]): {
50
+ config?: string;
51
+ connection?: string;
52
+ connectionsDir?: string;
53
+ };
42
54
  export declare function main(): Promise<void>;
package/dist/src/index.js CHANGED
@@ -3,6 +3,7 @@ import { parseArgs } from "node:util";
3
3
  import { Command } from "commander";
4
4
  import { readDataArg } from "./commands/_data.js";
5
5
  import { runAuthLogin, runAuthLogout, runAuthStatus, runAuthUse } from "./commands/auth.js";
6
+ import { renderConnectionsTable, runConnectionsList, } from "./commands/connections.js";
6
7
  import { runCreate } from "./commands/create.js";
7
8
  import { runDelete } from "./commands/delete.js";
8
9
  import { runRead } from "./commands/read.js";
@@ -16,6 +17,7 @@ import { createFileStore } from "./core/cache/file-store.js";
16
17
  import { createOutput } from "./core/cli/output.js";
17
18
  import { createPrompt } from "./core/cli/prompt.js";
18
19
  import { loadConfig } from "./core/config.js";
20
+ import { listConnections, resolveConfigSource, resolveConnectionsDir, } from "./core/connections.js";
19
21
  import { createHttpClient } from "./core/http.js";
20
22
  import { createJsonApiClient } from "./core/jsonapi/client.js";
21
23
  import { buildPayloadFromParameters } from "./core/params/from-parameters.js";
@@ -26,9 +28,6 @@ import { fetchJsonSchema } from "./core/schema/jsonschema-source.js";
26
28
  import { toOperationVariant } from "./core/schema/to-jsonschema.js";
27
29
  import { validatePayload } from "./core/schema/validate.js";
28
30
  import { AuthError, ConfigError, exitCodeFor, ValidationError } from "./errors.js";
29
- function resolveConfigPath(override) {
30
- return override ?? process.env.DROPSH_CONFIG ?? "dropsh.config.js";
31
- }
32
31
  // Normalizes a variadic --include option into a flat, trimmed list of field
33
32
  // names: splits any comma-containing entries (so both `--include a,b` and
34
33
  // `--include a b` work) and drops empty strings.
@@ -153,7 +152,9 @@ export function buildProgram(opts = {}) {
153
152
  .name("dropsh")
154
153
  .description("Entity-agnostic CLI for Drupal 11 JSON:API")
155
154
  .version("0.0.0")
156
- .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)")
157
158
  .option("--auth-profile <id>", "auth profile to use (overrides the active profile and $DROPSH_AUTH_PROFILE)")
158
159
  .option("--format <id>", "output format: json (default) or a renderer id", "json")
159
160
  .option("--view-mode <name>", "entity view mode for interactive formats", "default");
@@ -163,11 +164,28 @@ export function buildProgram(opts = {}) {
163
164
  ((c) => {
164
165
  process.exitCode = c;
165
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
+ }
166
185
  const contextFactory = opts.contextFactory ??
167
186
  (() => {
168
- const o = program.opts();
169
- const profile = o.authProfile ?? process.env.DROPSH_AUTH_PROFILE;
170
- return defaultContext(resolveConfigPath(o.config), profile);
187
+ const profile = program.opts().authProfile ?? process.env.DROPSH_AUTH_PROFILE;
188
+ return defaultContext(currentConfigSource().path, profile);
171
189
  });
172
190
  const renderers = (opts.plugins ?? []).flatMap((p) => p.renderers ?? []);
173
191
  const output = createOutput({
@@ -279,7 +297,7 @@ export function buildProgram(opts = {}) {
279
297
  }
280
298
  }
281
299
  async function authDeps() {
282
- const cfg = await loadConfig(resolveConfigPath(program.opts().config));
300
+ const cfg = await loadConfig(currentConfigSource().path);
283
301
  return {
284
302
  baseUrl: cfg.site.base_url,
285
303
  providers: collectProviders(cfg.plugins),
@@ -528,6 +546,27 @@ Example:
528
546
  });
529
547
  }, () => assertJsonOnly("schema"));
530
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
+ }));
531
570
  const auth = program.command("auth").description("Manage authentication");
532
571
  auth
533
572
  .command("login")
@@ -558,17 +597,49 @@ Example:
558
597
  program.exitOverride();
559
598
  return program;
560
599
  }
561
- 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) {
562
608
  const { values } = parseArgs({
563
609
  args: argv.slice(2),
564
- options: { config: { type: "string" } },
610
+ options: {
611
+ config: { type: "string" },
612
+ connection: { type: "string" },
613
+ "connections-dir": { type: "string" },
614
+ },
565
615
  strict: false,
566
616
  });
567
- 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
+ };
568
626
  }
569
627
  export async function main() {
570
- const configPath = resolveConfigPath(earlyConfigArg(process.argv));
571
- 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);
572
643
  const plugins = cfg?.plugins ?? [];
573
644
  const program = buildProgram({ plugins });
574
645
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dropsh",
3
- "version": "0.6.1",
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": {