dropsh 0.2.1 → 0.3.3

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
@@ -141,8 +141,76 @@ dropsh auth status
141
141
  dropsh auth logout
142
142
  ```
143
143
 
144
- Secrets and tokens are never written to `dropsh.config.js`. The active session is
145
- stored per host under `~/.config/dropsh/<host>.json` with file mode `0600`.
144
+ Secrets and tokens are never written to `dropsh.config.js`. Sessions are stored
145
+ per host under `~/.config/dropsh/<host>.json` with file mode `0600`.
146
+
147
+ ### Named profiles — many identities per host
148
+
149
+ A host can hold several **named auth profiles** at once (e.g. two OAuth2 scopes)
150
+ and you switch between them without logging in again. Each auth provider is one
151
+ profile, identified by its `id`. For OAuth2, give each `oauth2Plugin` an explicit
152
+ `id` (it defaults to the grant `type` when omitted) so two profiles of the same
153
+ grant flow can coexist:
154
+
155
+ ```js
156
+ plugins: [
157
+ oauth2Plugin({
158
+ id: "session",
159
+ default: true, // used when none is selected/active
160
+ type: "oauth2_client_credentials",
161
+ client_id: "my-client",
162
+ client_secret, // enables headless auto-renew (see below)
163
+ token_url: "https://my-drupal.example.com/oauth/token",
164
+ scope: "some:scope",
165
+ }),
166
+ oauth2Plugin({
167
+ id: "pm",
168
+ type: "oauth2_client_credentials",
169
+ client_id: "my-client",
170
+ client_secret,
171
+ token_url: "https://my-drupal.example.com/oauth/token",
172
+ scope: "other:scope",
173
+ }),
174
+ ],
175
+ ```
176
+
177
+ ```bash
178
+ dropsh auth login --provider session # log in and store the "session" profile
179
+ dropsh auth login --provider pm # log in and store the "pm" profile too
180
+ dropsh auth status # list all profiles; * marks the active one
181
+ dropsh auth use pm # switch the persistent active profile
182
+ dropsh --auth-profile session read … # override the profile for one command
183
+ dropsh auth logout --profile pm # drop one profile
184
+ dropsh auth logout --all # drop every profile for this host
185
+ ```
186
+
187
+ Profile selection precedence (highest first):
188
+
189
+ 1. `--auth-profile <id>` (global flag)
190
+ 2. `$DROPSH_AUTH_PROFILE`
191
+ 3. the stored active profile (`auth use`)
192
+ 4. the provider marked `default: true`
193
+ 5. the sole configured profile, if there is only one
194
+
195
+ With more than one profile and none active/default/selected, dropsh asks you to
196
+ run `auth use <id>` or pass `--auth-profile <id>`.
197
+
198
+ The on-disk format holds every profile in one file; legacy single-session files
199
+ are upgraded automatically on first write.
200
+
201
+ ### Token renewal
202
+
203
+ dropsh renews expiring tokens automatically — both **proactively** (before a token
204
+ lapses) and **reactively** (if the server rejects a token with `401`, dropsh renews
205
+ once and retries the request). Renewal uses the credentials already available:
206
+
207
+ - **`oauth2_authcode`** — the stored `refresh_token`.
208
+ - **`oauth2_client_credentials`** — re-mints from `client_secret`, so keep the
209
+ secret in config (e.g. `conductor.config.local.js`) for unattended runs. Without
210
+ it, an interactive login cannot be renewed and you must `auth login` again.
211
+
212
+ Renewal is isolated per profile: refreshing one profile never touches another's
213
+ session or the active pointer.
146
214
 
147
215
  ## Commands
148
216
 
package/bin/dropsh-src CHANGED
File without changes
@@ -1,5 +1,5 @@
1
- import { loginCapableProviders, providerById } from "../core/auth/registry.js";
2
- import { clearSession, readSession, writeSession } from "../core/auth/session-store.js";
1
+ import { defaultProvider, loginCapableProviders, providerById } from "../core/auth/registry.js";
2
+ import { clearAll, clearProfile, readProfiles, setActive, writeProfile, } from "../core/auth/session-store.js";
3
3
  import { AuthError, ConfigError } from "../errors.js";
4
4
  function authContext(deps) {
5
5
  const ctx = {
@@ -24,6 +24,12 @@ async function pickProvider(deps, requestedId) {
24
24
  throw new ConfigError(`unknown provider '${requestedId}'. Available: ${capable.map((p) => p.id).join(", ")}`);
25
25
  return found;
26
26
  }
27
+ // No explicit --provider: fall back to the configured default profile
28
+ // (default: true), or the sole provider. Only then, if still ambiguous and
29
+ // interactive, prompt; non-interactive with no default stays an error.
30
+ const fallback = defaultProvider(capable) ?? (capable.length === 1 ? capable[0] : undefined);
31
+ if (fallback)
32
+ return fallback;
27
33
  if (!deps.isTTY)
28
34
  throw new ConfigError("non-interactive: pass --provider <id>");
29
35
  deps.stdout("Select an auth provider:\n");
@@ -40,16 +46,28 @@ async function pickProvider(deps, requestedId) {
40
46
  export async function runAuthLogin(args, deps) {
41
47
  const provider = await pickProvider(deps, args.provider);
42
48
  const session = await provider.login(authContext(deps));
43
- await writeSession(deps.baseUrl, provider.id, session, deps.stateDir);
49
+ // Store under the provider id and make it the active profile (login = new default).
50
+ await writeProfile(deps.baseUrl, provider.id, provider.id, session, deps.stateDir);
51
+ await setActive(deps.baseUrl, provider.id, deps.stateDir);
44
52
  deps.stdout(`logged in via ${provider.displayName}\n`);
45
53
  }
46
- export async function runAuthLogout(_args, deps) {
47
- const rec = await readSession(deps.baseUrl, deps.stateDir);
48
- if (!rec) {
54
+ export async function runAuthUse(args, deps) {
55
+ await setActive(deps.baseUrl, args.profile, deps.stateDir);
56
+ deps.stdout(`active auth profile: ${args.profile}\n`);
57
+ }
58
+ export async function runAuthLogout(args, deps) {
59
+ if (args.all) {
60
+ await clearAll(deps.baseUrl, deps.stateDir);
61
+ deps.stdout("cleared all auth profiles\n");
62
+ return;
63
+ }
64
+ const file = await readProfiles(deps.baseUrl, deps.stateDir);
65
+ const name = args.profile ?? file?.active;
66
+ if (!name || !file?.profiles[name]) {
49
67
  deps.stdout("not logged in\n");
50
68
  return;
51
69
  }
52
- const provider = providerById(deps.providers, rec.activeProvider);
70
+ const provider = providerById(deps.providers, file.profiles[name].provider);
53
71
  try {
54
72
  if (provider)
55
73
  await provider.logout(authContext(deps));
@@ -59,30 +77,49 @@ export async function runAuthLogout(_args, deps) {
59
77
  deps.stderr(`warning: provider logout failed: ${String(err)}\n`);
60
78
  }
61
79
  finally {
62
- await clearSession(deps.baseUrl, deps.stateDir);
80
+ await clearProfile(deps.baseUrl, name, deps.stateDir);
63
81
  }
64
- deps.stdout("logged out\n");
82
+ deps.stdout(`logged out ${name}\n`);
83
+ }
84
+ async function statusFor(deps, name, providerId, session) {
85
+ const provider = providerById(deps.providers, providerId);
86
+ const base = provider
87
+ ? await provider.status(session)
88
+ : { loggedIn: false, provider: providerId };
89
+ return { ...base, profile: name, active: false };
65
90
  }
66
91
  export async function runAuthStatus(args, deps) {
67
- const rec = await readSession(deps.baseUrl, deps.stateDir);
68
- if (!rec) {
92
+ const file = await readProfiles(deps.baseUrl, deps.stateDir);
93
+ const host = new URL(deps.baseUrl).hostname;
94
+ const names = file ? Object.keys(file.profiles) : [];
95
+ const wanted = args.profile ? names.filter((n) => n === args.profile) : names;
96
+ if (wanted.length === 0) {
97
+ if (args.profile && names.length > 0)
98
+ throw new AuthError(`no auth profile '${args.profile}' for ${host}`);
69
99
  if (args.json)
70
- deps.stdout(`${JSON.stringify({ loggedIn: false })}\n`);
100
+ deps.stdout(`${JSON.stringify({ host, profiles: {} })}\n`);
71
101
  else
72
102
  deps.stdout("not logged in\n");
73
103
  return;
74
104
  }
75
- const provider = providerById(deps.providers, rec.activeProvider);
76
- if (!provider)
77
- throw new AuthError(`active provider '${rec.activeProvider}' is not configured`);
78
- const info = await provider.status(rec.session);
79
- const host = new URL(deps.baseUrl).hostname;
105
+ const rows = await Promise.all(wanted.map(async (n) => {
106
+ const rec = file?.profiles[n];
107
+ const info = rec ? await statusFor(deps, n, rec.provider, rec.session) : null;
108
+ return { name: n, active: file?.active === n, info };
109
+ }));
80
110
  if (args.json) {
81
- deps.stdout(`${JSON.stringify({ ...info, host })}\n`);
111
+ const profiles = {};
112
+ for (const r of rows)
113
+ profiles[r.name] = { ...r.info, active: r.active };
114
+ deps.stdout(`${JSON.stringify({ host, active: file?.active, profiles })}\n`);
82
115
  return;
83
116
  }
84
- deps.stdout(`provider: ${provider.displayName} [${provider.id}]\n`);
85
117
  deps.stdout(`host: ${host}\n`);
86
- if (info.expiresAt !== undefined)
87
- deps.stdout(`token: ${info.state} (until ${new Date(info.expiresAt).toISOString()})\n`);
118
+ for (const r of rows) {
119
+ const marker = r.active ? "*" : " ";
120
+ const state = r.info?.expiresAt !== undefined
121
+ ? `${r.info.state} (until ${new Date(r.info.expiresAt).toISOString()})`
122
+ : (r.info?.state ?? (r.info?.loggedIn ? "ok" : "not logged in"));
123
+ deps.stdout(`${marker} ${r.name}: ${state}\n`);
124
+ }
88
125
  }
@@ -1,5 +1,25 @@
1
+ import { ConfigError } from "../../errors.js";
1
2
  export function collectProviders(plugins) {
2
- return plugins.map((p) => p.authProvider).filter((p) => p !== undefined);
3
+ const providers = plugins
4
+ .map((p) => p.authProvider)
5
+ .filter((p) => p !== undefined);
6
+ const seen = new Set();
7
+ for (const p of providers) {
8
+ if (seen.has(p.id))
9
+ throw new ConfigError(`duplicate auth profile id '${p.id}'. Give each oauth2Plugin a unique 'id'.`);
10
+ seen.add(p.id);
11
+ }
12
+ return providers;
13
+ }
14
+ /**
15
+ * The provider declaring `default: true`, used as the fallback profile when none
16
+ * is active or explicitly selected. Throws if more than one claims the default.
17
+ */
18
+ export function defaultProvider(providers) {
19
+ const defaults = providers.filter((p) => p.default === true);
20
+ if (defaults.length > 1)
21
+ throw new ConfigError(`multiple auth profiles set default:true (${defaults.map((p) => p.id).join(", ")}). Only one may.`);
22
+ return defaults[0];
3
23
  }
4
24
  export function loginCapableProviders(providers) {
5
25
  return providers.filter((p) => p.capabilities.login);
@@ -1,6 +1,7 @@
1
1
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { ConfigError } from "../../errors.js";
4
5
  export function defaultStateDir() {
5
6
  return join(homedir(), ".config", "dropsh");
6
7
  }
@@ -10,29 +11,107 @@ function hostnameFromUrl(baseUrl) {
10
11
  function sessionPath(baseUrl, dir) {
11
12
  return join(dir, `${hostnameFromUrl(baseUrl)}.json`);
12
13
  }
13
- export async function readSession(baseUrl, dir) {
14
+ function isLegacy(raw) {
15
+ return (typeof raw === "object" &&
16
+ raw !== null &&
17
+ "activeProvider" in raw &&
18
+ typeof raw.activeProvider === "string");
19
+ }
20
+ /**
21
+ * Read the whole profiles file, migrating a legacy `{ activeProvider, session }`
22
+ * record in memory to the v2 shape. No write-time migration is needed: the next
23
+ * `writeProfile` persists v2 naturally.
24
+ */
25
+ export async function readProfiles(baseUrl, dir) {
14
26
  const path = sessionPath(baseUrl, dir ?? defaultStateDir());
27
+ let raw;
15
28
  try {
16
- const raw = await readFile(path, "utf8");
17
- return JSON.parse(raw);
29
+ raw = JSON.parse(await readFile(path, "utf8"));
18
30
  }
19
31
  catch {
20
32
  return null;
21
33
  }
34
+ if (isLegacy(raw)) {
35
+ return {
36
+ version: 2,
37
+ active: raw.activeProvider,
38
+ profiles: { [raw.activeProvider]: { provider: raw.activeProvider, session: raw.session } },
39
+ };
40
+ }
41
+ const file = raw;
42
+ if (typeof file !== "object" || file === null || typeof file.profiles !== "object")
43
+ return null;
44
+ return {
45
+ version: 2,
46
+ profiles: file.profiles ?? {},
47
+ ...(typeof file.active === "string" ? { active: file.active } : {}),
48
+ };
22
49
  }
23
- export async function writeSession(baseUrl, activeProvider, session, dir) {
24
- const stateDir = dir ?? defaultStateDir();
50
+ export async function readProfile(baseUrl, name, dir) {
51
+ const file = await readProfiles(baseUrl, dir);
52
+ return file?.profiles[name] ?? null;
53
+ }
54
+ async function writeFileAtomic(path, file, stateDir) {
25
55
  await mkdir(stateDir, { recursive: true });
26
- const record = { activeProvider, session };
27
- const path = sessionPath(baseUrl, stateDir);
28
- await writeFile(path, JSON.stringify(record, null, 2), {
29
- encoding: "utf8",
30
- mode: 0o600,
31
- });
56
+ await writeFile(path, JSON.stringify(file, null, 2), { encoding: "utf8", mode: 0o600 });
32
57
  // writeFile's `mode` only applies when creating a new file; overwriting an
33
58
  // existing file keeps its old (possibly looser) permissions, so tighten explicitly.
34
59
  await chmod(path, 0o600);
35
60
  }
36
- export async function clearSession(baseUrl, dir) {
61
+ /**
62
+ * Upsert a single profile slot, merging into the existing file so other slots and
63
+ * the `active` pointer are preserved. Used both by `auth login` and by adapter
64
+ * token renewals (which must never disturb sibling profiles).
65
+ */
66
+ export async function writeProfile(baseUrl, name, provider, session, dir) {
67
+ const stateDir = dir ?? defaultStateDir();
68
+ const file = (await readProfiles(baseUrl, stateDir)) ?? { version: 2, profiles: {} };
69
+ file.profiles[name] = { provider, session };
70
+ await writeFileAtomic(sessionPath(baseUrl, stateDir), file, stateDir);
71
+ }
72
+ /** Set the persistent active pointer. Errors if the slot has no stored session. */
73
+ export async function setActive(baseUrl, name, dir) {
74
+ const stateDir = dir ?? defaultStateDir();
75
+ const file = await readProfiles(baseUrl, stateDir);
76
+ if (!file?.profiles[name])
77
+ throw new ConfigError(`no auth profile '${name}' for this host. Run 'dropsh auth login --provider ${name}'.`);
78
+ file.active = name;
79
+ await writeFileAtomic(sessionPath(baseUrl, stateDir), file, stateDir);
80
+ }
81
+ /** Remove a single profile; unset `active` if it pointed there; delete the file if empty. */
82
+ export async function clearProfile(baseUrl, name, dir) {
83
+ const stateDir = dir ?? defaultStateDir();
84
+ const file = await readProfiles(baseUrl, stateDir);
85
+ if (!file?.profiles[name])
86
+ return;
87
+ delete file.profiles[name];
88
+ if (file.active === name)
89
+ delete file.active;
90
+ const path = sessionPath(baseUrl, stateDir);
91
+ if (Object.keys(file.profiles).length === 0) {
92
+ await rm(path, { force: true });
93
+ return;
94
+ }
95
+ await writeFileAtomic(path, file, stateDir);
96
+ }
97
+ /** Remove every profile for this host (deletes the file). */
98
+ export async function clearAll(baseUrl, dir) {
37
99
  await rm(sessionPath(baseUrl, dir ?? defaultStateDir()), { force: true });
38
100
  }
101
+ // --- Legacy (v1) compatibility API -----------------------------------------
102
+ // Existing callers/tests use these single-session helpers. They now operate on
103
+ // the active profile of the v2 store, so old and new code interoperate.
104
+ export async function readSession(baseUrl, dir) {
105
+ const file = await readProfiles(baseUrl, dir);
106
+ if (!file?.active)
107
+ return null;
108
+ const rec = file.profiles[file.active];
109
+ return rec ? { activeProvider: file.active, session: rec.session } : null;
110
+ }
111
+ export async function writeSession(baseUrl, activeProvider, session, dir) {
112
+ await writeProfile(baseUrl, activeProvider, activeProvider, session, dir);
113
+ await setActive(baseUrl, activeProvider, dir);
114
+ }
115
+ export async function clearSession(baseUrl, dir) {
116
+ await clearAll(baseUrl, dir);
117
+ }
@@ -1,3 +1,4 @@
1
+ import { HttpError } from "../../errors.js";
1
2
  import { createCollection } from "./collection.js";
2
3
  import { toResource } from "./resource.js";
3
4
  import { resolveType } from "./types.js";
@@ -19,8 +20,20 @@ export function createJsonApiClient(opts) {
19
20
  headers: { ...JSONAPI_HEADERS, ...extraHeaders },
20
21
  };
21
22
  const req = body !== undefined ? { ...reqBase, body } : reqBase;
22
- const withAuth = await opts.auth.apply(req);
23
- const res = await opts.http.send(withAuth);
23
+ let res;
24
+ try {
25
+ res = await opts.http.send(await opts.auth.apply(req));
26
+ }
27
+ catch (err) {
28
+ // The server rejected the token (401). Ask the auth adapter to renew and,
29
+ // if it could, retry the request exactly once with a fresh token.
30
+ if (!(err instanceof HttpError) || err.status !== 401 || !opts.auth.renew)
31
+ throw err;
32
+ const renewed = await opts.auth.renew();
33
+ if (!renewed)
34
+ throw err;
35
+ res = await opts.http.send(await opts.auth.apply(req));
36
+ }
24
37
  if (res.status === 204 || res.body.length === 0)
25
38
  return { ok: true };
26
39
  return JSON.parse(res.body);
package/dist/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
3
  import { Command } from "commander";
4
- import { runAuthLogin, runAuthLogout, runAuthStatus } from "./commands/auth.js";
4
+ import { runAuthLogin, runAuthLogout, runAuthStatus, runAuthUse } from "./commands/auth.js";
5
5
  import { runCreate } from "./commands/create.js";
6
6
  import { runDelete } from "./commands/delete.js";
7
7
  import { runRead } from "./commands/read.js";
@@ -9,8 +9,8 @@ import { applyOperationSchemaPlugins, operationHookPluginIds, runSchema, SCHEMA_
9
9
  import { runSearch } from "./commands/search.js";
10
10
  import { runUpdate } from "./commands/update.js";
11
11
  import { runUploadFile } from "./commands/upload-file.js";
12
- import { collectProviders, providerById, sessionlessProvider } from "./core/auth/registry.js";
13
- import { readSession, writeSession } from "./core/auth/session-store.js";
12
+ import { collectProviders, defaultProvider, providerById, sessionlessProvider, } from "./core/auth/registry.js";
13
+ import { readProfiles, writeProfile } from "./core/auth/session-store.js";
14
14
  import { createFileStore } from "./core/cache/file-store.js";
15
15
  import { createOutput } from "./core/cli/output.js";
16
16
  import { createPrompt } from "./core/cli/prompt.js";
@@ -24,44 +24,81 @@ import { AuthError, ConfigError, exitCodeFor } from "./errors.js";
24
24
  function resolveConfigPath(override) {
25
25
  return override ?? process.env.DROPSH_CONFIG ?? "dropsh.config.js";
26
26
  }
27
+ /**
28
+ * Choose the profile name to authenticate as. Precedence:
29
+ * explicit → stored `active` → config `default:true` → the sole provider.
30
+ * Returns undefined when nothing selects a profile (caller then tries sessionless).
31
+ */
32
+ function chooseProfile(providers, file, explicit) {
33
+ if (explicit)
34
+ return explicit;
35
+ if (file?.active)
36
+ return file.active;
37
+ const def = defaultProvider(providers);
38
+ if (def)
39
+ return def.id;
40
+ return providers.length === 1 ? providers[0]?.id : undefined;
41
+ }
27
42
  export async function resolveAuth(deps) {
28
43
  const providers = collectProviders(deps.plugins);
29
- const rec = await readSession(deps.baseUrl, deps.stateDir);
30
- if (!rec) {
31
- const sessionless = sessionlessProvider(providers);
32
- if (sessionless) {
33
- return sessionless.createAdapter(undefined, {
44
+ const file = await readProfiles(deps.baseUrl, deps.stateDir);
45
+ const name = chooseProfile(providers, file, deps.profile);
46
+ if (name) {
47
+ const provider = providerById(providers, name);
48
+ if (!provider)
49
+ throw new ConfigError(`auth profile '${name}' has no configured provider`);
50
+ const rec = file?.profiles[name];
51
+ if (rec) {
52
+ return provider.createAdapter(rec.session, {
53
+ http: deps.http,
54
+ now: deps.now,
55
+ save: (session) => writeProfile(deps.baseUrl, name, provider.id, session, deps.stateDir),
56
+ });
57
+ }
58
+ // No stored session for the chosen profile: a login-less provider carries its
59
+ // credentials inline; anything else needs an explicit login.
60
+ if (provider.capabilities.login === false) {
61
+ return provider.createAdapter(undefined, {
34
62
  http: deps.http,
35
63
  now: deps.now,
36
64
  save: async () => { },
37
65
  });
38
66
  }
39
- throw new AuthError("Not authenticated. Run 'dropsh auth login'.");
67
+ throw new AuthError(`Not authenticated for profile '${name}'. Run 'dropsh auth login --provider ${name}'.`);
40
68
  }
41
- const provider = providerById(providers, rec.activeProvider);
42
- if (!provider)
43
- throw new ConfigError(`active provider '${rec.activeProvider}' is not configured`);
44
- return provider.createAdapter(rec.session, {
45
- http: deps.http,
46
- now: deps.now,
47
- save: (session) => writeSession(deps.baseUrl, provider.id, session, deps.stateDir),
48
- });
69
+ const sessionless = sessionlessProvider(providers);
70
+ if (sessionless) {
71
+ return sessionless.createAdapter(undefined, {
72
+ http: deps.http,
73
+ now: deps.now,
74
+ save: async () => { },
75
+ });
76
+ }
77
+ const ids = providers.map((p) => p.id).join(", ");
78
+ throw new AuthError(`Multiple auth profiles configured (${ids}); none active. Run 'dropsh auth use <id>' or pass --auth-profile <id>.`);
49
79
  }
50
80
  export async function authStatus(deps) {
51
81
  const providers = collectProviders(deps.plugins);
52
- const rec = await readSession(deps.baseUrl, deps.stateDir);
53
- if (!rec) {
82
+ const file = await readProfiles(deps.baseUrl, deps.stateDir);
83
+ const name = chooseProfile(providers, file, deps.profile);
84
+ if (!name) {
54
85
  const sessionless = sessionlessProvider(providers);
55
86
  return sessionless
56
87
  ? { loggedIn: true, provider: sessionless.id, sessionless: true }
57
88
  : { loggedIn: false };
58
89
  }
59
- const provider = providerById(providers, rec.activeProvider);
90
+ const provider = providerById(providers, name);
60
91
  if (!provider)
61
92
  return { loggedIn: false };
93
+ const rec = file?.profiles[name];
94
+ if (!rec) {
95
+ return provider.capabilities.login === false
96
+ ? { loggedIn: true, provider: provider.id, sessionless: true }
97
+ : { loggedIn: false, provider: provider.id };
98
+ }
62
99
  return provider.status(rec.session);
63
100
  }
64
- async function defaultContext(configPath) {
101
+ async function defaultContext(configPath, profile) {
65
102
  const cfg = await loadConfig(configPath);
66
103
  const http = createHttpClient({ timeoutMs: cfg.defaults.timeout_ms });
67
104
  const auth = await resolveAuth({
@@ -69,6 +106,7 @@ async function defaultContext(configPath) {
69
106
  plugins: cfg.plugins,
70
107
  http,
71
108
  now: Date.now,
109
+ ...(profile !== undefined ? { profile } : {}),
72
110
  });
73
111
  const client = createJsonApiClient({
74
112
  baseUrl: cfg.site.base_url,
@@ -92,7 +130,8 @@ export function buildProgram(opts = {}) {
92
130
  .name("dropsh")
93
131
  .description("Entity-agnostic CLI for Drupal 11 JSON:API")
94
132
  .version("0.0.0")
95
- .option("--config <path>", "path to config file (overrides DROPSH_CONFIG)");
133
+ .option("--config <path>", "path to config file (overrides DROPSH_CONFIG)")
134
+ .option("--auth-profile <id>", "auth profile to use (overrides the active profile and $DROPSH_AUTH_PROFILE)");
96
135
  const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
97
136
  const stderr = opts.stderr ?? ((s) => process.stderr.write(s));
98
137
  const setExitCode = opts.setExitCode ??
@@ -100,7 +139,11 @@ export function buildProgram(opts = {}) {
100
139
  process.exitCode = c;
101
140
  });
102
141
  const contextFactory = opts.contextFactory ??
103
- (() => defaultContext(resolveConfigPath(program.opts().config)));
142
+ (() => {
143
+ const o = program.opts();
144
+ const profile = o.authProfile ?? process.env.DROPSH_AUTH_PROFILE;
145
+ return defaultContext(resolveConfigPath(o.config), profile);
146
+ });
104
147
  const output = createOutput({ stdout, stderr });
105
148
  async function run(fn) {
106
149
  try {
@@ -293,14 +336,21 @@ export function buildProgram(opts = {}) {
293
336
  .description("Log in via an auth provider")
294
337
  .option("--provider <id>", "skip the picker and use this provider id")
295
338
  .action((o) => run2(async () => runAuthLogin(o.provider ? { provider: o.provider } : {}, await authDeps())));
339
+ auth
340
+ .command("use <id>")
341
+ .description("Set the active auth profile for this host")
342
+ .action((id) => run2(async () => runAuthUse({ profile: id }, await authDeps())));
296
343
  auth
297
344
  .command("logout")
298
- .description("Clear the active session")
299
- .action(() => run2(async () => runAuthLogout({}, await authDeps())));
345
+ .description("Clear a stored session (default: the active profile)")
346
+ .option("--profile <id>", "log out this profile instead of the active one")
347
+ .option("--all", "clear every profile for this host")
348
+ .action((o) => run2(async () => runAuthLogout(o, await authDeps())));
300
349
  auth
301
350
  .command("status")
302
- .description("Show the active session")
351
+ .description("List auth profiles and their session state")
303
352
  .option("--json", "machine-readable output")
353
+ .option("--profile <id>", "show only this profile")
304
354
  .action((o) => run2(async () => runAuthStatus(o, await authDeps())));
305
355
  if (opts.plugins) {
306
356
  for (const plugin of opts.plugins) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dropsh",
3
- "version": "0.2.1",
3
+ "version": "0.3.3",
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": {
@@ -17,22 +17,6 @@
17
17
  "README.md",
18
18
  "LICENSE"
19
19
  ],
20
- "scripts": {
21
- "build": "tsc",
22
- "build:packages": "pnpm --filter \"./packages/*\" run build",
23
- "build:plugins": "pnpm --filter \"./plugins/*\" run build",
24
- "dev": "tsx src/index.ts",
25
- "test": "vitest run --passWithNoTests",
26
- "test:watch": "vitest",
27
- "test:integration": "pnpm run build && pnpm run build:packages && vitest run -c vitest.config.integration.ts",
28
- "typecheck": "pnpm run build:packages && tsc --noEmit && pnpm -r run typecheck",
29
- "lint": "biome check src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
30
- "lint:fix": "biome check --write src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
31
- "drupal:up": "bash tests/integrations/bin/drupal-up.sh",
32
- "drupal:down": "bash tests/integrations/bin/drupal-down.sh",
33
- "prepublishOnly": "pnpm run typecheck && pnpm run build",
34
- "release": "node scripts/release.mjs"
35
- },
36
20
  "keywords": [
37
21
  "drupal",
38
22
  "drupal11",
@@ -62,19 +46,22 @@
62
46
  "vite": "^6.4.3",
63
47
  "vitest": "^4.1.8"
64
48
  },
65
- "pnpm": {
66
- "onlyBuiltDependencies": [
67
- "esbuild"
68
- ],
69
- "overrides": {
70
- "fast-uri": "^3.1.2",
71
- "qs": "^6.15.2",
72
- "vite": "^6.4.2",
73
- "esbuild": "^0.25.0"
74
- }
75
- },
76
49
  "engines": {
77
50
  "node": ">=20"
78
51
  },
79
- "packageManager": "pnpm@10.29.1"
80
- }
52
+ "scripts": {
53
+ "build": "tsc",
54
+ "build:packages": "pnpm --filter \"./packages/*\" run build",
55
+ "build:plugins": "pnpm --filter \"./plugins/*\" run build",
56
+ "dev": "tsx src/index.ts",
57
+ "test": "vitest run --passWithNoTests",
58
+ "test:watch": "vitest",
59
+ "test:integration": "pnpm run build && pnpm run build:packages && vitest run -c vitest.config.integration.ts",
60
+ "typecheck": "pnpm run build:packages && tsc --noEmit && pnpm -r run typecheck",
61
+ "lint": "biome check src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
62
+ "lint:fix": "biome check --write src/ packages/ plugins/ tests/integrations/helpers/ tests/integrations/schema/ tests/integrations/*.ts",
63
+ "drupal:up": "bash tests/integrations/bin/drupal-up.sh",
64
+ "drupal:down": "bash tests/integrations/bin/drupal-down.sh",
65
+ "release": "node scripts/release.mjs"
66
+ }
67
+ }