@elixpo/lixblogs-cli 1.1.2 → 1.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/API.md +18 -0
- package/CHANGELOG.md +10 -0
- package/README.md +111 -18
- package/RELEASE.md +30 -0
- package/bin/lixblogs.mjs +358 -46
- package/package.json +8 -2
- package/skills/lixblogs-analytics/SKILL.md +58 -0
- package/skills/lixblogs-analytics/agents/openai.yaml +7 -0
- package/skills/lixblogs-author/SKILL.md +48 -0
- package/skills/lixblogs-author/agents/openai.yaml +7 -0
- package/skills/lixblogs-editorial/SKILL.md +54 -0
- package/skills/lixblogs-editorial/agents/openai.yaml +7 -0
- package/skills/lixblogs-organizations/SKILL.md +44 -0
- package/skills/lixblogs-organizations/agents/openai.yaml +7 -0
- package/skills/lixblogs-publish/SKILL.md +55 -0
- package/skills/lixblogs-publish/agents/openai.yaml +7 -0
- package/src/api/AnalyticsClient.js +40 -0
- package/src/api/BlogClient.js +5 -0
- package/src/api/CollaborationClient.js +73 -0
- package/src/api/OrgClient.js +158 -0
- package/src/auth/AuthenticatedClient.js +15 -0
- package/src/auth/ElixpoAuthProvider.js +1 -1
- package/src/cli/contract.js +46 -0
- package/src/cli/ui.js +54 -0
- package/src/commands/analytics/index.js +57 -0
- package/src/commands/auth/login.js +16 -2
- package/src/commands/auth/profileAlias.js +29 -0
- package/src/commands/blog/index.js +4 -0
- package/src/commands/collab/index.js +53 -0
- package/src/commands/org/index.js +22 -0
- package/src/commands/skill/index.js +83 -0
- package/src/config/providerFactory.js +1 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lixblogs-author
|
|
3
|
+
description: Draft, inspect, and revise LixBlogs posts through the supported CLI. Use when an agent needs to outline a post, create a draft, update Markdown or metadata, or resolve an edit conflict without publishing or deleting content.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# LixBlogs author
|
|
7
|
+
|
|
8
|
+
Use `@elixpo/lixblogs-cli` 1.2.0 or newer. Run every automation command with `--json --no-input`. Never use D1, session cookies, passwords, bearer tokens, or direct API calls.
|
|
9
|
+
|
|
10
|
+
## Access
|
|
11
|
+
|
|
12
|
+
- Inspect: `lixblogs:profile:read`, `lixblogs:blog:read`
|
|
13
|
+
- Draft or revise: add `lixblogs:blog:write`
|
|
14
|
+
- Do not request publish, delete, organization-write, or collaboration-write scopes for this workflow.
|
|
15
|
+
|
|
16
|
+
Check the active identity before writing:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
lixblogs whoami --json --no-input
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
If a scope is missing, stop and tell the user which scope is required. Do not initiate login without their involvement.
|
|
23
|
+
|
|
24
|
+
## Workflow
|
|
25
|
+
|
|
26
|
+
1. Inspect existing work with `lixblogs blog list --status draft --json --no-input` or `lixblogs blog get BLOG_ID --json --no-input`.
|
|
27
|
+
2. Preserve the creator's claims, citations, tone, headings, and code. Mark unsupported facts for review; do not invent them.
|
|
28
|
+
3. Prefer a Markdown file for substantial content. Use stdin for a generated pipeline and `--editor` only for a human-controlled terminal.
|
|
29
|
+
4. Validate the intended write with `--dry-run`.
|
|
30
|
+
5. Write the draft, then fetch it once to verify the stored result.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
lixblogs blog create --file post.md --title "Title" --tag topic --dry-run --json --no-input
|
|
34
|
+
lixblogs blog create --file post.md --title "Title" --tag topic --json --no-input
|
|
35
|
+
lixblogs blog edit BLOG_ID --file post.md --dry-run --json --no-input
|
|
36
|
+
lixblogs blog edit BLOG_ID --file post.md --json --no-input
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Metadata-only revisions use `--title`, `--subtitle`, `--slug`, repeatable `--tag`, `--emoji`, `--cover`, `--publication`, and `--collection`. Content inputs `--file`, `--stdin`, `--content`, and `--editor` are mutually exclusive.
|
|
40
|
+
|
|
41
|
+
## Recovery
|
|
42
|
+
|
|
43
|
+
- Exit `2`: correct command syntax; never guess a flag.
|
|
44
|
+
- Exit `3` / `revision_conflict`: read `details.localPath` and `details.serverPath`, preserve both authors' changes, obtain creator approval for ambiguous merges, then retry with `--etag details.serverEtag`.
|
|
45
|
+
- Exit `4` / authentication or scope failure: stop and request the named login/scope action.
|
|
46
|
+
- A request ID in an error is diagnostic metadata; report it without exposing credentials.
|
|
47
|
+
|
|
48
|
+
This skill ends at a reviewed draft. Use the separate `lixblogs-publish` skill for any public-state change.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "LixBlogs Author"
|
|
3
|
+
short_description: "Draft and revise LixBlogs posts safely"
|
|
4
|
+
brand_color: "#8B5CF6"
|
|
5
|
+
default_prompt: "Use $lixblogs-author to prepare or revise a LixBlogs draft through the CLI."
|
|
6
|
+
policy:
|
|
7
|
+
allow_implicit_invocation: true
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lixblogs-editorial
|
|
3
|
+
description: Manage LixBlogs collaborators, invitations, review roles, and editorial permissions through the supported CLI. Use when an agent must inspect a blog team, invite a reviewer or editor, change a role, resolve an invitation, or explain deferred collaboration notifications.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# LixBlogs editorial
|
|
7
|
+
|
|
8
|
+
Use `@elixpo/lixblogs-cli` 1.2.0 or newer. Automation must include `--json --no-input`. Never use D1, browser cookies, passwords, raw credentials, or direct API calls.
|
|
9
|
+
|
|
10
|
+
## Authority model
|
|
11
|
+
|
|
12
|
+
- `viewer`: review/read authority only; cannot edit or publish.
|
|
13
|
+
- `editor`: may edit after accepting. Publishing still requires the separate publish OAuth scope and an explicit publishing decision.
|
|
14
|
+
- `admin`: may edit and manage collaborators after accepting. Publishing remains a separate operation.
|
|
15
|
+
- Blog owner and eligible organization managers may manage the team.
|
|
16
|
+
|
|
17
|
+
Read workflows require `lixblogs:collaboration:read`. Invitations, role changes, acceptance, decline, and removal require `lixblogs:collaboration:write`. Do not request `lixblogs:blog:publish` unless the user separately asks to publish.
|
|
18
|
+
|
|
19
|
+
## Inspect
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
lixblogs whoami --json --no-input
|
|
23
|
+
lixblogs collab list BLOG_ID --json --no-input
|
|
24
|
+
lixblogs collab invitations --json --no-input
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Invitation output reports `notificationState`. `deferred_until_publish` means the draft invitation exists but its user notification is intentionally withheld until the blog has a published reader URL.
|
|
28
|
+
|
|
29
|
+
## Mutations
|
|
30
|
+
|
|
31
|
+
State the target user, blog, role, and consequence. Obtain explicit approval before passing `--yes`. Run `--dry-run` first.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
lixblogs collab invite BLOG_ID --user USERNAME --role viewer --dry-run --json --no-input
|
|
35
|
+
lixblogs collab invite BLOG_ID --user USERNAME --role viewer --yes --json --no-input
|
|
36
|
+
lixblogs collab role BLOG_ID --user USERNAME_OR_ID --role editor --yes --json --no-input
|
|
37
|
+
lixblogs collab remove BLOG_ID --user USERNAME_OR_ID --yes --json --no-input
|
|
38
|
+
lixblogs collab accept BLOG_ID --yes --json --no-input
|
|
39
|
+
lixblogs collab accept BLOG_ID --hide-on-profile --yes --json --no-input
|
|
40
|
+
lixblogs collab decline BLOG_ID --yes --json --no-input
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Omitting `--user` from `collab remove` removes the current identity from that blog. Never interpret a review request as permission to grant editor/admin access, accept an invitation, publish, or remove someone.
|
|
44
|
+
|
|
45
|
+
## Recovery
|
|
46
|
+
|
|
47
|
+
- Exit `5`: approval is missing. Ask rather than retrying.
|
|
48
|
+
- `role_forbidden`: report the current role and stop; do not probe another tenant or identity.
|
|
49
|
+
- `collaborator_limit_reached`: do not remove someone automatically to make space.
|
|
50
|
+
- `invitation_not_found`: refresh `collab invitations`; it may have been resolved elsewhere.
|
|
51
|
+
- Authentication/scope failure: request only the reported collaboration scope.
|
|
52
|
+
- Include request IDs in failure reports and never expose credentials.
|
|
53
|
+
|
|
54
|
+
Use `lixblogs-author` for draft content and `lixblogs-publish` only after separate publication approval.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "LixBlogs Editorial"
|
|
3
|
+
short_description: "Manage reviewers and collaborators safely"
|
|
4
|
+
brand_color: "#8B5CF6"
|
|
5
|
+
default_prompt: "Use $lixblogs-editorial to inspect or manage a LixBlogs collaboration workflow."
|
|
6
|
+
policy:
|
|
7
|
+
allow_implicit_invocation: true
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lixblogs-organizations
|
|
3
|
+
description: Inspect LixBlogs organization memberships, roles, collections, and valid publication targets through the supported CLI. Use when an agent must choose or verify a personal, organization, or collection target without crossing tenant boundaries.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# LixBlogs organizations
|
|
7
|
+
|
|
8
|
+
Use `@elixpo/lixblogs-cli` 1.2.0 or newer and add `--json --no-input` for automation. Use only CLI commands; never access D1, cookies, passwords, tokens, or endpoints directly.
|
|
9
|
+
|
|
10
|
+
## Access
|
|
11
|
+
|
|
12
|
+
This read-only workflow requires `lixblogs:organizations:read`. It does not authorize organization changes or publication. Verify the active identity and scope first:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
lixblogs whoami --json --no-input
|
|
16
|
+
lixblogs org list --json --no-input
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Resolve a target
|
|
20
|
+
|
|
21
|
+
1. Use `org list` to obtain authenticated memberships and effective roles.
|
|
22
|
+
2. Use the returned organization ID—not an unverified slug—to inspect it.
|
|
23
|
+
3. Use `org targets` for writable publication targets. It excludes read-only memberships.
|
|
24
|
+
4. If a collection is requested, select its ID only from the target's returned collection list.
|
|
25
|
+
5. Present the chosen tenant, role, and collection to the user before passing `--publication org:ORG_ID` or `--collection COLLECTION_ID` to an authoring command.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
lixblogs org get ORG_ID --json --no-input
|
|
29
|
+
lixblogs org collections ORG_ID --json --no-input
|
|
30
|
+
lixblogs org members ORG_ID --json --no-input
|
|
31
|
+
lixblogs org targets --json --no-input
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Roles `admin`, `maintain`, and `write` may appear as writable. `read` is inspection-only. Never infer access from a public organization page, a slug, a previous run, or user-supplied metadata; the current CLI response is authoritative.
|
|
35
|
+
|
|
36
|
+
## Safety and recovery
|
|
37
|
+
|
|
38
|
+
- `org_not_found` also represents inaccessible tenants. Do not probe alternate identifiers.
|
|
39
|
+
- `insufficient_scope`: request only `lixblogs:organizations:read`.
|
|
40
|
+
- A missing target means the current identity cannot publish there; stop instead of falling back to a similarly named organization.
|
|
41
|
+
- Re-run `org targets` immediately before a draft changes publication tenant because memberships and roles can change.
|
|
42
|
+
- Report request IDs for diagnosis without printing credentials.
|
|
43
|
+
|
|
44
|
+
Use `lixblogs-author` to draft after target selection and `lixblogs-publish` for a separately approved public-state change.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "LixBlogs Organizations"
|
|
3
|
+
short_description: "Resolve safe organization publication targets"
|
|
4
|
+
brand_color: "#8B5CF6"
|
|
5
|
+
default_prompt: "Use $lixblogs-organizations to verify an organization or collection publishing target."
|
|
6
|
+
policy:
|
|
7
|
+
allow_implicit_invocation: true
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lixblogs-publish
|
|
3
|
+
description: Safely preview, publish, unpublish, trash, restore, or permanently delete LixBlogs posts through the supported CLI. Use when an agent is asked to change a post's public or deletion state and must enforce explicit approval, revision checks, and recovery guidance.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# LixBlogs publish
|
|
7
|
+
|
|
8
|
+
Use `@elixpo/lixblogs-cli` 1.2.0 or newer with `--json --no-input`. Never use D1, cookies, raw tokens, or direct HTTP calls.
|
|
9
|
+
|
|
10
|
+
## Access and approval
|
|
11
|
+
|
|
12
|
+
| Action | Scope | Consequence |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| Preview | `lixblogs:blog:read` | No state change |
|
|
15
|
+
| Publish / unpublish | `lixblogs:blog:publish` | Adds or removes public access |
|
|
16
|
+
| Trash / restore | `lixblogs:blog:delete` | Hides or recovers content |
|
|
17
|
+
| Permanent delete | `lixblogs:blog:delete:permanent` | Irreversible removal |
|
|
18
|
+
|
|
19
|
+
Before a state change, state the target, current state, intended state, and consequence. Obtain explicit user approval. A prior request to draft or edit is not approval to publish or delete. Pass `--yes` only after approval.
|
|
20
|
+
|
|
21
|
+
## Procedure
|
|
22
|
+
|
|
23
|
+
1. Verify identity and scopes with `lixblogs whoami --json --no-input`.
|
|
24
|
+
2. Inspect the latest state and ETag:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
lixblogs blog preview BLOG_ID --json --no-input
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
3. Preflight the exact operation with `--dry-run`.
|
|
31
|
+
4. Show the consequence and ask for approval if it is not already explicit.
|
|
32
|
+
5. Execute once with `--yes`, `--etag ETAG`, and a stable `--idempotency-key` where accepted.
|
|
33
|
+
6. Report the returned canonical URL and ETag. Fetch once to verify the final state.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
lixblogs blog publish BLOG_ID --etag ETAG --idempotency-key KEY --dry-run --json --no-input
|
|
37
|
+
lixblogs blog publish BLOG_ID --etag ETAG --idempotency-key KEY --yes --json --no-input
|
|
38
|
+
lixblogs blog unpublish BLOG_ID --etag ETAG --yes --json --no-input
|
|
39
|
+
lixblogs blog trash BLOG_ID --etag ETAG --yes --json --no-input
|
|
40
|
+
lixblogs blog restore BLOG_ID --etag ETAG --yes --json --no-input
|
|
41
|
+
lixblogs blog delete BLOG_ID --etag ETAG --permanent --yes --json --no-input
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Trash is the default deletion behavior. Never add `--permanent` unless the user explicitly asks for irreversible deletion after seeing that consequence.
|
|
45
|
+
|
|
46
|
+
## Conflicts and recovery
|
|
47
|
+
|
|
48
|
+
- Exit `3` / `revision_conflict`: stop. Fetch the current post, explain that it changed, and request a new decision; never force the stale transition.
|
|
49
|
+
- Exit `5` / `confirmation_required`: obtain explicit approval; do not silently retry.
|
|
50
|
+
- `idempotency_in_progress`: wait for the original request result before retrying with the same key.
|
|
51
|
+
- `idempotency_key_reused`: create a new key only for a genuinely new operation.
|
|
52
|
+
- Authentication or missing-scope errors: request only the named scope.
|
|
53
|
+
- If the response has a request ID, include it in the failure report. Never print credentials.
|
|
54
|
+
|
|
55
|
+
Use `lixblogs-author` for content changes before returning to this procedure.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "LixBlogs Publish"
|
|
3
|
+
short_description: "Publish and recover posts with approval"
|
|
4
|
+
brand_color: "#8B5CF6"
|
|
5
|
+
default_prompt: "Use $lixblogs-publish to safely change a LixBlogs post's publication state."
|
|
6
|
+
policy:
|
|
7
|
+
allow_implicit_invocation: true
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { BlogApiError } from './BlogClient.js';
|
|
2
|
+
|
|
3
|
+
async function parseResponse(response) {
|
|
4
|
+
let payload;
|
|
5
|
+
try { payload = await response.json(); } catch { payload = null; }
|
|
6
|
+
if (!response.ok || payload?.error) {
|
|
7
|
+
throw new BlogApiError(
|
|
8
|
+
payload?.error?.code || `http_${response.status}`,
|
|
9
|
+
payload?.error?.message || `LixBlogs returned HTTP ${response.status}.`,
|
|
10
|
+
{ status: response.status, requestId: payload?.error?.requestId || response.headers.get('x-request-id'), details: payload?.error?.details },
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
return payload;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class AnalyticsClient {
|
|
17
|
+
constructor(authenticatedClient) {
|
|
18
|
+
this.http = authenticatedClient;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async query(options = {}) {
|
|
22
|
+
const scope = options.scope || 'personal';
|
|
23
|
+
await this.http.requireScopes([
|
|
24
|
+
'lixblogs:analytics:read',
|
|
25
|
+
...(scope.startsWith('org:') ? ['lixblogs:organizations:read'] : []),
|
|
26
|
+
]);
|
|
27
|
+
const query = new URLSearchParams({
|
|
28
|
+
scope,
|
|
29
|
+
range: options.range || (options.from || options.to ? 'custom' : '30d'),
|
|
30
|
+
dimension: options.dimension || 'overview',
|
|
31
|
+
limit: String(options.limit || 20),
|
|
32
|
+
});
|
|
33
|
+
if (options.from) query.set('from', options.from);
|
|
34
|
+
if (options.to) query.set('to', options.to);
|
|
35
|
+
if (options.cursor) query.set('cursor', options.cursor);
|
|
36
|
+
return parseResponse(await this.http.request(`/api/v1/analytics?${query}`, {
|
|
37
|
+
headers: { accept: 'application/json' },
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/api/BlogClient.js
CHANGED
|
@@ -66,6 +66,11 @@ export class BlogClient {
|
|
|
66
66
|
if (typeof this.http.requireScopes === 'function') await this.http.requireScopes(scopes);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
async whoami() {
|
|
70
|
+
await this.requireScopes(['lixblogs:profile:read']);
|
|
71
|
+
return (await this.request('/api/v1/me')).payload.data;
|
|
72
|
+
}
|
|
73
|
+
|
|
69
74
|
async list({ status = 'all', limit = 20, cursor } = {}) {
|
|
70
75
|
await this.requireScopes(['lixblogs:blog:read']);
|
|
71
76
|
const query = new URLSearchParams({ status, limit: String(limit) });
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { BlogApiError } from './BlogClient.js';
|
|
3
|
+
|
|
4
|
+
async function parseResponse(response) {
|
|
5
|
+
let payload;
|
|
6
|
+
try { payload = await response.json(); } catch { payload = null; }
|
|
7
|
+
if (!response.ok || payload?.error) {
|
|
8
|
+
throw new BlogApiError(payload?.error?.code || `http_${response.status}`, payload?.error?.message || `LixBlogs returned HTTP ${response.status}.`, {
|
|
9
|
+
status: response.status,
|
|
10
|
+
requestId: payload?.error?.requestId || response.headers.get('x-request-id'),
|
|
11
|
+
details: payload?.error?.details,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return payload.data;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class CollaborationClient {
|
|
18
|
+
constructor(authenticatedClient) {
|
|
19
|
+
this.http = authenticatedClient;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async request(path, options = {}) {
|
|
23
|
+
const response = await this.http.request(path, {
|
|
24
|
+
...options,
|
|
25
|
+
headers: {
|
|
26
|
+
accept: 'application/json',
|
|
27
|
+
...(options.body ? { 'content-type': 'application/json' } : {}),
|
|
28
|
+
...options.headers,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
return parseResponse(response);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async list(blogId) {
|
|
35
|
+
await this.http.requireScopes(['lixblogs:collaboration:read']);
|
|
36
|
+
return this.request(`/api/v1/blogs/${encodeURIComponent(blogId)}/collaborators`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async invitations() {
|
|
40
|
+
await this.http.requireScopes(['lixblogs:collaboration:read']);
|
|
41
|
+
return this.request('/api/v1/collaboration/invitations');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async invite(blogId, { user, role, idempotencyKey = randomUUID() }) {
|
|
45
|
+
await this.http.requireScopes(['lixblogs:collaboration:write']);
|
|
46
|
+
return this.request(`/api/v1/blogs/${encodeURIComponent(blogId)}/collaborators`, {
|
|
47
|
+
method: 'POST', headers: { 'idempotency-key': idempotencyKey }, body: JSON.stringify({ user, role }),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async role(blogId, { user, role, idempotencyKey = randomUUID() }) {
|
|
52
|
+
await this.http.requireScopes(['lixblogs:collaboration:write']);
|
|
53
|
+
return this.request(`/api/v1/blogs/${encodeURIComponent(blogId)}/collaborators`, {
|
|
54
|
+
method: 'PATCH', headers: { 'idempotency-key': idempotencyKey }, body: JSON.stringify({ user, role }),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async remove(blogId, { user, idempotencyKey = randomUUID() } = {}) {
|
|
59
|
+
await this.http.requireScopes(['lixblogs:collaboration:write']);
|
|
60
|
+
return this.request(`/api/v1/blogs/${encodeURIComponent(blogId)}/collaborators`, {
|
|
61
|
+
method: 'DELETE', headers: { 'idempotency-key': idempotencyKey }, body: JSON.stringify({ ...(user ? { user } : {}) }),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async resolveInvitation(blogId, { action, showOnProfile = true, idempotencyKey = randomUUID() }) {
|
|
66
|
+
await this.http.requireScopes(['lixblogs:collaboration:write']);
|
|
67
|
+
return this.request('/api/v1/collaboration/invitations', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'idempotency-key': idempotencyKey },
|
|
70
|
+
body: JSON.stringify({ blogId, action, showOnProfile }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { BlogApiError } from "./BlogClient.js";
|
|
2
|
+
|
|
3
|
+
async function parseResponse(response) {
|
|
4
|
+
let payload;
|
|
5
|
+
try {
|
|
6
|
+
payload = await response.json();
|
|
7
|
+
} catch {
|
|
8
|
+
payload = null;
|
|
9
|
+
}
|
|
10
|
+
if (!response.ok || payload?.error) {
|
|
11
|
+
throw new BlogApiError(
|
|
12
|
+
payload?.error?.code || `http_${response.status}`,
|
|
13
|
+
payload?.error?.message ||
|
|
14
|
+
`LixBlogs returned HTTP ${response.status}.`,
|
|
15
|
+
{
|
|
16
|
+
status: response.status,
|
|
17
|
+
requestId:
|
|
18
|
+
payload?.error?.requestId ||
|
|
19
|
+
response.headers.get("x-request-id"),
|
|
20
|
+
details: payload?.error?.details,
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return { payload, etag: response.headers.get("etag") };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class OrgClient {
|
|
28
|
+
constructor(
|
|
29
|
+
authenticatedClient,
|
|
30
|
+
{
|
|
31
|
+
sleep = (milliseconds) =>
|
|
32
|
+
new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
33
|
+
} = {},
|
|
34
|
+
) {
|
|
35
|
+
this.http = authenticatedClient;
|
|
36
|
+
this.sleep = sleep;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async request(path, options = {}) {
|
|
40
|
+
const requestOptions = {
|
|
41
|
+
...options,
|
|
42
|
+
headers: {
|
|
43
|
+
accept: "application/json",
|
|
44
|
+
...(options.body ? { "content-type": "application/json" } : {}),
|
|
45
|
+
...options.headers,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const method = requestOptions.method || "GET";
|
|
49
|
+
const retryable = method === "GET";
|
|
50
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
51
|
+
try {
|
|
52
|
+
const response = await this.http.request(path, requestOptions);
|
|
53
|
+
if (
|
|
54
|
+
retryable &&
|
|
55
|
+
attempt === 0 &&
|
|
56
|
+
(response.status === 429 || response.status >= 500)
|
|
57
|
+
) {
|
|
58
|
+
const seconds = Math.min(
|
|
59
|
+
2,
|
|
60
|
+
Number.parseInt(
|
|
61
|
+
response.headers.get("retry-after") || "1",
|
|
62
|
+
10,
|
|
63
|
+
) || 1,
|
|
64
|
+
);
|
|
65
|
+
await this.sleep(seconds * 1000);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
return parseResponse(response);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (
|
|
71
|
+
!retryable ||
|
|
72
|
+
attempt > 0 ||
|
|
73
|
+
error instanceof BlogApiError ||
|
|
74
|
+
error?.code
|
|
75
|
+
)
|
|
76
|
+
throw error;
|
|
77
|
+
await this.sleep(250);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw new BlogApiError(
|
|
81
|
+
"request_failed",
|
|
82
|
+
"The LixBlogs request failed after retrying.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async requireScopes(scopes) {
|
|
87
|
+
if (typeof this.http.requireScopes === "function")
|
|
88
|
+
await this.http.requireScopes(scopes);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async list() {
|
|
92
|
+
await this.requireScopes(["lixblogs:organizations:read"]);
|
|
93
|
+
return (await this.request("/api/v1/orgs")).payload;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async get(id) {
|
|
97
|
+
if (!id) throw new Error("An organization ID or handle is required.");
|
|
98
|
+
await this.requireScopes(["lixblogs:organizations:read"]);
|
|
99
|
+
return (await this.request(`/api/v1/orgs/${encodeURIComponent(id)}`))
|
|
100
|
+
.payload.data;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async collections(id) {
|
|
104
|
+
if (!id) throw new Error("An organization ID or handle is required.");
|
|
105
|
+
await this.requireScopes(["lixblogs:organizations:read"]);
|
|
106
|
+
return (
|
|
107
|
+
await this.request(
|
|
108
|
+
`/api/v1/orgs/${encodeURIComponent(id)}/collections`,
|
|
109
|
+
)
|
|
110
|
+
).payload.data;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async members(id) {
|
|
114
|
+
if (!id) throw new Error("An organization ID or handle is required.");
|
|
115
|
+
await this.requireScopes(["lixblogs:organizations:read"]);
|
|
116
|
+
return (
|
|
117
|
+
await this.request(`/api/v1/orgs/${encodeURIComponent(id)}/members`)
|
|
118
|
+
).payload.data;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async targets() {
|
|
122
|
+
await this.requireScopes(["lixblogs:organizations:read"]);
|
|
123
|
+
const orgsList = await this.list();
|
|
124
|
+
const orgs = orgsList?.data || [];
|
|
125
|
+
const writableOrgs = orgs.filter((org) => org.canWrite);
|
|
126
|
+
|
|
127
|
+
const orgTargets = await Promise.all(
|
|
128
|
+
writableOrgs.map(async (org) => {
|
|
129
|
+
let cols = [];
|
|
130
|
+
try {
|
|
131
|
+
cols = await this.collections(org.id);
|
|
132
|
+
} catch {
|
|
133
|
+
cols = [];
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
target: `org:${org.id}`,
|
|
137
|
+
orgId: org.id,
|
|
138
|
+
slug: org.slug,
|
|
139
|
+
name: org.name,
|
|
140
|
+
role: org.role,
|
|
141
|
+
collections: cols.map((col) => ({
|
|
142
|
+
id: col.id,
|
|
143
|
+
slug: col.slug,
|
|
144
|
+
name: col.name,
|
|
145
|
+
})),
|
|
146
|
+
};
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
personal: {
|
|
152
|
+
target: "personal",
|
|
153
|
+
name: "Personal Blog",
|
|
154
|
+
},
|
|
155
|
+
organizations: orgTargets,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -20,6 +20,17 @@ export class LoginRequiredError extends Error {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export class ApiContractUnavailableError extends Error {
|
|
24
|
+
constructor(status, contentType) {
|
|
25
|
+
super("The configured LixBlogs origin is not serving the API v1 JSON contract.");
|
|
26
|
+
this.name = "ApiContractUnavailableError";
|
|
27
|
+
this.code = "api_contract_unavailable";
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.details = { contentType: contentType || "unknown" };
|
|
30
|
+
this.hint = "Deploy the LixBlogs API v1 stack, or select an origin that exposes /api/v1.";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
export class AuthenticatedClient {
|
|
24
35
|
constructor({
|
|
25
36
|
provider,
|
|
@@ -99,6 +110,10 @@ export class AuthenticatedClient {
|
|
|
99
110
|
credentials = await this.credentials({ forceRefresh: true });
|
|
100
111
|
response = await send();
|
|
101
112
|
}
|
|
113
|
+
const contentType = response.headers.get("content-type") || "";
|
|
114
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
115
|
+
throw new ApiContractUnavailableError(response.status, contentType);
|
|
116
|
+
}
|
|
102
117
|
return response;
|
|
103
118
|
}
|
|
104
119
|
|
|
@@ -100,7 +100,7 @@ export class ElixpoAuthProvider extends AuthProvider {
|
|
|
100
100
|
accountsBaseUrl = "https://accounts.elixpo.com",
|
|
101
101
|
clientId = "lixblogs-cli-prod",
|
|
102
102
|
audience = "blogs.elixpo.com",
|
|
103
|
-
cliVersion = "1.
|
|
103
|
+
cliVersion = "1.2.0",
|
|
104
104
|
fetchImpl = globalThis.fetch,
|
|
105
105
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
106
106
|
} = {}) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const EXIT_CODES = Object.freeze({
|
|
2
|
+
OK: 0,
|
|
3
|
+
ERROR: 1,
|
|
4
|
+
USAGE: 2,
|
|
5
|
+
CONFLICT: 3,
|
|
6
|
+
AUTH: 4,
|
|
7
|
+
CONFIRMATION: 5,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const TOP_LEVEL_ALIASES = Object.freeze({
|
|
11
|
+
login: ['auth', 'login'],
|
|
12
|
+
logout: ['auth', 'logout'],
|
|
13
|
+
whoami: ['auth', 'whoami'],
|
|
14
|
+
profiles: ['auth', 'profiles'],
|
|
15
|
+
use: ['auth', 'use'],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function normalizeCommand(positionals) {
|
|
19
|
+
const [command, ...rest] = positionals;
|
|
20
|
+
const alias = TOP_LEVEL_ALIASES[command];
|
|
21
|
+
return alias ? [...alias, ...rest] : positionals;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function errorEnvelope(error, fallbackCode = 'cli_error') {
|
|
25
|
+
if (error && typeof error === 'object' && error.error && !Array.isArray(error.error)) return error;
|
|
26
|
+
const value = error && typeof error === 'object' ? error : { message: String(error || 'Command failed.') };
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: {
|
|
30
|
+
code: value.code || fallbackCode,
|
|
31
|
+
message: value.message || 'Command failed.',
|
|
32
|
+
hint: value.hint || null,
|
|
33
|
+
requestId: value.requestId || null,
|
|
34
|
+
...(value.details ? { details: value.details } : {}),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function requireConfirmation(options, action) {
|
|
40
|
+
if (options.yes) return;
|
|
41
|
+
const error = new Error(`${action} requires --yes in non-interactive operation.`);
|
|
42
|
+
error.code = 'confirmation_required';
|
|
43
|
+
error.hint = `Review the operation, then run it again with --yes.`;
|
|
44
|
+
error.exitCode = EXIT_CODES.CONFIRMATION;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
package/src/cli/ui.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const ANSI = Object.freeze({
|
|
2
|
+
reset: "\u001b[0m",
|
|
3
|
+
bold: "\u001b[1m",
|
|
4
|
+
dim: "\u001b[2m",
|
|
5
|
+
violet: "\u001b[38;5;141m",
|
|
6
|
+
green: "\u001b[38;5;42m",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export function colorEnabled(stream = process.stdout, env = process.env) {
|
|
10
|
+
return Boolean(stream.isTTY) && env.NO_COLOR === undefined && env.TERM !== "dumb";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function paint(value, code, enabled) {
|
|
14
|
+
return enabled ? `${code}${value}${ANSI.reset}` : value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loginChallenge({ url, code, expiresInSeconds, profile, interactive, color = false }) {
|
|
18
|
+
const title = `${paint("◆", ANSI.violet, color)} ${paint("LixBlogs", ANSI.bold, color)}`;
|
|
19
|
+
const instruction = interactive
|
|
20
|
+
? "Press Enter to open here, or use the URL on another device."
|
|
21
|
+
: "Open the URL in any browser and approve this device.";
|
|
22
|
+
return [
|
|
23
|
+
"",
|
|
24
|
+
` ${title}`,
|
|
25
|
+
` ${paint("Device login", ANSI.dim, color)}`,
|
|
26
|
+
" ─────────────────────────────────────────",
|
|
27
|
+
` URL ${url}`,
|
|
28
|
+
` Code ${paint(code, ANSI.bold, color)}`,
|
|
29
|
+
` Expires ${Math.ceil(expiresInSeconds / 60)} min`,
|
|
30
|
+
profile
|
|
31
|
+
? ` Profile ${profile} ${paint("(local credential slot)", ANSI.dim, color)}`
|
|
32
|
+
: ` Profile ${paint("your Accounts username after approval", ANSI.dim, color)}`,
|
|
33
|
+
"",
|
|
34
|
+
` ${instruction}`,
|
|
35
|
+
" No localhost callback or exposed port is required.",
|
|
36
|
+
"",
|
|
37
|
+
].join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function successLine(message, color = false) {
|
|
41
|
+
return ` ${paint("✓", ANSI.green, color)} ${message}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function listenForEnter({ input = process.stdin, open, url }) {
|
|
45
|
+
if (!input.isTTY || typeof open !== "function") return () => {};
|
|
46
|
+
const onData = () => { Promise.resolve(open(url)).catch(() => {}); };
|
|
47
|
+
input.setEncoding?.("utf8");
|
|
48
|
+
input.once("data", onData);
|
|
49
|
+
input.resume?.();
|
|
50
|
+
return () => {
|
|
51
|
+
input.off?.("data", onData);
|
|
52
|
+
input.pause?.();
|
|
53
|
+
};
|
|
54
|
+
}
|