@elixpo/lixblogs-cli 1.1.2
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/API.md +86 -0
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/THREAT_MODEL.md +91 -0
- package/bin/lixblogs.mjs +490 -0
- package/package.json +71 -0
- package/src/api/BlogClient.js +135 -0
- package/src/auth/AuthProvider.js +90 -0
- package/src/auth/AuthenticatedClient.js +116 -0
- package/src/auth/ElixpoAuthProvider.js +281 -0
- package/src/auth/MockAuthProvider.js +170 -0
- package/src/auth/productionGate.js +44 -0
- package/src/commands/auth/login.js +103 -0
- package/src/commands/auth/logout.js +21 -0
- package/src/commands/auth/profiles.js +27 -0
- package/src/commands/auth/revoke.js +45 -0
- package/src/commands/auth/status.js +33 -0
- package/src/commands/blog/index.js +81 -0
- package/src/commands/blog/input.js +59 -0
- package/src/config/CredentialStore.js +142 -0
- package/src/config/KeychainCredentialStore.js +180 -0
- package/src/config/ProfileRegistry.js +105 -0
- package/src/config/config.js +60 -0
- package/src/config/credentialStoreFactory.js +63 -0
- package/src/config/providerFactory.js +42 -0
- package/src/config/redact.js +74 -0
- package/src/content/markdown.js +68 -0
- package/src/content/validate.js +45 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lixblogs auth login
|
|
3
|
+
*
|
|
4
|
+
* Per #135:
|
|
5
|
+
* - Display verification URL, user code, expiry, and polling status;
|
|
6
|
+
* optionally open the browser
|
|
7
|
+
* - Support multiple named accounts/profiles
|
|
8
|
+
* - Destructive and publishing scopes require clear consent
|
|
9
|
+
*
|
|
10
|
+
* This command is deliberately provider-agnostic — it only calls the
|
|
11
|
+
* AuthProvider interface, never a concrete implementation. In dev/tests
|
|
12
|
+
* this is wired to MockAuthProvider; production wiring goes through
|
|
13
|
+
* productionGate.assertProviderAllowed() before this ever runs (see
|
|
14
|
+
* bin/lixblogs.mjs for where that check happens).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { redactErrorMessage } from "../../config/redact.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} params
|
|
21
|
+
* @param {import("../../auth/AuthProvider.js").AuthProvider} params.provider
|
|
22
|
+
* @param {import("../../config/CredentialStore.js").CredentialStore} params.credentialStore
|
|
23
|
+
* @param {string} params.profileId - which named profile this login is for
|
|
24
|
+
* @param {string[]} params.scopes - scopes being requested
|
|
25
|
+
* @param {(url: string) => Promise<void>} [params.openBrowser] - optional browser opener
|
|
26
|
+
* @param {(ms: number) => Promise<void>} [params.sleep] - injectable for tests
|
|
27
|
+
* @param {(...args: any[]) => void} [params.onStatus] - callback for UI updates (verification URL, polling status, etc.)
|
|
28
|
+
* @returns {Promise<{ ok: true, profileId: string } | { ok: false, reason: string }>}
|
|
29
|
+
*/
|
|
30
|
+
export async function authLogin({
|
|
31
|
+
provider,
|
|
32
|
+
credentialStore,
|
|
33
|
+
profileId,
|
|
34
|
+
scopes,
|
|
35
|
+
openBrowser,
|
|
36
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
37
|
+
onStatus = () => {},
|
|
38
|
+
}) {
|
|
39
|
+
let deviceCode;
|
|
40
|
+
try {
|
|
41
|
+
deviceCode = await provider.requestDeviceCode({ scopes });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { ok: false, reason: redactErrorMessage(err.message) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
onStatus({
|
|
47
|
+
type: "verification_pending",
|
|
48
|
+
verificationUri: deviceCode.verificationUri,
|
|
49
|
+
verificationUriComplete: deviceCode.verificationUriComplete,
|
|
50
|
+
userCode: deviceCode.userCode,
|
|
51
|
+
expiresInSeconds: deviceCode.expiresInSeconds,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (openBrowser) {
|
|
55
|
+
await openBrowser(deviceCode.verificationUriComplete || deviceCode.verificationUri);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let pollIntervalMs = deviceCode.pollIntervalSeconds * 1000;
|
|
59
|
+
const deadline = Date.now() + deviceCode.expiresInSeconds * 1000;
|
|
60
|
+
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
await sleep(pollIntervalMs);
|
|
63
|
+
|
|
64
|
+
let result;
|
|
65
|
+
try {
|
|
66
|
+
result = await provider.pollDeviceCode({ deviceCode: deviceCode.deviceCode });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return { ok: false, reason: redactErrorMessage(err.message) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (result.status === "approved") {
|
|
72
|
+
await credentialStore.set(profileId, {
|
|
73
|
+
accessToken: result.token.accessToken,
|
|
74
|
+
refreshToken: result.token.refreshToken,
|
|
75
|
+
expiresAt: Date.now() + result.token.expiresInSeconds * 1000,
|
|
76
|
+
scopes: result.token.scopes,
|
|
77
|
+
});
|
|
78
|
+
onStatus({ type: "approved" });
|
|
79
|
+
return { ok: true, profileId };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (result.status === "denied") {
|
|
83
|
+
onStatus({ type: "denied" });
|
|
84
|
+
return { ok: false, reason: "Login was denied." };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (result.status === "expired") {
|
|
88
|
+
onStatus({ type: "expired" });
|
|
89
|
+
return { ok: false, reason: "Device code expired before login was approved." };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (result.status === "slow_down") {
|
|
93
|
+
pollIntervalMs += result.pollIntervalIncreaseSeconds * 1000;
|
|
94
|
+
onStatus({ type: "slow_down", newIntervalMs: pollIntervalMs });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// status === "pending" — keep polling
|
|
99
|
+
onStatus({ type: "pending" });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { ok: false, reason: "Device code expired before login was approved." };
|
|
103
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lixblogs auth logout
|
|
3
|
+
*
|
|
4
|
+
* Clears locally stored credentials for a profile. This is a *local*
|
|
5
|
+
* operation — it does not revoke the token server-side (that's `revoke`).
|
|
6
|
+
* A user who just wants to stop using this machine, without invalidating
|
|
7
|
+
* the token everywhere, should be able to do that — this command is that
|
|
8
|
+
* lighter-weight action. See revoke.js for the destructive, server-side
|
|
9
|
+
* equivalent.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {Object} params
|
|
14
|
+
* @param {import("../../config/CredentialStore.js").CredentialStore} params.credentialStore
|
|
15
|
+
* @param {string} params.profileId
|
|
16
|
+
* @returns {Promise<{ ok: true }>}
|
|
17
|
+
*/
|
|
18
|
+
export async function authLogout({ credentialStore, profileId }) {
|
|
19
|
+
await credentialStore.delete(profileId);
|
|
20
|
+
return { ok: true };
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** List known profiles without exposing credentials. */
|
|
2
|
+
export async function authProfiles({ credentialStore, profileRegistry }) {
|
|
3
|
+
const activeProfile = await profileRegistry.getActive();
|
|
4
|
+
const profileIds = await credentialStore.listProfiles();
|
|
5
|
+
const profiles = [];
|
|
6
|
+
for (const profileId of profileIds) {
|
|
7
|
+
const credentials = await credentialStore.get(profileId);
|
|
8
|
+
profiles.push({
|
|
9
|
+
profileId,
|
|
10
|
+
active: profileId === activeProfile,
|
|
11
|
+
loggedIn: Boolean(credentials),
|
|
12
|
+
expired: credentials ? Date.now() >= credentials.expiresAt : undefined,
|
|
13
|
+
scopes: credentials?.scopes || [],
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return { activeProfile, profiles };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Select the profile used when --profile is omitted. */
|
|
20
|
+
export async function authUse({ credentialStore, profileRegistry, profileId }) {
|
|
21
|
+
const credentials = await credentialStore.get(profileId);
|
|
22
|
+
if (!credentials) {
|
|
23
|
+
return { ok: false, reason: `Profile "${profileId}" is not logged in.` };
|
|
24
|
+
}
|
|
25
|
+
await profileRegistry.setActive(profileId);
|
|
26
|
+
return { ok: true, profileId };
|
|
27
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lixblogs auth revoke
|
|
3
|
+
*
|
|
4
|
+
* Revokes the token server-side (via the AuthProvider) AND clears local
|
|
5
|
+
* storage. This is destructive — per #135, "destructive and publishing
|
|
6
|
+
* scopes require clear consent" and "destructive commands cannot run
|
|
7
|
+
* accidentally in a non-interactive session."
|
|
8
|
+
*
|
|
9
|
+
* This function does not itself prompt — that's the CLI shell's job
|
|
10
|
+
* (interactive confirmation prompt, or requiring an explicit --yes flag in
|
|
11
|
+
* non-interactive mode). This function requires the caller to have already
|
|
12
|
+
* obtained consent and pass confirmed: true; if confirmed is not exactly
|
|
13
|
+
* true, it refuses to proceed. This makes "forgot to check for consent"
|
|
14
|
+
* impossible to do accidentally at the call site.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Object} params
|
|
19
|
+
* @param {import("../../auth/AuthProvider.js").AuthProvider} params.provider
|
|
20
|
+
* @param {import("../../config/CredentialStore.js").CredentialStore} params.credentialStore
|
|
21
|
+
* @param {string} params.profileId
|
|
22
|
+
* @param {boolean} params.confirmed - must be exactly `true`; caller is
|
|
23
|
+
* responsible for having obtained real user consent before setting this.
|
|
24
|
+
* @returns {Promise<{ ok: true } | { ok: false, reason: string }>}
|
|
25
|
+
*/
|
|
26
|
+
export async function authRevoke({ provider, credentialStore, profileId, confirmed }) {
|
|
27
|
+
if (confirmed !== true) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
reason:
|
|
31
|
+
"Revoke was not confirmed. This is a destructive action and requires " +
|
|
32
|
+
"explicit confirmation (interactive prompt, or --yes in a non-interactive session).",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const credentials = await credentialStore.get(profileId);
|
|
37
|
+
if (!credentials) {
|
|
38
|
+
return { ok: false, reason: `No stored credentials for profile "${profileId}".` };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await provider.revoke({ token: credentials.refreshToken });
|
|
42
|
+
await credentialStore.delete(profileId);
|
|
43
|
+
|
|
44
|
+
return { ok: true };
|
|
45
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lixblogs auth status
|
|
3
|
+
*
|
|
4
|
+
* Shows whether the given profile (or all profiles) is logged in, and
|
|
5
|
+
* whether its token is expired. Never prints the token itself — only
|
|
6
|
+
* derived, safe-to-display metadata (per #135's redaction requirement).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} params
|
|
11
|
+
* @param {import("../../config/CredentialStore.js").CredentialStore} params.credentialStore
|
|
12
|
+
* @param {string} [params.profileId] - if omitted, reports on all profiles
|
|
13
|
+
* @returns {Promise<Array<{ profileId: string, loggedIn: boolean, expired?: boolean, scopes?: string[] }>>}
|
|
14
|
+
*/
|
|
15
|
+
export async function authStatus({ credentialStore, profileId }) {
|
|
16
|
+
const profileIds = profileId ? [profileId] : await credentialStore.listProfiles();
|
|
17
|
+
|
|
18
|
+
const results = [];
|
|
19
|
+
for (const id of profileIds) {
|
|
20
|
+
const credentials = await credentialStore.get(id);
|
|
21
|
+
if (!credentials) {
|
|
22
|
+
results.push({ profileId: id, loggedIn: false });
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
results.push({
|
|
26
|
+
profileId: id,
|
|
27
|
+
loggedIn: true,
|
|
28
|
+
expired: Date.now() >= credentials.expiresAt,
|
|
29
|
+
scopes: credentials.scopes,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return results;
|
|
33
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { blocksToMarkdown } from '../../content/markdown.js';
|
|
2
|
+
import { BlogApiError } from '../../api/BlogClient.js';
|
|
3
|
+
import { metadataFromOptions, resolveMarkdownInput } from './input.js';
|
|
4
|
+
import { validateBlogInput } from '../../content/validate.js';
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export async function blogList({ client, options }) {
|
|
9
|
+
return client.list({ status: options.status, limit: options.limit, cursor: options.cursor });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function blogGet({ client, id }) {
|
|
13
|
+
if (!id) throw new Error('A blog ID is required.');
|
|
14
|
+
const blog = await client.get(id);
|
|
15
|
+
return { ...blog, markdown: blocksToMarkdown(blog.content) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function blogCreate({ client, options, stdin }) {
|
|
19
|
+
const source = await resolveMarkdownInput(options, { stdin });
|
|
20
|
+
const input = { ...metadataFromOptions(options), content: source?.blocks || [] };
|
|
21
|
+
validateBlogInput(input);
|
|
22
|
+
if (options['dry-run']) return { dryRun: true, input, markdown: source?.markdown || '' };
|
|
23
|
+
return client.create(input, { idempotencyKey: options['idempotency-key'] });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function blogEdit({ client, id, options, stdin }) {
|
|
27
|
+
if (!id) throw new Error('A blog ID is required.');
|
|
28
|
+
const current = await client.get(id);
|
|
29
|
+
const source = await resolveMarkdownInput(options, { stdin, initial: blocksToMarkdown(current.content) });
|
|
30
|
+
const input = { ...metadataFromOptions(options), ...(source ? { content: source.blocks } : {}) };
|
|
31
|
+
if (!Object.keys(input).length) throw new Error('No blog changes were provided.');
|
|
32
|
+
validateBlogInput(input);
|
|
33
|
+
if (options['dry-run']) return { dryRun: true, id, etag: current.etag, input, markdown: source?.markdown };
|
|
34
|
+
try {
|
|
35
|
+
return await client.update(id, input, { etag: options.etag || current.etag });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!(error instanceof BlogApiError) || error.code !== 'revision_conflict') throw error;
|
|
38
|
+
const server = await client.get(id);
|
|
39
|
+
const directory = options.conflictDirectory || path.resolve('.lixblogs-conflicts');
|
|
40
|
+
await fs.mkdir(directory, { recursive: true });
|
|
41
|
+
const safeId = id.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
42
|
+
const localPath = path.join(directory, `${safeId}-local.json`);
|
|
43
|
+
const serverPath = path.join(directory, `${safeId}-server.md`);
|
|
44
|
+
await Promise.all([
|
|
45
|
+
fs.writeFile(localPath, JSON.stringify(input, null, 2), { mode: 0o600 }),
|
|
46
|
+
fs.writeFile(serverPath, blocksToMarkdown(server.content), { mode: 0o600 }),
|
|
47
|
+
]);
|
|
48
|
+
error.details = { ...error.details, localPath, serverPath, serverEtag: server.etag };
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function blogPublish({ client, id, options }) {
|
|
54
|
+
if (!id) throw new Error('A blog ID is required.');
|
|
55
|
+
const current = await client.get(id);
|
|
56
|
+
validateBlogInput(current, { publishing: true });
|
|
57
|
+
if (options['dry-run']) return { dryRun: true, id, from: current.status, to: 'published' };
|
|
58
|
+
return client.publish(id, { etag: options.etag || current.etag, idempotencyKey: options['idempotency-key'] });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function blogUnpublish({ client, id, options }) {
|
|
62
|
+
if (!id) throw new Error('A blog ID is required.');
|
|
63
|
+
const current = await client.get(id);
|
|
64
|
+
if (options['dry-run']) return { dryRun: true, id, from: current.status, to: 'draft' };
|
|
65
|
+
return client.unpublish(id, { etag: options.etag || current.etag });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function blogDelete({ client, id, options }) {
|
|
69
|
+
if (!id) throw new Error('A blog ID is required.');
|
|
70
|
+
if (!options.yes) throw new Error('Deletion requires --yes. Trash is the default; add --permanent for irreversible deletion.');
|
|
71
|
+
const current = await client.get(id);
|
|
72
|
+
if (options['dry-run']) return { dryRun: true, id, permanent: options.permanent };
|
|
73
|
+
return client.delete(id, { etag: options.etag || current.etag, permanent: options.permanent });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function blogRestore({ client, id, options }) {
|
|
77
|
+
if (!id) throw new Error('A blog ID is required.');
|
|
78
|
+
const current = await client.get(id);
|
|
79
|
+
if (options['dry-run']) return { dryRun: true, id, restoreTo: current.preDeleteStatus || 'draft' };
|
|
80
|
+
return client.restore(id, { etag: options.etag || current.etag });
|
|
81
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { markdownToBlocks } from '../../content/markdown.js';
|
|
6
|
+
|
|
7
|
+
async function stdinText(stream) {
|
|
8
|
+
let value = '';
|
|
9
|
+
stream.setEncoding('utf8');
|
|
10
|
+
for await (const chunk of stream) value += chunk;
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function editText(initial = '', editor = process.env.EDITOR || process.env.VISUAL) {
|
|
15
|
+
if (!editor) throw new Error('$EDITOR or $VISUAL must be set when using --editor.');
|
|
16
|
+
const directory = await fs.mkdtemp(path.join(tmpdir(), 'lixblogs-'));
|
|
17
|
+
const filename = path.join(directory, 'post.md');
|
|
18
|
+
await fs.writeFile(filename, initial, { mode: 0o600 });
|
|
19
|
+
try {
|
|
20
|
+
await new Promise((resolve, reject) => {
|
|
21
|
+
const child = spawn(editor, [filename], { stdio: 'inherit', shell: true });
|
|
22
|
+
child.once('error', reject);
|
|
23
|
+
child.once('exit', (code) => code === 0 ? resolve() : reject(new Error(`Editor exited with code ${code}.`)));
|
|
24
|
+
});
|
|
25
|
+
return await fs.readFile(filename, 'utf8');
|
|
26
|
+
} finally {
|
|
27
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function resolveMarkdownInput(options, { stdin = process.stdin, initial = '' } = {}) {
|
|
32
|
+
const selected = [options.file !== undefined, options.stdin, options.content !== undefined, options.editor]
|
|
33
|
+
.filter(Boolean).length;
|
|
34
|
+
if (selected > 1) throw new Error('Use only one of --file, --stdin, --content, or --editor.');
|
|
35
|
+
if (!selected) return null;
|
|
36
|
+
let markdown;
|
|
37
|
+
if (options.file !== undefined) markdown = await fs.readFile(path.resolve(options.file), 'utf8');
|
|
38
|
+
else if (options.stdin) markdown = await stdinText(stdin);
|
|
39
|
+
else if (options.content !== undefined) markdown = options.content;
|
|
40
|
+
else markdown = await editText(initial);
|
|
41
|
+
return { markdown, blocks: markdownToBlocks(markdown) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function metadataFromOptions(options) {
|
|
45
|
+
const input = {};
|
|
46
|
+
const mappings = {
|
|
47
|
+
title: 'title', subtitle: 'subtitle', slug: 'slug', emoji: 'emoji',
|
|
48
|
+
publication: 'publishedAs', collection: 'collectionId', cover: 'coverUrl',
|
|
49
|
+
};
|
|
50
|
+
for (const [option, field] of Object.entries(mappings)) {
|
|
51
|
+
if (options[option] !== undefined) input[field] = options[option];
|
|
52
|
+
}
|
|
53
|
+
if (options.tag !== undefined) input.tags = options.tag;
|
|
54
|
+
if (options['member-only']) input.memberOnly = true;
|
|
55
|
+
if (options['no-member-only']) input.memberOnly = false;
|
|
56
|
+
if (options.secret) input.secret = true;
|
|
57
|
+
if (options['not-secret']) input.secret = false;
|
|
58
|
+
return input;
|
|
59
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CredentialStore — abstracts OS keychain access for tokens.
|
|
3
|
+
*
|
|
4
|
+
* Per #135: "Store credentials in the OS keychain; require an explicit
|
|
5
|
+
* opt-in fallback when unavailable." This means:
|
|
6
|
+
* - Default backend must be the real OS keychain (macOS Keychain,
|
|
7
|
+
* Windows Credential Manager, Linux Secret Service/libsecret)
|
|
8
|
+
* - Falling back to anything else (e.g. an encrypted file) requires the
|
|
9
|
+
* user to explicitly opt in at the moment it's needed — never silently
|
|
10
|
+
*
|
|
11
|
+
* This file defines the interface + a real backend wrapper. The actual
|
|
12
|
+
* OS-level library (e.g. a keytar-alternative) is expected to be wired in
|
|
13
|
+
* here; this scaffold ships with the interface, an in-memory store (tests
|
|
14
|
+
* only — never for real use), and the opt-in-gated fallback path so the
|
|
15
|
+
* commands built on top of this don't need to change once the real
|
|
16
|
+
* keychain library is chosen and added as a dependency.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export class CredentialStoreUnavailableError extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "CredentialStoreUnavailableError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class CredentialStore {
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} _profileId
|
|
29
|
+
* @returns {Promise<{ accessToken: string, refreshToken: string, expiresAt: number, scopes: string[] } | null>}
|
|
30
|
+
*/
|
|
31
|
+
async get(_profileId) {
|
|
32
|
+
throw new Error("CredentialStore.get must be implemented by subclass");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} _profileId
|
|
37
|
+
* @param {{ accessToken: string, refreshToken: string, expiresAt: number, scopes: string[] }} _credentials
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
async set(_profileId, _credentials) {
|
|
41
|
+
throw new Error("CredentialStore.set must be implemented by subclass");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} _profileId
|
|
46
|
+
* @returns {Promise<void>} Must not throw if nothing was stored.
|
|
47
|
+
*/
|
|
48
|
+
async delete(_profileId) {
|
|
49
|
+
throw new Error("CredentialStore.delete must be implemented by subclass");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @returns {Promise<string[]>} All profile IDs currently holding credentials.
|
|
54
|
+
*/
|
|
55
|
+
async listProfiles() {
|
|
56
|
+
throw new Error("CredentialStore.listProfiles must be implemented by subclass");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* In-memory store — for tests only. Never use this for real credential
|
|
62
|
+
* storage; it exists purely so command logic can be tested without an OS
|
|
63
|
+
* keychain in CI.
|
|
64
|
+
*/
|
|
65
|
+
export class InMemoryCredentialStore extends CredentialStore {
|
|
66
|
+
constructor() {
|
|
67
|
+
super();
|
|
68
|
+
/** @type {Map<string, object>} */
|
|
69
|
+
this._store = new Map();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async get(profileId) {
|
|
73
|
+
return this._store.get(profileId) ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async set(profileId, credentials) {
|
|
77
|
+
this._store.set(profileId, credentials);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async delete(profileId) {
|
|
81
|
+
this._store.delete(profileId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async listProfiles() {
|
|
85
|
+
return [...this._store.keys()];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Wraps a real OS-keychain-backed store, enforcing the "explicit opt-in
|
|
91
|
+
* fallback" rule: if the underlying keychain library reports unavailable
|
|
92
|
+
* (e.g. no Secret Service running on a headless Linux box), this does NOT
|
|
93
|
+
* silently fall back — it throws CredentialStoreUnavailableError, and the
|
|
94
|
+
* calling command is responsible for prompting the user for explicit
|
|
95
|
+
* opt-in before constructing a fallback store instance.
|
|
96
|
+
*
|
|
97
|
+
* @param {CredentialStore} realStore - the actual OS-keychain-backed store
|
|
98
|
+
*/
|
|
99
|
+
export class GatedCredentialStore extends CredentialStore {
|
|
100
|
+
constructor(realStore) {
|
|
101
|
+
super();
|
|
102
|
+
this._realStore = realStore;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async get(profileId) {
|
|
106
|
+
try {
|
|
107
|
+
return await this._realStore.get(profileId);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
throw new CredentialStoreUnavailableError(
|
|
110
|
+
`OS keychain is unavailable: ${err.message}. Re-run with an explicit ` +
|
|
111
|
+
`fallback flag if you want to opt in to a less secure storage method.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async set(profileId, credentials) {
|
|
117
|
+
try {
|
|
118
|
+
await this._realStore.set(profileId, credentials);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
throw new CredentialStoreUnavailableError(
|
|
121
|
+
`OS keychain is unavailable: ${err.message}. Re-run with an explicit ` +
|
|
122
|
+
`fallback flag if you want to opt in to a less secure storage method.`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async delete(profileId) {
|
|
128
|
+
try {
|
|
129
|
+
await this._realStore.delete(profileId);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
throw new CredentialStoreUnavailableError(`OS keychain is unavailable: ${err.message}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async listProfiles() {
|
|
136
|
+
try {
|
|
137
|
+
return await this._realStore.listProfiles();
|
|
138
|
+
} catch (err) {
|
|
139
|
+
throw new CredentialStoreUnavailableError(`OS keychain is unavailable: ${err.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|