@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.
@@ -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
+ }