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