dropsh 0.2.0 → 0.3.1
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 +70 -2
- package/dist/src/commands/auth.js +51 -20
- package/dist/src/core/auth/registry.js +34 -1
- package/dist/src/core/auth/session-store.js +91 -12
- package/dist/src/core/jsonapi/client.js +15 -2
- package/dist/src/index.js +93 -19
- package/package.json +1 -1
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`.
|
|
145
|
-
|
|
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
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loginCapableProviders, providerById } from "../core/auth/registry.js";
|
|
2
|
-
import {
|
|
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 = {
|
|
@@ -40,16 +40,28 @@ async function pickProvider(deps, requestedId) {
|
|
|
40
40
|
export async function runAuthLogin(args, deps) {
|
|
41
41
|
const provider = await pickProvider(deps, args.provider);
|
|
42
42
|
const session = await provider.login(authContext(deps));
|
|
43
|
-
|
|
43
|
+
// Store under the provider id and make it the active profile (login = new default).
|
|
44
|
+
await writeProfile(deps.baseUrl, provider.id, provider.id, session, deps.stateDir);
|
|
45
|
+
await setActive(deps.baseUrl, provider.id, deps.stateDir);
|
|
44
46
|
deps.stdout(`logged in via ${provider.displayName}\n`);
|
|
45
47
|
}
|
|
46
|
-
export async function
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
export async function runAuthUse(args, deps) {
|
|
49
|
+
await setActive(deps.baseUrl, args.profile, deps.stateDir);
|
|
50
|
+
deps.stdout(`active auth profile: ${args.profile}\n`);
|
|
51
|
+
}
|
|
52
|
+
export async function runAuthLogout(args, deps) {
|
|
53
|
+
if (args.all) {
|
|
54
|
+
await clearAll(deps.baseUrl, deps.stateDir);
|
|
55
|
+
deps.stdout("cleared all auth profiles\n");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const file = await readProfiles(deps.baseUrl, deps.stateDir);
|
|
59
|
+
const name = args.profile ?? file?.active;
|
|
60
|
+
if (!name || !file?.profiles[name]) {
|
|
49
61
|
deps.stdout("not logged in\n");
|
|
50
62
|
return;
|
|
51
63
|
}
|
|
52
|
-
const provider = providerById(deps.providers,
|
|
64
|
+
const provider = providerById(deps.providers, file.profiles[name].provider);
|
|
53
65
|
try {
|
|
54
66
|
if (provider)
|
|
55
67
|
await provider.logout(authContext(deps));
|
|
@@ -59,30 +71,49 @@ export async function runAuthLogout(_args, deps) {
|
|
|
59
71
|
deps.stderr(`warning: provider logout failed: ${String(err)}\n`);
|
|
60
72
|
}
|
|
61
73
|
finally {
|
|
62
|
-
await
|
|
74
|
+
await clearProfile(deps.baseUrl, name, deps.stateDir);
|
|
63
75
|
}
|
|
64
|
-
deps.stdout(
|
|
76
|
+
deps.stdout(`logged out ${name}\n`);
|
|
77
|
+
}
|
|
78
|
+
async function statusFor(deps, name, providerId, session) {
|
|
79
|
+
const provider = providerById(deps.providers, providerId);
|
|
80
|
+
const base = provider
|
|
81
|
+
? await provider.status(session)
|
|
82
|
+
: { loggedIn: false, provider: providerId };
|
|
83
|
+
return { ...base, profile: name, active: false };
|
|
65
84
|
}
|
|
66
85
|
export async function runAuthStatus(args, deps) {
|
|
67
|
-
const
|
|
68
|
-
|
|
86
|
+
const file = await readProfiles(deps.baseUrl, deps.stateDir);
|
|
87
|
+
const host = new URL(deps.baseUrl).hostname;
|
|
88
|
+
const names = file ? Object.keys(file.profiles) : [];
|
|
89
|
+
const wanted = args.profile ? names.filter((n) => n === args.profile) : names;
|
|
90
|
+
if (wanted.length === 0) {
|
|
91
|
+
if (args.profile && names.length > 0)
|
|
92
|
+
throw new AuthError(`no auth profile '${args.profile}' for ${host}`);
|
|
69
93
|
if (args.json)
|
|
70
|
-
deps.stdout(`${JSON.stringify({
|
|
94
|
+
deps.stdout(`${JSON.stringify({ host, profiles: {} })}\n`);
|
|
71
95
|
else
|
|
72
96
|
deps.stdout("not logged in\n");
|
|
73
97
|
return;
|
|
74
98
|
}
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
99
|
+
const rows = await Promise.all(wanted.map(async (n) => {
|
|
100
|
+
const rec = file?.profiles[n];
|
|
101
|
+
const info = rec ? await statusFor(deps, n, rec.provider, rec.session) : null;
|
|
102
|
+
return { name: n, active: file?.active === n, info };
|
|
103
|
+
}));
|
|
80
104
|
if (args.json) {
|
|
81
|
-
|
|
105
|
+
const profiles = {};
|
|
106
|
+
for (const r of rows)
|
|
107
|
+
profiles[r.name] = { ...r.info, active: r.active };
|
|
108
|
+
deps.stdout(`${JSON.stringify({ host, active: file?.active, profiles })}\n`);
|
|
82
109
|
return;
|
|
83
110
|
}
|
|
84
|
-
deps.stdout(`provider: ${provider.displayName} [${provider.id}]\n`);
|
|
85
111
|
deps.stdout(`host: ${host}\n`);
|
|
86
|
-
|
|
87
|
-
|
|
112
|
+
for (const r of rows) {
|
|
113
|
+
const marker = r.active ? "*" : " ";
|
|
114
|
+
const state = r.info?.expiresAt !== undefined
|
|
115
|
+
? `${r.info.state} (until ${new Date(r.info.expiresAt).toISOString()})`
|
|
116
|
+
: (r.info?.state ?? (r.info?.loggedIn ? "ok" : "not logged in"));
|
|
117
|
+
deps.stdout(`${marker} ${r.name}: ${state}\n`);
|
|
118
|
+
}
|
|
88
119
|
}
|
|
@@ -1,5 +1,25 @@
|
|
|
1
|
+
import { ConfigError } from "../../errors.js";
|
|
1
2
|
export function collectProviders(plugins) {
|
|
2
|
-
|
|
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);
|
|
@@ -7,3 +27,16 @@ export function loginCapableProviders(providers) {
|
|
|
7
27
|
export function providerById(providers, id) {
|
|
8
28
|
return providers.find((p) => p.id === id);
|
|
9
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* A session-less provider (e.g. static Basic) is the sole configured provider
|
|
32
|
+
* that declares no login capability: it carries credentials inline and needs no
|
|
33
|
+
* stored session. Used by both resolveAuth and authStatus.
|
|
34
|
+
*/
|
|
35
|
+
export function sessionlessProvider(providers) {
|
|
36
|
+
if (providers.length !== 1)
|
|
37
|
+
return undefined;
|
|
38
|
+
const only = providers[0];
|
|
39
|
+
if (!only)
|
|
40
|
+
return undefined;
|
|
41
|
+
return only.capabilities.login === false ? only : undefined;
|
|
42
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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 } from "./core/auth/registry.js";
|
|
13
|
-
import {
|
|
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,20 +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
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
const providers = collectProviders(deps.plugins);
|
|
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, {
|
|
62
|
+
http: deps.http,
|
|
63
|
+
now: deps.now,
|
|
64
|
+
save: async () => { },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
throw new AuthError(`Not authenticated for profile '${name}'. Run 'dropsh auth login --provider ${name}'.`);
|
|
68
|
+
}
|
|
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>.`);
|
|
79
|
+
}
|
|
80
|
+
export async function authStatus(deps) {
|
|
81
|
+
const providers = collectProviders(deps.plugins);
|
|
82
|
+
const file = await readProfiles(deps.baseUrl, deps.stateDir);
|
|
83
|
+
const name = chooseProfile(providers, file, deps.profile);
|
|
84
|
+
if (!name) {
|
|
85
|
+
const sessionless = sessionlessProvider(providers);
|
|
86
|
+
return sessionless
|
|
87
|
+
? { loggedIn: true, provider: sessionless.id, sessionless: true }
|
|
88
|
+
: { loggedIn: false };
|
|
89
|
+
}
|
|
90
|
+
const provider = providerById(providers, name);
|
|
32
91
|
if (!provider)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
}
|
|
99
|
+
return provider.status(rec.session);
|
|
39
100
|
}
|
|
40
|
-
async function defaultContext(configPath) {
|
|
101
|
+
async function defaultContext(configPath, profile) {
|
|
41
102
|
const cfg = await loadConfig(configPath);
|
|
42
103
|
const http = createHttpClient({ timeoutMs: cfg.defaults.timeout_ms });
|
|
43
104
|
const auth = await resolveAuth({
|
|
@@ -45,6 +106,7 @@ async function defaultContext(configPath) {
|
|
|
45
106
|
plugins: cfg.plugins,
|
|
46
107
|
http,
|
|
47
108
|
now: Date.now,
|
|
109
|
+
...(profile !== undefined ? { profile } : {}),
|
|
48
110
|
});
|
|
49
111
|
const client = createJsonApiClient({
|
|
50
112
|
baseUrl: cfg.site.base_url,
|
|
@@ -68,7 +130,8 @@ export function buildProgram(opts = {}) {
|
|
|
68
130
|
.name("dropsh")
|
|
69
131
|
.description("Entity-agnostic CLI for Drupal 11 JSON:API")
|
|
70
132
|
.version("0.0.0")
|
|
71
|
-
.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)");
|
|
72
135
|
const stdout = opts.stdout ?? ((s) => process.stdout.write(s));
|
|
73
136
|
const stderr = opts.stderr ?? ((s) => process.stderr.write(s));
|
|
74
137
|
const setExitCode = opts.setExitCode ??
|
|
@@ -76,7 +139,11 @@ export function buildProgram(opts = {}) {
|
|
|
76
139
|
process.exitCode = c;
|
|
77
140
|
});
|
|
78
141
|
const contextFactory = opts.contextFactory ??
|
|
79
|
-
(() =>
|
|
142
|
+
(() => {
|
|
143
|
+
const o = program.opts();
|
|
144
|
+
const profile = o.authProfile ?? process.env.DROPSH_AUTH_PROFILE;
|
|
145
|
+
return defaultContext(resolveConfigPath(o.config), profile);
|
|
146
|
+
});
|
|
80
147
|
const output = createOutput({ stdout, stderr });
|
|
81
148
|
async function run(fn) {
|
|
82
149
|
try {
|
|
@@ -269,14 +336,21 @@ export function buildProgram(opts = {}) {
|
|
|
269
336
|
.description("Log in via an auth provider")
|
|
270
337
|
.option("--provider <id>", "skip the picker and use this provider id")
|
|
271
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())));
|
|
272
343
|
auth
|
|
273
344
|
.command("logout")
|
|
274
|
-
.description("Clear the active
|
|
275
|
-
.
|
|
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())));
|
|
276
349
|
auth
|
|
277
350
|
.command("status")
|
|
278
|
-
.description("
|
|
351
|
+
.description("List auth profiles and their session state")
|
|
279
352
|
.option("--json", "machine-readable output")
|
|
353
|
+
.option("--profile <id>", "show only this profile")
|
|
280
354
|
.action((o) => run2(async () => runAuthStatus(o, await authDeps())));
|
|
281
355
|
if (opts.plugins) {
|
|
282
356
|
for (const plugin of opts.plugins) {
|