@elixpo/lixblogs-cli 1.3.3 → 1.4.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/README.md +9 -7
- package/dist/lixblogs.mjs +102 -0
- package/package.json +9 -10
- package/API.md +0 -104
- package/CHANGELOG.md +0 -10
- package/RELEASE.md +0 -30
- package/THREAT_MODEL.md +0 -91
- package/bin/lixblogs.mjs +0 -802
- package/src/api/AnalyticsClient.js +0 -40
- package/src/api/BlogClient.js +0 -140
- package/src/api/CollaborationClient.js +0 -73
- package/src/api/OrgClient.js +0 -158
- package/src/auth/AuthProvider.js +0 -90
- package/src/auth/AuthenticatedClient.js +0 -131
- package/src/auth/ElixpoAuthProvider.js +0 -281
- package/src/auth/MockAuthProvider.js +0 -170
- package/src/auth/productionGate.js +0 -44
- package/src/cli/contract.js +0 -46
- package/src/cli/ui.js +0 -54
- package/src/commands/analytics/index.js +0 -57
- package/src/commands/auth/login.js +0 -117
- package/src/commands/auth/logout.js +0 -21
- package/src/commands/auth/profileAlias.js +0 -29
- package/src/commands/auth/profiles.js +0 -27
- package/src/commands/auth/revoke.js +0 -45
- package/src/commands/auth/status.js +0 -33
- package/src/commands/blog/index.js +0 -85
- package/src/commands/blog/input.js +0 -59
- package/src/commands/collab/index.js +0 -53
- package/src/commands/org/index.js +0 -22
- package/src/commands/skill/index.js +0 -83
- package/src/config/CredentialStore.js +0 -142
- package/src/config/KeychainCredentialStore.js +0 -180
- package/src/config/ProfileRegistry.js +0 -105
- package/src/config/config.js +0 -60
- package/src/config/credentialStoreFactory.js +0 -63
- package/src/config/providerFactory.js +0 -42
- package/src/config/redact.js +0 -74
- package/src/content/markdown.js +0 -68
- package/src/content/validate.js +0 -45
package/README.md
CHANGED
|
@@ -173,10 +173,11 @@ Global flags:
|
|
|
173
173
|
- The mock provider is available only with an explicit non-production
|
|
174
174
|
environment, for example `--env development --auth-provider mock`.
|
|
175
175
|
- The resource contract, scopes, pagination, errors, and mutation guarantees
|
|
176
|
-
are documented in [API
|
|
176
|
+
are documented in the [API reference](https://github.com/elixpo/blogs.elixpo/blob/main/packages/lixblogs-cli/API.md).
|
|
177
177
|
- Release compatibility, provenance, smoke gates, and rollback are documented
|
|
178
|
-
in [
|
|
179
|
-
|
|
178
|
+
in the [release policy](https://github.com/elixpo/blogs.elixpo/blob/main/packages/lixblogs-cli/RELEASE.md).
|
|
179
|
+
Contract changes are summarized in the
|
|
180
|
+
[changelog](https://github.com/elixpo/blogs.elixpo/blob/main/packages/lixblogs-cli/CHANGELOG.md).
|
|
180
181
|
|
|
181
182
|
The production client is public and has no client secret. Never add one to
|
|
182
183
|
CLI configuration, package files, or GitHub secrets.
|
|
@@ -210,14 +211,15 @@ npm test # runs the full CLI test suite
|
|
|
210
211
|
|
|
211
212
|
Tests exercise both a mocked auth provider and, where relevant, the real
|
|
212
213
|
OS keychain backend on whatever machine runs them — see
|
|
213
|
-
|
|
214
|
+
the [threat model](https://github.com/elixpo/blogs.elixpo/blob/main/packages/lixblogs-cli/THREAT_MODEL.md)
|
|
215
|
+
and inline comments in `src/config/KeychainCredentialStore.js`
|
|
214
216
|
for known platform-specific behavior (e.g. a documented WSL/keyring-rs quirk).
|
|
215
217
|
|
|
216
218
|
## Architecture
|
|
217
219
|
|
|
218
220
|
```
|
|
219
|
-
bin/lixblogs.mjs CLI entry point (Node's native util.parseArgs
|
|
220
|
-
|
|
221
|
+
bin/lixblogs.mjs Source CLI entry point (Node's native util.parseArgs)
|
|
222
|
+
dist/lixblogs.mjs Minified executable shipped in the npm package
|
|
221
223
|
src/auth/ Accounts provider, development mock, refresh-safe
|
|
222
224
|
authenticated client, and production safety gate
|
|
223
225
|
src/commands/auth/ Command logic (login, status, logout, revoke) —
|
|
@@ -228,7 +230,7 @@ src/content/ Dependency-free Markdown/block conversion
|
|
|
228
230
|
src/config/ Credential storage (real keychain + gated fallback),
|
|
229
231
|
profile registry, config resolution, token redaction
|
|
230
232
|
tests/ Full test suite
|
|
231
|
-
THREAT_MODEL.md Security threat model
|
|
233
|
+
THREAT_MODEL.md Security threat model (repository documentation)
|
|
232
234
|
```
|
|
233
235
|
|
|
234
236
|
Command logic under `src/commands/` is deliberately decoupled from the CLI
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{parseArgs as gr}from"node:util";import{spawn as wr}from"node:child_process";var F={environment:"production",profile:"default",accountsBaseUrl:"https://accounts.elixpo.com",apiBaseUrl:"https://blogs.elixpo.com"},Ee={development:{clientId:"lixblogs-cli-dev",audience:"localhost"},staging:{clientId:"lixblogs-cli-staging",audience:"staging.blogs.elixpo.com"},production:{clientId:"lixblogs-cli-prod",audience:"blogs.elixpo.com"},test:{clientId:"lixblogs-cli-dev",audience:"localhost"}};function _({flags:t={},env:e=process.env}={}){let r=t.env??e.LIXBLOGS_ENV??F.environment,o=t.profile??e.LIXBLOGS_PROFILE??F.profile,i=Ee[r]||Ee.production,n=t.authProvider??e.LIXBLOGS_AUTH_PROVIDER??(r==="production"?"elixpo":"mock"),a=t.accountsUrl??e.LIXBLOGS_ACCOUNTS_URL??F.accountsBaseUrl,s=t.apiUrl??e.LIXBLOGS_API_URL??F.apiBaseUrl,l=t.clientId??e.LIXBLOGS_CLIENT_ID??i.clientId,u=t.audience??e.LIXBLOGS_AUDIENCE??i.audience;return{environment:r,profile:o,profileExplicit:t.profile!==void 0||e.LIXBLOGS_PROFILE!==void 0,authProvider:n,accountsBaseUrl:a,apiBaseUrl:s,clientId:l,audience:u}}var P=class{get providerId(){throw new Error("AuthProvider.providerId must be implemented by subclass")}async requestDeviceCode(e){throw new Error("AuthProvider.requestDeviceCode must be implemented by subclass")}async pollDeviceCode(e){throw new Error("AuthProvider.pollDeviceCode must be implemented by subclass")}async refresh(e){throw new Error("AuthProvider.refresh must be implemented by subclass")}async revoke(e){throw new Error("AuthProvider.revoke must be implemented by subclass")}};var ke={APPROVE_IMMEDIATELY:"mock-approve-",PENDING_THEN_APPROVE:"mock-pending-then-approve-",DENY:"mock-deny-",EXPIRE:"mock-expire-",SLOW_DOWN_THEN_APPROVE:"mock-slow-down-then-approve-"},Lt=5,Se=0;function jt(t){return Se+=1,`${t}${Se}`}var z=class extends P{constructor(){super(),this._devicesCodes=new Map,this._revoked=new Set,this._refreshWillFail=new Set}get providerId(){return"mock"}async requestDeviceCode({scopes:e,scenario:r="APPROVE_IMMEDIATELY"}){let o=ke[r]??ke.APPROVE_IMMEDIATELY,i=jt(o),n=i.slice(-6).toUpperCase();return this._devicesCodes.set(i,{scenario:r,pollCount:0,createdAt:Date.now(),scopes:[...e]}),{deviceCode:i,userCode:n,verificationUri:"https://mock.lixblogs.local/device",verificationUriComplete:`https://mock.lixblogs.local/device?user_code=${encodeURIComponent(n)}`,expiresInSeconds:r==="EXPIRE"?1:600,pollIntervalSeconds:1}}async pollDeviceCode({deviceCode:e}){let r=this._devicesCodes.get(e);return r?r.scenario==="EXPIRE"?{status:"expired"}:r.scenario==="DENY"?{status:"denied"}:r.scenario==="PENDING_THEN_APPROVE"&&(r.pollCount+=1,r.pollCount<2)?{status:"pending"}:r.scenario==="SLOW_DOWN_THEN_APPROVE"&&(r.pollCount+=1,r.pollCount<2)?{status:"slow_down",pollIntervalIncreaseSeconds:Lt}:{status:"approved",token:{accessToken:`mock-access-${e}`,refreshToken:`mock-refresh-${e}`,expiresInSeconds:3600,scopes:r.scopes}}:{status:"denied"}}async refresh({refreshToken:e,scopes:r=[]}){if(this._revoked.has(e))throw new Error("refresh token has been revoked");if(this._refreshWillFail.has(e))throw new Error("mock refresh failure (test-injected)");return{accessToken:`mock-access-refreshed-${e}`,refreshToken:e,expiresInSeconds:3600,scopes:r}}async revoke({token:e}){this._revoked.add(e)}_simulateRefreshFailureFor(e){this._refreshWillFail.add(e)}};var Ae="urn:ietf:params:oauth:grant-type:device_code",Ut=1,Nt=15e3,Dt={access_denied:"Login was denied.",authorization_pending:"Login is awaiting approval.",expired_token:"The device authorization expired. Start login again.",invalid_client:"The LixBlogs CLI client is not registered for this environment.",invalid_grant:"This session is no longer valid. Log in again.",invalid_request:"Accounts rejected the authentication request.",invalid_scope:"The requested LixBlogs permissions are not available for this client.",server_error:"Accounts could not complete authentication. Try again later.",slow_down:"Accounts requested slower polling.",temporarily_unavailable:"Accounts is temporarily unavailable. Try again later."},v=class extends Error{constructor(e,{status:r=0,requiresLogin:o=!1}={}){super(Dt[e]||"Authentication failed."),this.name="AuthProviderError",this.code=e||"authentication_failed",this.status=r,this.requiresLogin=o}},I=class extends Error{constructor(e){super(e),this.name="CompatibilityError",this.code="incompatible_accounts_contract"}};function ae(t){return String(t||"0.0.0").split(".").slice(0,3).map(e=>Number.parseInt(e,10)||0)}function Bt(t,e){let r=ae(t),o=ae(e);for(let i=0;i<3;i+=1)if(r[i]!==o[i])return r[i]>o[i];return!0}function Mt(t){let e=new URL(t);if(e.pathname=e.pathname.replace(/\/$/,""),e.search="",e.hash="",e.protocol!=="https:"&&e.hostname!=="localhost"&&e.hostname!=="127.0.0.1")throw new I("Accounts must use HTTPS outside local development.");return e.toString().replace(/\/$/,"")}async function j(t){try{return await t.json()}catch{throw new v("server_error",{status:t.status})}}function G(t,e){let r=typeof t?.error=="string"?t.error:"server_error";return new v(r,{status:e.status,requiresLogin:r==="invalid_grant"||r==="access_denied"||r==="expired_token"})}function $e(t,e){if(!e.ok)throw G(t,e);if(typeof t?.access_token!="string"||typeof t?.refresh_token!="string"||!Number.isFinite(Number(t?.expires_in)))throw new v("server_error",{status:e.status});return{accessToken:t.access_token,refreshToken:t.refresh_token,expiresInSeconds:Number(t.expires_in),scopes:typeof t.scope=="string"?t.scope.split(/\s+/).filter(Boolean):[]}}var V=class extends P{constructor({accountsBaseUrl:e="https://accounts.elixpo.com",clientId:r="lixblogs-cli-prod",audience:o="blogs.elixpo.com",cliVersion:i="1.2.0",fetchImpl:n=globalThis.fetch,timeoutMs:a=Nt}={}){if(super(),typeof n!="function")throw new TypeError("A fetch implementation is required.");this.accountsBaseUrl=Mt(e),this.clientId=r,this.audience=o,this.cliVersion=i,this.fetchImpl=n,this.timeoutMs=a,this._metadata=null,this._discoveryPromise=null}get providerId(){return"elixpo"}async _fetch(e,r={}){let o=new AbortController,i=setTimeout(()=>o.abort(),this.timeoutMs);try{return await this.fetchImpl(e,{...r,signal:r.signal||o.signal,headers:{accept:"application/json",...r.headers}})}catch{throw new v("temporarily_unavailable")}finally{clearTimeout(i)}}async discover({scopes:e=[]}={}){if(!this._metadata){this._discoveryPromise||(this._discoveryPromise=this._loadDiscovery());try{this._metadata=await this._discoveryPromise}finally{this._discoveryPromise=null}}if(e.filter(o=>!this._metadata.scopes_supported.includes(o)).length)throw new v("invalid_scope");return this._metadata}async _loadDiscovery(){let e=await this._fetch(`${this.accountsBaseUrl}/.well-known/oauth-authorization-server`),r=await j(e);if(!e.ok)throw new v("temporarily_unavailable",{status:e.status});if(ae(r.elixpo_contract_version)[0]!==Ut)throw new I("Accounts uses an unsupported device-flow contract version.");if(!Bt(this.cliVersion,r.elixpo_min_compatible_cli_version))throw new I(`This CLI is too old for Accounts. Upgrade to version ${r.elixpo_min_compatible_cli_version} or newer.`);if(!Array.isArray(r.grant_types_supported)||!r.grant_types_supported.includes(Ae))throw new I("Accounts does not advertise OAuth device authorization.");let i=["device_authorization_endpoint","token_endpoint","revocation_endpoint"];for(let n of i){if(typeof r[n]!="string")throw new I(`Accounts discovery is missing ${n}.`);let a=new URL(r[n]),s=new URL(this.accountsBaseUrl);if(a.origin!==s.origin)throw new I(`Accounts discovery returned an untrusted ${n}.`)}return{...r,scopes_supported:Array.isArray(r.scopes_supported)?r.scopes_supported:[]}}async requestDeviceCode({scopes:e}){let r=await this.discover({scopes:e}),o=await this._fetch(r.device_authorization_endpoint,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({client_id:this.clientId,scope:e.join(" "),audience:this.audience})}),i=await j(o);if(!o.ok)throw G(i,o);if(typeof i.device_code!="string"||typeof i.user_code!="string"||typeof i.verification_uri!="string")throw new v("server_error",{status:o.status});return{deviceCode:i.device_code,userCode:i.user_code,verificationUri:i.verification_uri,verificationUriComplete:i.verification_uri_complete||i.verification_uri,expiresInSeconds:Number(i.expires_in)||600,pollIntervalSeconds:Number(i.interval)||5}}async pollDeviceCode({deviceCode:e}){let r=await this.discover(),o=new URLSearchParams({grant_type:Ae,device_code:e,client_id:this.clientId}),i=await this._fetch(r.token_endpoint,{method:"POST",headers:{"content-type":"application/x-www-form-urlencoded"},body:o}),n=await j(i);if(i.ok)return{status:"approved",token:$e(n,i)};if(n?.error==="authorization_pending")return{status:"pending"};if(n?.error==="slow_down"){let a=r.elixpo_device_flow_polling||{};return{status:"slow_down",pollIntervalIncreaseSeconds:Math.max(5,Number(a.slow_down_interval_seconds||10)-Number(a.interval_seconds||5))}}if(n?.error==="access_denied")return{status:"denied"};if(n?.error==="expired_token")return{status:"expired"};throw G(n,i)}async refresh({refreshToken:e,scopes:r}){let o=await this.discover({scopes:r||[]}),i=new URLSearchParams({grant_type:"refresh_token",refresh_token:e,client_id:this.clientId});r?.length&&i.set("scope",r.join(" "));let n=await this._fetch(o.token_endpoint,{method:"POST",headers:{"content-type":"application/x-www-form-urlencoded"},body:i}),a=await j(n);return $e(a,n)}async revoke({token:e}){let r=await this.discover(),o=await this._fetch(r.revocation_endpoint,{method:"POST",headers:{"content-type":"application/x-www-form-urlencoded"},body:new URLSearchParams({token:e,client_id:this.clientId})});if(!o.ok){let i=await j(o);throw G(i,o)}}};var Re="elixpo",le=class extends Error{constructor(e){super(e),this.name="ProductionAuthGateError"}};function Pe({providerId:t,environment:e}){if(e==="production"&&t!==Re)throw new le(`Provider "${t}" is not approved for production. Only "${Re}" may be used in production.`)}function U(t){if(t.authProvider==="mock"&&t.environment==="production")throw new Error("The mock auth provider cannot run in production.");if(t.authProvider!=="mock"&&t.authProvider!=="elixpo")throw new Error(`Unknown auth provider "${t.authProvider}".`);let e=t.authProvider==="mock"?new z:new V({accountsBaseUrl:t.accountsBaseUrl,clientId:t.clientId,audience:t.audience,cliVersion:t.cliVersion||"1.2.0",fetchImpl:t.fetchImpl});return Pe({providerId:e.providerId,environment:t.environment}),e}var E=class extends Error{constructor(e){super(e),this.name="CredentialStoreUnavailableError"}},A=class{async get(e){throw new Error("CredentialStore.get must be implemented by subclass")}async set(e,r){throw new Error("CredentialStore.set must be implemented by subclass")}async delete(e){throw new Error("CredentialStore.delete must be implemented by subclass")}async listProfiles(){throw new Error("CredentialStore.listProfiles must be implemented by subclass")}},J=class extends A{constructor(){super(),this._store=new Map}async get(e){return this._store.get(e)??null}async set(e,r){this._store.set(e,r)}async delete(e){this._store.delete(e)}async listProfiles(){return[...this._store.keys()]}},H=class extends A{constructor(e){super(),this._realStore=e}async get(e){try{return await this._realStore.get(e)}catch(r){throw new E(`OS keychain is unavailable: ${r.message}. Re-run with an explicit fallback flag if you want to opt in to a less secure storage method.`)}}async set(e,r){try{await this._realStore.set(e,r)}catch(o){throw new E(`OS keychain is unavailable: ${o.message}. Re-run with an explicit fallback flag if you want to opt in to a less secure storage method.`)}}async delete(e){try{await this._realStore.delete(e)}catch(r){throw new E(`OS keychain is unavailable: ${r.message}`)}}async listProfiles(){try{return await this._realStore.listProfiles()}catch(e){throw new E(`OS keychain is unavailable: ${e.message}`)}}};import{Entry as Ft}from"@napi-rs/keyring";var zt="lixblogs-cli",Gt="__lixblogs_availability_probe__";function K(t){return new Ft(zt,t)}async function Oe(){let t=K(Gt);try{return t.setPassword("probe"),t.deletePassword(),{available:!0}}catch(e){return{available:!1,error:String(e.message??e).split(`
|
|
3
|
+
`)[0].trim()}}}var ce=class extends A{async get(e){let r=K(e),o;try{o=r.getPassword()}catch(i){if(qe(i))return null;throw i}return o==null?null:JSON.parse(o)}async set(e,r){K(e).setPassword(JSON.stringify(r))}async delete(e){let r=K(e);try{r.deletePassword()}catch(o){if(qe(o))return;throw o}}async listProfiles(){throw new Error("KeychainCredentialStore.listProfiles is not supported directly \u2014 use ProfileRegistry to track known profile IDs, then look up each one via get().")}};function qe(t){let e=String(t?.message??"");return/no such|not found|nosuchkeyring|nosuchitem/i.test(e)}var W=class extends A{constructor(e){super(),this._keychain=new ce,this._registry=e}async get(e){return this._keychain.get(e)}async set(e,r){await this._keychain.set(e,r),await this._registry.add(e)}async delete(e){await this._keychain.delete(e),await this._registry.remove(e)}async listProfiles(){return this._registry.list()}};import{promises as X}from"node:fs";import Te from"node:path";import Vt from"node:os";function Jt(){return Te.join(Vt.homedir(),".config","lixblogs","profiles.json")}var g=class{constructor(e=Jt()){this._path=e}async list(){return(await this._read()).profiles}async getActive(){let e=await this._read();return e.activeProfile&&e.profiles.includes(e.activeProfile)?e.activeProfile:e.profiles[0]||null}async setActive(e){x(e);let r=await this._read();if(!r.profiles.includes(e))throw new Error(`Profile "${e}" does not exist. Log in with it first.`);await this._write(r.profiles,e)}async _read(){try{let e=await X.readFile(this._path,"utf8"),r=JSON.parse(e);return{profiles:Array.isArray(r.profiles)?r.profiles.filter(i=>typeof i=="string"):[],activeProfile:typeof r.activeProfile=="string"?r.activeProfile:null}}catch(e){if(e.code==="ENOENT")return{profiles:[],activeProfile:null};throw e}}async add(e){x(e);let r=await this._read(),o=new Set(r.profiles);o.add(e),await this._write([...o],r.activeProfile||e)}async remove(e){let r=await this._read(),o=r.profiles.filter(n=>n!==e),i=r.activeProfile===e?o[0]||null:r.activeProfile;await this._write(o,i)}async _write(e,r=null){await X.mkdir(Te.dirname(this._path),{recursive:!0});let o=`${this._path}.${process.pid}.tmp`;await X.writeFile(o,JSON.stringify({activeProfile:r,profiles:e},null,2),{encoding:"utf8",mode:384}),await X.rename(o,this._path)}};function x(t){if(!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(t||""))throw new Error("Profile names must be 1-64 characters using letters, numbers, dot, dash, or underscore.");return t}async function Ce({allowInsecureFallback:t=!1,profileRegistry:e}={}){let r=await Oe();if(!r.available){if(!t)throw new E(`OS keychain is unavailable: ${r.error}. Re-run with an explicit fallback flag if you want to opt in to a less secure storage method.`);return process.stderr.write(`warning: OS keychain unavailable (${r.error}); using in-memory fallback because --allow-insecure-fallback was passed. Credentials will NOT persist between CLI runs.
|
|
4
|
+
`),new J}let o=e||new g,i=new W(o);return new H(i)}var de="[REDACTED]",Ht=/token|refresh|secret|password|authorization/i,Kt=/^(mock-(access|refresh)-|Bearer\s+)\S+/i;function Le(t){return typeof t=="string"&&Kt.test(t)?de:t}function ue(t){if(Array.isArray(t))return t.map(e=>ue(e));if(t&&typeof t=="object"){let e={};for(let[r,o]of Object.entries(t))Ht.test(r)?e[r]=de:typeof o=="object"&&o!==null?e[r]=ue(o):e[r]=Le(o);return e}return Le(t)}function Y(t,e){return JSON.stringify(ue(t),null,e)}function q(t){return typeof t!="string"?t:t.replace(/(mock-(access|refresh)-\S+|Bearer\s+\S+)/gi,de)}async function je({provider:t,credentialStore:e,profileId:r,scopes:o,openBrowser:i,resolveProfileId:n,sleep:a=l=>new Promise(u=>setTimeout(u,l)),onStatus:s=()=>{}}){let l;try{l=await t.requestDeviceCode({scopes:o})}catch(c){return{ok:!1,reason:q(c.message)}}s({type:"verification_pending",verificationUri:l.verificationUri,verificationUriComplete:l.verificationUriComplete,userCode:l.userCode,expiresInSeconds:l.expiresInSeconds}),i&&await i(l.verificationUriComplete||l.verificationUri);let u=l.pollIntervalSeconds*1e3,y=Date.now()+l.expiresInSeconds*1e3;for(;Date.now()<y;){await a(u);let c;try{c=await t.pollDeviceCode({deviceCode:l.deviceCode})}catch(h){return{ok:!1,reason:q(h.message)}}if(c.status==="approved"){let h=r;if(n)try{h=await n({accessToken:c.token.accessToken,requestedProfileId:r})}catch(L){return{ok:!1,reason:q(L.message)}}return await e.set(h,{accessToken:c.token.accessToken,refreshToken:c.token.refreshToken,expiresAt:Date.now()+c.token.expiresInSeconds*1e3,scopes:c.token.scopes}),s({type:"approved"}),{ok:!0,profileId:h}}if(c.status==="denied")return s({type:"denied"}),{ok:!1,reason:"Login was denied."};if(c.status==="expired")return s({type:"expired"}),{ok:!1,reason:"Device code expired before login was approved."};if(c.status==="slow_down"){u+=c.pollIntervalIncreaseSeconds*1e3,s({type:"slow_down",newIntervalMs:u});continue}s({type:"pending"})}return{ok:!1,reason:"Device code expired before login was approved."}}async function Ue({credentialStore:t,profileId:e}){let r=e?[e]:await t.listProfiles(),o=[];for(let i of r){let n=await t.get(i);if(!n){o.push({profileId:i,loggedIn:!1});continue}o.push({profileId:i,loggedIn:!0,expired:Date.now()>=n.expiresAt,scopes:n.scopes})}return o}async function Ne({credentialStore:t,profileId:e}){return await t.delete(e),{ok:!0}}async function De({provider:t,credentialStore:e,profileId:r,confirmed:o}){if(o!==!0)return{ok:!1,reason:"Revoke was not confirmed. This is a destructive action and requires explicit confirmation (interactive prompt, or --yes in a non-interactive session)."};let i=await e.get(r);return i?(await t.revoke({token:i.refreshToken}),await e.delete(r),{ok:!0}):{ok:!1,reason:`No stored credentials for profile "${r}".`}}async function Be({credentialStore:t,profileRegistry:e}){let r=await e.getActive(),o=await t.listProfiles(),i=[];for(let n of o){let a=await t.get(n);i.push({profileId:n,active:n===r,loggedIn:!!a,expired:a?Date.now()>=a.expiresAt:void 0,scopes:a?.scopes||[]})}return{activeProfile:r,profiles:i}}async function Me({credentialStore:t,profileRegistry:e,profileId:r}){return await t.get(r)?(await e.setActive(r),{ok:!0,profileId:r}):{ok:!1,reason:`Profile "${r}" is not logged in.`}}async function Fe({accessToken:t,apiBaseUrl:e,fetchImpl:r=globalThis.fetch}){let o=new URL("/api/v1/me",e),i=await r(o,{headers:{accept:"application/json",authorization:`Bearer ${t}`}}),n;try{n=await i.json()}catch{throw new Error("LixBlogs could not resolve the signed-in username.")}if(!i.ok||typeof n?.data?.username!="string")throw new Error(n?.error?.message||"LixBlogs could not resolve the signed-in username.");return x(n.data.username)}var Wt=6e4,ze=new WeakMap;function Xt(t){let e=ze.get(t);return e||(e=new Map,ze.set(t,e)),e}var Z=class extends Error{constructor(e){super(`Profile "${e}" needs to log in again.`),this.name="LoginRequiredError",this.code="login_required"}},pe=class extends Error{constructor(e,r){super("The configured LixBlogs origin is not serving the API v1 JSON contract."),this.name="ApiContractUnavailableError",this.code="api_contract_unavailable",this.status=e,this.details={contentType:r||"unknown"},this.hint="Deploy the LixBlogs API v1 stack, or select an origin that exposes /api/v1."}},N=class{constructor({provider:e,credentialStore:r,profileId:o,apiBaseUrl:i="https://blogs.elixpo.com",fetchImpl:n=globalThis.fetch,refreshSkewMs:a=Wt}){this.provider=e,this.credentialStore=r,this.profileId=o,this.apiBaseUrl=new URL(i),this.fetchImpl=n,this.refreshSkewMs=a}async _refresh(e,{force:r=!1}={}){let o=Xt(this.credentialStore),i=o.get(this.profileId);if(i)return i;let n=(async()=>{let a=await this.credentialStore.get(this.profileId)||e;if(!r&&a.expiresAt-Date.now()>this.refreshSkewMs)return a;try{let s=await this.provider.refresh({refreshToken:a.refreshToken,scopes:a.scopes}),l={accessToken:s.accessToken,refreshToken:s.refreshToken,expiresAt:Date.now()+s.expiresInSeconds*1e3,scopes:s.scopes};return await this.credentialStore.set(this.profileId,l),l}catch(s){throw s instanceof v&&s.requiresLogin?(await this.credentialStore.delete(this.profileId),new Z(this.profileId)):s}})();o.set(this.profileId,n);try{return await n}finally{o.get(this.profileId)===n&&o.delete(this.profileId)}}async credentials({forceRefresh:e=!1}={}){let r=await this.credentialStore.get(this.profileId);if(!r)throw new Z(this.profileId);return e||r.expiresAt-Date.now()<=this.refreshSkewMs?this._refresh(r,{force:e}):r}async request(e,r={}){let o=new URL(e,this.apiBaseUrl);if(o.origin!==this.apiBaseUrl.origin||!o.pathname.startsWith("/api/v1/"))throw new Error("Authenticated CLI requests are restricted to the configured LixBlogs /api/v1 resource server.");let i=await this.credentials(),n=()=>this.fetchImpl(o.toString(),{...r,headers:{...r.headers,authorization:`Bearer ${i.accessToken}`}}),a=await n();a.status===401&&(i=await this.credentials({forceRefresh:!0}),a=await n());let s=a.headers.get("content-type")||"";if(!s.toLowerCase().includes("application/json"))throw new pe(a.status,s);return a}async requireScopes(e){let r=await this.credentials(),o=e.filter(i=>!r.scopes.includes(i));if(o.length){let i=new Error(`Login again with the required scope${o.length>1?"s":""}: ${o.join(", ")}`);throw i.name="InsufficientScopeError",i.code="insufficient_scope",i.missingScopes=o,i}}};import{randomUUID as Ge}from"node:crypto";var d=class extends Error{constructor(e,r,{status:o,requestId:i,details:n}={}){super(r),this.name="BlogApiError",this.code=e||"api_error",this.status=o||0,this.requestId=i||null,this.details=n||null}};async function Yt(t){let e;try{e=await t.json()}catch{e=null}if(!t.ok||e?.error)throw new d(e?.error?.code||`http_${t.status}`,e?.error?.message||`LixBlogs returned HTTP ${t.status}.`,{status:t.status,requestId:e?.error?.requestId||t.headers.get("x-request-id"),details:e?.error?.details});return{payload:e,etag:t.headers.get("etag")}}var D=class{constructor(e,{sleep:r=o=>new Promise(i=>setTimeout(i,o))}={}){this.http=e,this.sleep=r}async request(e,r={}){let o={...r,headers:{accept:"application/json",...r.body?{"content-type":"application/json"}:{},...r.headers}},n=(o.method||"GET")==="GET"||!!o.headers["idempotency-key"];for(let a=0;a<2;a+=1)try{let s=await this.http.request(e,o);if(n&&a===0&&(s.status===429||s.status>=500)){let l=Math.min(2,Number.parseInt(s.headers.get("retry-after")||"1",10)||1);await this.sleep(l*1e3);continue}return Yt(s)}catch(s){if(!n||a>0||s instanceof d||s?.code)throw s;await this.sleep(250)}throw new d("request_failed","The LixBlogs request failed after retrying.")}async requireScopes(e){typeof this.http.requireScopes=="function"&&await this.http.requireScopes(e)}async whoami(){return await this.requireScopes(["lixblogs:profile:read"]),(await this.request("/api/v1/me")).payload.data}async list({status:e="all",limit:r=20,cursor:o}={}){await this.requireScopes(["lixblogs:blog:read"]);let i=new URLSearchParams({status:e,limit:String(r)});return o&&i.set("cursor",o),(await this.request(`/api/v1/blogs?${i}`)).payload}async get(e){await this.requireScopes(["lixblogs:blog:read"]);let r=await this.request(`/api/v1/blogs/${encodeURIComponent(e)}`);return{...r.payload.data,etag:r.etag||r.payload.data.etag}}async create(e,{idempotencyKey:r=Ge()}={}){return await this.requireScopes(["lixblogs:blog:write"]),(await this.request("/api/v1/blogs",{method:"POST",headers:{"idempotency-key":r},body:JSON.stringify(e)})).payload.data}async update(e,r,{etag:o}){return await this.requireScopes(["lixblogs:blog:write"]),(await this.request(`/api/v1/blogs/${encodeURIComponent(e)}`,{method:"PATCH",headers:{"if-match":o},body:JSON.stringify(r)})).payload.data}async publish(e,{etag:r,idempotencyKey:o=Ge()}){return await this.requireScopes(["lixblogs:blog:publish"]),(await this.request(`/api/v1/blogs/${encodeURIComponent(e)}/publish`,{method:"POST",headers:{"if-match":r,"idempotency-key":o}})).payload.data}async unpublish(e,{etag:r}){return await this.requireScopes(["lixblogs:blog:publish"]),(await this.request(`/api/v1/blogs/${encodeURIComponent(e)}/unpublish`,{method:"POST",headers:{"if-match":r}})).payload.data}async delete(e,{etag:r,permanent:o=!1}){return await this.requireScopes(["lixblogs:blog:delete",...o?["lixblogs:blog:delete:permanent"]:[]]),(await this.request(`/api/v1/blogs/${encodeURIComponent(e)}${o?"?permanent=true":""}`,{method:"DELETE",headers:{"if-match":r,...o?{"x-confirm-permanent-delete":e}:{}}})).payload.data}async restore(e,{etag:r}){return await this.requireScopes(["lixblogs:blog:delete"]),(await this.request(`/api/v1/blogs/${encodeURIComponent(e)}/restore`,{method:"POST",headers:{"if-match":r}})).payload.data}};async function Zt(t){let e;try{e=await t.json()}catch{e=null}if(!t.ok||e?.error)throw new d(e?.error?.code||`http_${t.status}`,e?.error?.message||`LixBlogs returned HTTP ${t.status}.`,{status:t.status,requestId:e?.error?.requestId||t.headers.get("x-request-id"),details:e?.error?.details});return{payload:e,etag:t.headers.get("etag")}}var Q=class{constructor(e,{sleep:r=o=>new Promise(i=>setTimeout(i,o))}={}){this.http=e,this.sleep=r}async request(e,r={}){let o={...r,headers:{accept:"application/json",...r.body?{"content-type":"application/json"}:{},...r.headers}},n=(o.method||"GET")==="GET";for(let a=0;a<2;a+=1)try{let s=await this.http.request(e,o);if(n&&a===0&&(s.status===429||s.status>=500)){let l=Math.min(2,Number.parseInt(s.headers.get("retry-after")||"1",10)||1);await this.sleep(l*1e3);continue}return Zt(s)}catch(s){if(!n||a>0||s instanceof d||s?.code)throw s;await this.sleep(250)}throw new d("request_failed","The LixBlogs request failed after retrying.")}async requireScopes(e){typeof this.http.requireScopes=="function"&&await this.http.requireScopes(e)}async list(){return await this.requireScopes(["lixblogs:organizations:read"]),(await this.request("/api/v1/orgs")).payload}async get(e){if(!e)throw new Error("An organization ID or handle is required.");return await this.requireScopes(["lixblogs:organizations:read"]),(await this.request(`/api/v1/orgs/${encodeURIComponent(e)}`)).payload.data}async collections(e){if(!e)throw new Error("An organization ID or handle is required.");return await this.requireScopes(["lixblogs:organizations:read"]),(await this.request(`/api/v1/orgs/${encodeURIComponent(e)}/collections`)).payload.data}async members(e){if(!e)throw new Error("An organization ID or handle is required.");return await this.requireScopes(["lixblogs:organizations:read"]),(await this.request(`/api/v1/orgs/${encodeURIComponent(e)}/members`)).payload.data}async targets(){await this.requireScopes(["lixblogs:organizations:read"]);let o=((await this.list())?.data||[]).filter(n=>n.canWrite),i=await Promise.all(o.map(async n=>{let a=[];try{a=await this.collections(n.id)}catch{a=[]}return{target:`org:${n.id}`,orgId:n.id,slug:n.slug,name:n.name,role:n.role,collections:a.map(s=>({id:s.id,slug:s.slug,name:s.name}))}}));return{personal:{target:"personal",name:"Personal Blog"},organizations:i}}};import{randomUUID as ee}from"node:crypto";async function Qt(t){let e;try{e=await t.json()}catch{e=null}if(!t.ok||e?.error)throw new d(e?.error?.code||`http_${t.status}`,e?.error?.message||`LixBlogs returned HTTP ${t.status}.`,{status:t.status,requestId:e?.error?.requestId||t.headers.get("x-request-id"),details:e?.error?.details});return e.data}var te=class{constructor(e){this.http=e}async request(e,r={}){let o=await this.http.request(e,{...r,headers:{accept:"application/json",...r.body?{"content-type":"application/json"}:{},...r.headers}});return Qt(o)}async list(e){return await this.http.requireScopes(["lixblogs:collaboration:read"]),this.request(`/api/v1/blogs/${encodeURIComponent(e)}/collaborators`)}async invitations(){return await this.http.requireScopes(["lixblogs:collaboration:read"]),this.request("/api/v1/collaboration/invitations")}async invite(e,{user:r,role:o,idempotencyKey:i=ee()}){return await this.http.requireScopes(["lixblogs:collaboration:write"]),this.request(`/api/v1/blogs/${encodeURIComponent(e)}/collaborators`,{method:"POST",headers:{"idempotency-key":i},body:JSON.stringify({user:r,role:o})})}async role(e,{user:r,role:o,idempotencyKey:i=ee()}){return await this.http.requireScopes(["lixblogs:collaboration:write"]),this.request(`/api/v1/blogs/${encodeURIComponent(e)}/collaborators`,{method:"PATCH",headers:{"idempotency-key":i},body:JSON.stringify({user:r,role:o})})}async remove(e,{user:r,idempotencyKey:o=ee()}={}){return await this.http.requireScopes(["lixblogs:collaboration:write"]),this.request(`/api/v1/blogs/${encodeURIComponent(e)}/collaborators`,{method:"DELETE",headers:{"idempotency-key":o},body:JSON.stringify({...r?{user:r}:{}})})}async resolveInvitation(e,{action:r,showOnProfile:o=!0,idempotencyKey:i=ee()}){return await this.http.requireScopes(["lixblogs:collaboration:write"]),this.request("/api/v1/collaboration/invitations",{method:"POST",headers:{"idempotency-key":i},body:JSON.stringify({blogId:e,action:r,showOnProfile:o})})}};async function er(t){let e;try{e=await t.json()}catch{e=null}if(!t.ok||e?.error)throw new d(e?.error?.code||`http_${t.status}`,e?.error?.message||`LixBlogs returned HTTP ${t.status}.`,{status:t.status,requestId:e?.error?.requestId||t.headers.get("x-request-id"),details:e?.error?.details});return e}var re=class{constructor(e){this.http=e}async query(e={}){let r=e.scope||"personal";await this.http.requireScopes(["lixblogs:analytics:read",...r.startsWith("org:")?["lixblogs:organizations:read"]:[]]);let o=new URLSearchParams({scope:r,range:e.range||(e.from||e.to?"custom":"30d"),dimension:e.dimension||"overview",limit:String(e.limit||20)});return e.from&&o.set("from",e.from),e.to&&o.set("to",e.to),e.cursor&&o.set("cursor",e.cursor),er(await this.http.request(`/api/v1/analytics?${o}`,{headers:{accept:"application/json"}}))}};var p=Object.freeze({OK:0,ERROR:1,USAGE:2,CONFLICT:3,AUTH:4,CONFIRMATION:5}),tr=Object.freeze({login:["auth","login"],logout:["auth","logout"],whoami:["auth","whoami"],profiles:["auth","profiles"],use:["auth","use"]});function Ve(t){let[e,...r]=t,o=tr[e];return o?[...o,...r]:t}function Je(t,e="cli_error"){if(t&&typeof t=="object"&&t.error&&!Array.isArray(t.error))return t;let r=t&&typeof t=="object"?t:{message:String(t||"Command failed.")};return{ok:!1,error:{code:r.code||e,message:r.message||"Command failed.",hint:r.hint||null,requestId:r.requestId||null,...r.details?{details:r.details}:{}}}}function w(t,e){if(t.yes)return;let r=new Error(`${e} requires --yes in non-interactive operation.`);throw r.code="confirmation_required",r.hint="Review the operation, then run it again with --yes.",r.exitCode=p.CONFIRMATION,r}var k=Object.freeze({reset:"\x1B[0m",bold:"\x1B[1m",dim:"\x1B[2m",violet:"\x1B[38;5;141m",green:"\x1B[38;5;42m"});function fe(t=process.stdout,e=process.env){return!!t.isTTY&&e.NO_COLOR===void 0&&e.TERM!=="dumb"}function $(t,e,r){return r?`${e}${t}${k.reset}`:t}function He({url:t,code:e,expiresInSeconds:r,profile:o,interactive:i,color:n=!1}){let a=`${$("\u25C6",k.violet,n)} ${$("LixBlogs",k.bold,n)}`,s=i?"Press Enter to open here, or use the URL on another device.":"Open the URL in any browser and approve this device.";return["",` ${a}`,` ${$("Device login",k.dim,n)}`," \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",` URL ${t}`,` Code ${$(e,k.bold,n)}`,` Expires ${Math.ceil(r/60)} min`,o?` Profile ${o} ${$("(local credential slot)",k.dim,n)}`:` Profile ${$("your Accounts username after approval",k.dim,n)}`,"",` ${s}`," No localhost callback or exposed port is required.",""].join(`
|
|
5
|
+
`)}function Ke(t,e=!1){return` ${$("\u2713",k.green,e)} ${t}`}function We({input:t=process.stdin,open:e,url:r}){if(!t.isTTY||typeof e!="function")return()=>{};let o=()=>{Promise.resolve(e(r)).catch(()=>{})};return t.setEncoding?.("utf8"),t.once("data",o),t.resume?.(),()=>{t.off?.("data",o),t.pause?.()}}function O(t){return[{type:"text",text:t}]}function Xe(t){let e=String(t||"").replace(/\r\n/g,`
|
|
6
|
+
`).split(`
|
|
7
|
+
`),r=[],o=[],i=()=>{o.length&&(r.push({type:"paragraph",content:O(o.join(" ").trim())}),o=[])};for(let n=0;n<e.length;n+=1){let s=e[n].trim();if(!s){i();continue}let l=s.match(/^```([\w+-]*)/);if(l){i();let se=[];for(n+=1;n<e.length&&!/^```/.test(e[n].trim());)se.push(e[n++]);r.push(l[1].toLowerCase()==="mermaid"?{type:"mermaidBlock",props:{diagram:se.join(`
|
|
8
|
+
`)}}:{type:"codeBlock",props:{language:l[1].toLowerCase()},content:O(se.join(`
|
|
9
|
+
`))});continue}let u=s.match(/^(#{1,3})\s+(.+)/);if(u){i(),r.push({type:"heading",props:{level:String(u[1].length)},content:O(u[2])});continue}let y=s.match(/^[-*]\s+(.+)/);if(y){i(),r.push({type:"bulletListItem",content:O(y[1])});continue}let c=s.match(/^\d+\.\s+(.+)/);if(c){i(),r.push({type:"numberedListItem",content:O(c[1])});continue}let h=s.match(/^>\s?(.*)/);if(h){i(),r.push({type:"quote",content:O(h[1])});continue}let L=s.match(/^!\[([^\]]*)\]\((https:\/\/[^)]+)\)$/);if(L){i(),r.push({type:"image",props:{url:L[2],caption:L[1]}});continue}if(/^([-*_])\1{2,}$/.test(s)){i(),r.push({type:"divider"});continue}o.push(s)}return i(),r}function rr(t){return(t?.content||[]).map(e=>typeof e=="string"?e:e?.text||"").join("")}function oe(t){return(t||[]).map(e=>{let r=rr(e);return e.type==="heading"?`${"#".repeat(Number(e.props?.level)||1)} ${r}`:e.type==="bulletListItem"?`- ${r}`:e.type==="numberedListItem"?`1. ${r}`:e.type==="quote"?`> ${r}`:e.type==="codeBlock"?`\`\`\`${e.props?.language||""}
|
|
10
|
+
${r}
|
|
11
|
+
\`\`\``:e.type==="mermaidBlock"?`\`\`\`mermaid
|
|
12
|
+
${e.props?.diagram||""}
|
|
13
|
+
\`\`\``:e.type==="image"?``:e.type==="divider"?"---":r}).join(`
|
|
14
|
+
|
|
15
|
+
`)}import{promises as B}from"node:fs";import{tmpdir as or}from"node:os";import he from"node:path";import{spawn as ir}from"node:child_process";async function nr(t){let e="";t.setEncoding("utf8");for await(let r of t)e+=r;return e}async function sr(t="",e=process.env.EDITOR||process.env.VISUAL){if(!e)throw new Error("$EDITOR or $VISUAL must be set when using --editor.");let r=await B.mkdtemp(he.join(or(),"lixblogs-")),o=he.join(r,"post.md");await B.writeFile(o,t,{mode:384});try{return await new Promise((i,n)=>{let a=ir(e,[o],{stdio:"inherit",shell:!0});a.once("error",n),a.once("exit",s=>s===0?i():n(new Error(`Editor exited with code ${s}.`)))}),await B.readFile(o,"utf8")}finally{await B.rm(r,{recursive:!0,force:!0})}}async function me(t,{stdin:e=process.stdin,initial:r=""}={}){let o=[t.file!==void 0,t.stdin,t.content!==void 0,t.editor].filter(Boolean).length;if(o>1)throw new Error("Use only one of --file, --stdin, --content, or --editor.");if(!o)return null;let i;return t.file!==void 0?i=await B.readFile(he.resolve(t.file),"utf8"):t.stdin?i=await nr(e):t.content!==void 0?i=t.content:i=await sr(r),{markdown:i,blocks:Xe(i)}}function ge(t){let e={},r={title:"title",subtitle:"subtitle",slug:"slug",emoji:"emoji",publication:"publishedAs",collection:"collectionId",cover:"coverUrl"};for(let[o,i]of Object.entries(r))t[o]!==void 0&&(e[i]=t[o]);return t.tag!==void 0&&(e.tags=t.tag),t["member-only"]&&(e.memberOnly=!0),t["no-member-only"]&&(e.memberOnly=!1),t.secret&&(e.secret=!0),t["not-secret"]&&(e.secret=!1),e}function ar(t){let e=[],r=o=>{for(let i of o||[]){for(let n of i?.content||[]){let a=typeof n=="string"?n:n?.text||"";e.push(...a.trim().split(/\s+/).filter(Boolean))}i?.children&&r(i.children)}};return r(t),e.length}function ie(t,{publishing:e=!1}={}){if(t.title!==void 0&&(typeof t.title!="string"||t.title.length>300))throw new Error("Title must be 300 characters or fewer.");if(t.subtitle!==void 0&&(typeof t.subtitle!="string"||t.subtitle.length>500))throw new Error("Subtitle must be 500 characters or fewer.");if(t.tags!==void 0&&(!Array.isArray(t.tags)||t.tags.length>5))throw new Error("Use at most five tags.");if(t.coverUrl&&!/^https:\/\//i.test(t.coverUrl))throw new Error("Cover URLs must use HTTPS.");if(t.publishedAs&&t.publishedAs!=="personal"&&!/^org:[^:]+$/.test(t.publishedAs))throw new Error("Publication must be personal or org:<id>.");if(t.content!==void 0){if(!Array.isArray(t.content))throw new Error("Blog content must be a block array.");if(Buffer.byteLength(JSON.stringify(t.content),"utf8")>15e5)throw new Error("Blog content exceeds the 1.5 MB limit.")}if(e){if(!t.title?.trim())throw new Error("A title is required before publishing.");if(ar(t.content)<20)throw new Error("A post needs at least 20 words before publishing.")}return t}import{promises as we}from"node:fs";import ye from"node:path";async function Ye({client:t,options:e}){return t.list({status:e.status,limit:e.limit,cursor:e.cursor})}async function be({client:t,id:e}){if(!e)throw new Error("A blog ID is required.");let r=await t.get(e);return{...r,markdown:oe(r.content)}}async function Ze({client:t,options:e,stdin:r}){let o=await me(e,{stdin:r}),i={...ge(e),content:o?.blocks||[]};return ie(i),e["dry-run"]?{dryRun:!0,input:i,markdown:o?.markdown||""}:t.create(i,{idempotencyKey:e["idempotency-key"]})}async function Qe({client:t,id:e,options:r,stdin:o}){if(!e)throw new Error("A blog ID is required.");let i=await t.get(e),n=await me(r,{stdin:o,initial:oe(i.content)}),a={...ge(r),...n?{content:n.blocks}:{}};if(!Object.keys(a).length)throw new Error("No blog changes were provided.");if(ie(a),r["dry-run"])return{dryRun:!0,id:e,etag:i.etag,input:a,markdown:n?.markdown};try{return await t.update(e,a,{etag:r.etag||i.etag})}catch(s){if(!(s instanceof d)||s.code!=="revision_conflict")throw s;let l=await t.get(e),u=r.conflictDirectory||ye.resolve(".lixblogs-conflicts");await we.mkdir(u,{recursive:!0});let y=e.replace(/[^A-Za-z0-9._-]/g,"_"),c=ye.join(u,`${y}-local.json`),h=ye.join(u,`${y}-server.md`);throw await Promise.all([we.writeFile(c,JSON.stringify(a,null,2),{mode:384}),we.writeFile(h,oe(l.content),{mode:384})]),s.details={...s.details,localPath:c,serverPath:h,serverEtag:l.etag},s}}async function et({client:t,id:e,options:r}){if(!e)throw new Error("A blog ID is required.");let o=await t.get(e);return ie(o,{publishing:!0}),r["dry-run"]?{dryRun:!0,id:e,from:o.status,to:"published"}:(w(r,"Publishing this blog"),t.publish(e,{etag:r.etag||o.etag,idempotencyKey:r["idempotency-key"]}))}async function tt({client:t,id:e,options:r}){if(!e)throw new Error("A blog ID is required.");let o=await t.get(e);return r["dry-run"]?{dryRun:!0,id:e,from:o.status,to:"draft"}:(w(r,"Unpublishing this blog"),t.unpublish(e,{etag:r.etag||o.etag}))}async function ve({client:t,id:e,options:r}){if(!e)throw new Error("A blog ID is required.");if(!r.yes)throw new Error("Deletion requires --yes. Trash is the default; add --permanent for irreversible deletion.");let o=await t.get(e);return r["dry-run"]?{dryRun:!0,id:e,permanent:r.permanent}:t.delete(e,{etag:r.etag||o.etag,permanent:r.permanent})}async function rt({client:t,id:e,options:r}){if(!e)throw new Error("A blog ID is required.");let o=await t.get(e);return r["dry-run"]?{dryRun:!0,id:e,restoreTo:o.preDeleteStatus||"draft"}:(w(r,"Restoring this blog"),t.restore(e,{etag:r.etag||o.etag}))}async function ot({client:t}){return t.list()}async function it({client:t,id:e}){if(!e)throw new Error("An organization ID or handle is required.");return t.get(e)}async function nt({client:t,id:e}){if(!e)throw new Error("An organization ID or handle is required.");return t.collections(e)}async function st({client:t,id:e}){if(!e)throw new Error("An organization ID or handle is required.");return t.members(e)}async function at({client:t}){return t.targets()}function T(t){if(!t)throw new Error("A blog ID is required.")}async function lt({client:t,id:e}){return T(e),t.list(e)}async function ct({client:t}){return t.invitations()}async function ut({client:t,id:e,options:r}){if(T(e),!r.user)throw new Error("--user is required.");if(!["viewer","editor","admin"].includes(r.role))throw new Error("--role must be viewer, editor, or admin.");return r["dry-run"]?{dryRun:!0,action:"invite",blogId:e,user:r.user,role:r.role}:(w(r,"Inviting this collaborator"),t.invite(e,{user:r.user,role:r.role,idempotencyKey:r["idempotency-key"]}))}async function dt({client:t,id:e,options:r}){if(T(e),!r.user)throw new Error("--user is required.");if(!["viewer","editor","admin"].includes(r.role))throw new Error("--role must be viewer, editor, or admin.");return r["dry-run"]?{dryRun:!0,action:"role",blogId:e,user:r.user,role:r.role}:(w(r,"Changing this collaborator role"),t.role(e,{user:r.user,role:r.role,idempotencyKey:r["idempotency-key"]}))}async function pt({client:t,id:e,options:r}){return T(e),r["dry-run"]?{dryRun:!0,action:"remove",blogId:e,user:r.user||"self"}:(w(r,"Removing this collaborator or invitation"),t.remove(e,{user:r.user,idempotencyKey:r["idempotency-key"]}))}async function ft({client:t,id:e,options:r}){return T(e),r["dry-run"]?{dryRun:!0,action:"accept",blogId:e,showOnProfile:!r["hide-on-profile"]}:(w(r,"Accepting this collaboration invitation"),t.resolveInvitation(e,{action:"accept",showOnProfile:!r["hide-on-profile"],idempotencyKey:r["idempotency-key"]}))}async function ht({client:t,id:e,options:r}){return T(e),r["dry-run"]?{dryRun:!0,action:"decline",blogId:e}:(w(r,"Declining this collaboration invitation"),t.resolveInvitation(e,{action:"decline",idempotencyKey:r["idempotency-key"]}))}import{access as lr,cp as cr,readFile as ur,readdir as dr}from"node:fs/promises";import b from"node:path";import{fileURLToPath as pr}from"node:url";var xe=b.dirname(pr(import.meta.url)),wt=b.basename(xe)==="dist"?b.resolve(xe,".."):b.resolve(xe,"../../.."),mt=b.join(wt,"skills"),gt=b.resolve(wt,"../..",".agents","skills");async function C(t){try{return await lr(t),!0}catch{return!1}}async function _e(){if(await C(mt))return mt;if(await C(gt))return gt;let t=new Error("No bundled LixBlogs skills were found. Reinstall @elixpo/lixblogs-cli.");throw t.code="skills_unavailable",t}function yt(t){if(!/^lixblogs-[a-z0-9-]+$/.test(t||"")){let e=new Error("A valid lixblogs-* skill name is required.");throw e.code="invalid_skill_name",e}return t}async function bt(t,e){let r=await ur(b.join(t,e,"SKILL.md"),"utf8"),o=r.match(/^description:\s*(.+)$/m)?.[1]||r.match(/^description:\s*>-\s*\n\s*(.+)$/m)?.[1]||"",i=r.match(/`@elixpo\/lixblogs-cli`\s+([0-9.]+)/)?.[1]||null;return{name:e,description:o.trim(),minimumCliVersion:i,content:r}}async function vt(){let t=await _e(),e=await dr(t,{withFileTypes:!0});return Promise.all(e.filter(r=>r.isDirectory()&&r.name.startsWith("lixblogs-")).map(r=>bt(t,r.name))).then(r=>r.map(({content:o,...i})=>i).sort((o,i)=>o.name.localeCompare(i.name)))}async function xt({name:t}){let e=await _e(),r=yt(t);if(!await C(b.join(e,r,"SKILL.md"))){let o=new Error(`Skill "${r}" is not bundled.`);throw o.code="skill_not_found",o}return bt(e,r)}async function _t({name:t,options:e}){let r=await _e(),o=yt(t),i=b.join(r,o);if(!await C(b.join(i,"SKILL.md"))){let s=new Error(`Skill "${o}" is not bundled.`);throw s.code="skill_not_found",s}let n=b.resolve(e.target||".agents/skills"),a=b.join(n,o);if(e["dry-run"])return{dryRun:!0,name:o,target:a,replace:await C(a)};if(await C(a)){if(!e.force){let s=new Error(`Skill already exists at ${a}.`);throw s.code="skill_exists",s.hint="Inspect the existing skill or re-run with --force --yes to replace it.",s}w(e,`Replacing ${a}`)}else w(e,`Installing ${o} into ${n}`);return await cr(i,a,{recursive:!0,force:!!e.force}),{installed:!0,name:o,target:a}}import{writeFile as fr}from"node:fs/promises";var hr=new Set(["overview","timeline","posts","sources","devices","countries"]),mr=new Set(["7d","30d","90d","12m","custom"]);function kt(t={}){let e=t.dimension||"overview",r=t.range||(t.from||t.to?"custom":"30d");if(!hr.has(e))throw new Error(`Unsupported analytics dimension: ${e}.`);if(!mr.has(r))throw new Error(`Unsupported analytics range: ${r}.`);if(r==="custom"&&(!t.from||!t.to))throw new Error("Custom analytics ranges require --from and --to.");return{scope:t.scope?.[0]||t.publication||"personal",range:r,from:t.from,to:t.to,dimension:e,limit:t.limit,cursor:t.cursor}}async function St({client:t,options:e}){return t.query(kt(e))}function It(t){let e=t==null?"":typeof t=="object"?JSON.stringify(t):String(t);return/[",\n]/.test(e)?`"${e.replaceAll('"','""')}"`:e}function Et(t){let e=t?.data?.values;return Array.isArray(e)?e:e?.labels&&Array.isArray(e.labels)?e.labels.map((r,o)=>({label:r,views:e.views?.[o]||0,reads:e.reads?.[o]||0})):e?.totals?Object.entries(e.totals).map(([r,o])=>({metric:r,value:o,previous:e.previous?.[r],change:e.changes?.[r]})):[]}async function At({client:t,options:e}){if(!e.output)throw new Error("Analytics export requires --output <file>.");let r=e.format||"json";if(!["json","csv"].includes(r))throw new Error("Analytics export format must be json or csv.");let o=await t.query(kt(e)),i;if(r==="json")i=`${JSON.stringify(o,null,2)}
|
|
16
|
+
`;else{let n=Et(o),a=[...new Set(n.flatMap(s=>Object.keys(s)))];i=`${a.map(It).join(",")}
|
|
17
|
+
${n.map(s=>a.map(l=>It(s[l])).join(",")).join(`
|
|
18
|
+
`)}
|
|
19
|
+
`}return await fr(e.output,i,{encoding:"utf8",flag:"wx"}),{output:e.output,format:r,rows:Et(o).length}}var yr={profile:{type:"string"},env:{type:"string"},json:{type:"boolean",default:!1},quiet:{type:"boolean",default:!1},yes:{type:"boolean",short:"y",default:!1},"allow-insecure-fallback":{type:"boolean",default:!1},"auth-provider":{type:"string"},"accounts-url":{type:"string"},"api-url":{type:"string"},"client-id":{type:"string"},audience:{type:"string"},scope:{type:"string",multiple:!0},open:{type:"boolean",default:!1},status:{type:"string"},limit:{type:"string"},cursor:{type:"string"},range:{type:"string"},from:{type:"string"},to:{type:"string"},dimension:{type:"string"},format:{type:"string"},output:{type:"string"},file:{type:"string"},stdin:{type:"boolean",default:!1},content:{type:"string"},editor:{type:"boolean",default:!1},title:{type:"string"},subtitle:{type:"string"},slug:{type:"string"},tag:{type:"string",multiple:!0},emoji:{type:"string"},publication:{type:"string"},collection:{type:"string"},cover:{type:"string"},"member-only":{type:"boolean",default:!1},"no-member-only":{type:"boolean",default:!1},secret:{type:"boolean",default:!1},"not-secret":{type:"boolean",default:!1},"dry-run":{type:"boolean",default:!1},"no-input":{type:"boolean",default:!1},etag:{type:"string"},permanent:{type:"boolean",default:!1},"idempotency-key":{type:"string"},user:{type:"string"},role:{type:"string"},"hide-on-profile":{type:"boolean",default:!1},target:{type:"string"},force:{type:"boolean",default:!1},help:{type:"boolean",short:"h",default:!1}},br=`lixblogs \u2014 LixBlogs CLI
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
lixblogs login [--profile <name>] [--open]
|
|
23
|
+
lixblogs register [--profile <name>] [--open]
|
|
24
|
+
lixblogs logout [--profile <name>]
|
|
25
|
+
lixblogs whoami [--profile <name>] [--json]
|
|
26
|
+
lixblogs profiles [--json]
|
|
27
|
+
lixblogs use <name> [--json]
|
|
28
|
+
lixblogs auth login [--profile <name>] [--env <environment>] [--json] [--quiet] [--allow-insecure-fallback]
|
|
29
|
+
lixblogs auth status [--profile <name>] [--json]
|
|
30
|
+
lixblogs auth logout [--profile <name>] [--json] [--quiet]
|
|
31
|
+
lixblogs auth revoke [--profile <name>] [--json] [--quiet] --yes
|
|
32
|
+
lixblogs auth profiles [--json]
|
|
33
|
+
lixblogs auth use <name> [--json]
|
|
34
|
+
lixblogs blog list [--status <status>] [--limit <n>] [--cursor <cursor>] [--json]
|
|
35
|
+
lixblogs blog get <id> [--json]
|
|
36
|
+
lixblogs blog preview <id> [--json]
|
|
37
|
+
lixblogs blog create [--file <post.md>|--stdin|--content <markdown>|--editor] [metadata]
|
|
38
|
+
lixblogs blog edit <id> [--file <post.md>|--stdin|--content <markdown>|--editor] [metadata]
|
|
39
|
+
lixblogs blog publish <id> --yes [--dry-run] [--json]
|
|
40
|
+
lixblogs blog unpublish <id> --yes [--dry-run] [--json]
|
|
41
|
+
lixblogs blog delete <id> --yes [--permanent] [--dry-run] [--json]
|
|
42
|
+
lixblogs blog trash <id> --yes [--dry-run] [--json]
|
|
43
|
+
lixblogs blog restore <id> --yes [--dry-run] [--json]
|
|
44
|
+
lixblogs org list [--json]
|
|
45
|
+
lixblogs org get <id> [--json]
|
|
46
|
+
lixblogs org collections <id> [--json]
|
|
47
|
+
lixblogs org members <id> [--json]
|
|
48
|
+
lixblogs org targets [--json]
|
|
49
|
+
lixblogs collab list <blog-id> [--json]
|
|
50
|
+
lixblogs collab invitations [--json]
|
|
51
|
+
lixblogs collab invite <blog-id> --user <username> --role <viewer|editor|admin> --yes
|
|
52
|
+
lixblogs collab role <blog-id> --user <username-or-id> --role <viewer|editor|admin> --yes
|
|
53
|
+
lixblogs collab remove <blog-id> [--user <username-or-id>] --yes
|
|
54
|
+
lixblogs collab accept <blog-id> --yes [--hide-on-profile]
|
|
55
|
+
lixblogs collab decline <blog-id> --yes
|
|
56
|
+
lixblogs analytics query [--scope personal|org:<id>] [--range 30d] [--dimension overview]
|
|
57
|
+
lixblogs analytics export --output <file> [--format json|csv] [query options]
|
|
58
|
+
lixblogs skill list [--json]
|
|
59
|
+
lixblogs skill inspect <name> [--json]
|
|
60
|
+
lixblogs skill install <name> [--target <directory>] [--dry-run] --yes
|
|
61
|
+
|
|
62
|
+
Global flags:
|
|
63
|
+
--profile <name> local account alias (defaults to the signed-in username)
|
|
64
|
+
--env <environment> override environment (development|staging|production)
|
|
65
|
+
--auth-provider <provider> elixpo, or mock in development/test only
|
|
66
|
+
--accounts-url <url> override the Accounts discovery origin
|
|
67
|
+
--api-url <url> LixBlogs API origin (default: https://blogs.elixpo.com)
|
|
68
|
+
--scope <scope> request an OAuth scope (repeatable)
|
|
69
|
+
--file <path> read blog Markdown from a file
|
|
70
|
+
--stdin read blog Markdown from stdin
|
|
71
|
+
--content <markdown> use inline Markdown
|
|
72
|
+
--editor open the current blog in $EDITOR
|
|
73
|
+
--title/--subtitle/--slug update blog metadata
|
|
74
|
+
--tag <tag> set a tag (repeatable, up to five)
|
|
75
|
+
--publication <target> personal or org:<id>
|
|
76
|
+
--collection <id> organization collection ID
|
|
77
|
+
--dry-run validate and show the intended action without writing
|
|
78
|
+
--permanent permanently delete instead of moving to trash
|
|
79
|
+
--open open the device verification URL immediately
|
|
80
|
+
--json machine-readable JSON output
|
|
81
|
+
--quiet suppress non-essential output
|
|
82
|
+
--yes, -y auto-confirm destructive actions (required for revoke)
|
|
83
|
+
--allow-insecure-fallback explicit opt-in: if the OS keychain is unavailable, use a
|
|
84
|
+
non-persistent in-memory store instead of failing
|
|
85
|
+
--help, -h show this help
|
|
86
|
+
|
|
87
|
+
Machine mode:
|
|
88
|
+
--json --no-input produces stable JSON on stdout, diagnostics on stderr, and
|
|
89
|
+
never prompts. Publishing and destructive state changes require --yes.
|
|
90
|
+
`,vr=["openid","profile","email","lixblogs:profile:read","lixblogs:blog:read"];function R(t){return{profile:t.profile,env:t.env,authProvider:t["auth-provider"],accountsUrl:t["accounts-url"],apiUrl:t["api-url"],clientId:t["client-id"],audience:t.audience}}async function M(t,e){return t.profileExplicit?x(t.profile):await e.getActive()||x(t.profile)}async function Ie(t){let e=process.platform==="darwin"?"open":process.platform==="win32"?"cmd":"xdg-open",r=process.platform==="win32"?["/c","start","",t]:[t],o=wr(e,r,{detached:!0,stdio:"ignore"});o.on("error",()=>{}),o.unref()}function m(t,e){t.json&&process.stdout.write(Y(e)+`
|
|
91
|
+
`)}function f(t,e,r=p.ERROR){let o=e&&typeof e=="object"?e:{message:String(e)},i=q(o.message),n=Je({...o,message:i});t.json?process.stdout.write(Y(n)+`
|
|
92
|
+
`):t.quiet||(process.stderr.write(`Error: ${i}
|
|
93
|
+
`),o.hint&&process.stderr.write(`Hint: ${o.hint}
|
|
94
|
+
`),o.requestId&&process.stderr.write(`Request: ${o.requestId}
|
|
95
|
+
`)),process.exitCode=o.exitCode||r}async function S(t,e){try{return await Ce({allowInsecureFallback:t["allow-insecure-fallback"],profileRegistry:e})}catch(r){return f(t,`${r.message}${t["allow-insecure-fallback"]?"":" Re-run with --allow-insecure-fallback to opt in to non-persistent storage instead."}`),null}}async function Rt(t){let e=_({flags:R(t)}),r=new g,o=x(e.profile),i=t.scope?.length?[...t.scope]:[...vr];!e.profileExplicit&&!i.includes("lixblogs:profile:read")&&i.push("lixblogs:profile:read");let n;try{n=U(e)}catch(u){return f(t,u.message)}let a=await S(t,r);if(!a)return;let s=()=>{},l;try{l=await je({provider:n,credentialStore:a,profileId:o,scopes:i,openBrowser:t.open?Ie:void 0,resolveProfileId:e.profileExplicit?void 0:({accessToken:u})=>Fe({accessToken:u,apiBaseUrl:e.apiBaseUrl}),onStatus:u=>{if(t.json){u.type!=="pending"&&m(t,{event:u.type,...u});return}if(!t.quiet)if(u.type==="verification_pending"){let y=u.verificationUriComplete||u.verificationUri,c=!!process.stdin.isTTY&&!t["no-input"];process.stdout.write(He({url:y,code:u.userCode,expiresInSeconds:u.expiresInSeconds,profile:e.profileExplicit?o:null,interactive:c,color:fe()})),c&&!t.open&&(s=We({input:process.stdin,open:Ie,url:y}))}else u.type==="approved"?console.log(Ke("Access approved by Elixpo Accounts.",fe())):u.type==="denied"?console.log(" Access denied."):u.type==="expired"&&console.log(" Device code expired.")}})}finally{s()}if(!l.ok)return f(t,l.reason);await r.add(l.profileId),await r.setActive(l.profileId),m(t,{ok:!0,profile:l.profileId}),!t.json&&!t.quiet&&(console.log(` Credentials saved to local profile "${l.profileId}".`),console.log(" Tip: add another account with `lixblogs login`, list accounts with `lixblogs profiles`,"),console.log(" and switch with `lixblogs use <username>`."))}async function xr(t){let e=_({flags:R(t)}),r=new g,o=await M(e,r),i=await S(t,r);if(!i)return;let n=await Ue({credentialStore:i,profileId:o});if(m(t,n),!t.json)for(let a of n)a.loggedIn?console.log(`${a.profileId}: logged in${a.expired?" (expired)":""} \u2014 scopes: ${a.scopes.join(", ")}`):console.log(`${a.profileId}: not logged in`)}async function ne(t){let e=_({flags:R(t)}),r=new g,o=await M(e,r),i=await S(t,r);if(!i)return null;let n;try{n=U(e)}catch(s){return f(t,s),null}let a=new N({provider:n,credentialStore:i,profileId:o,apiBaseUrl:e.apiBaseUrl});return{client:new D(a),http:a,config:e,credentialStore:i,profileId:o}}async function _r(t){let e=await ne(t);if(e)try{let[r,o]=await Promise.all([e.client.whoami(),e.credentialStore.get(e.profileId)]),i={ok:!0,profile:e.profileId,environment:e.config.environment,identity:r,scopes:o?.scopes||[],expiresAt:o?.expiresAt?new Date(o.expiresAt).toISOString():null,expired:o?Date.now()>=o.expiresAt:!0};m(t,i),!t.json&&!t.quiet&&(console.log(`${r.displayName||r.username} (@${r.username})`),console.log(`Profile: ${e.profileId} \xB7 ${i.environment}`),console.log(`Scopes: ${i.scopes.join(", ")||"none"}`),console.log(`Expires: ${i.expiresAt||"unknown"}`))}catch(r){f(t,r,r.status===401||r.status===403?p.AUTH:p.ERROR)}}async function Ir(t){let e=_({flags:R(t)}),r=new URL("/register",e.accountsBaseUrl).toString();if(t["no-input"]){m(t,{ok:!0,registrationUrl:r,next:"lixblogs login"}),!t.json&&!t.quiet&&console.log(r);return}await Ie(r),t.quiet||console.log(`Create your account at ${r}, then approve the device login.`),await Rt(t)}async function Er(t){let e=_({flags:R(t)}),r=new g,o=await M(e,r),i=await S(t,r);if(!i)return;let n=await Ne({credentialStore:i,profileId:o});m(t,n),!t.json&&!t.quiet&&console.log(`Logged out profile "${o}".`)}async function kr(t){let e=_({flags:R(t)}),r=new g,o=await M(e,r);if(!t.yes)return f(t,"This is a destructive action. Re-run with --yes to confirm (interactive confirmation prompt not yet implemented).");let i;try{i=U(e)}catch(s){return f(t,s.message)}let n=await S(t,r);if(!n)return;let a=await De({provider:i,credentialStore:n,profileId:o,confirmed:!0});if(!a.ok)return f(t,a.reason);m(t,a),!t.json&&!t.quiet&&console.log(`Revoked and logged out profile "${o}".`)}async function Sr(t){let e=new g,r=await S(t,e);if(!r)return;let o=await Be({credentialStore:r,profileRegistry:e});if(m(t,o),!t.json){o.profiles.length||console.log("No profiles. Run `lixblogs auth login` first.");for(let i of o.profiles)console.log(`${i.active?"*":" "} ${i.profileId}${i.expired?" (expired)":""}`)}}async function Ar(t,e){let r;try{r=x(e[0])}catch(a){return f(t,a.message)}let o=new g,i=await S(t,o);if(!i)return;let n=await Me({credentialStore:i,profileRegistry:o,profileId:r});if(!n.ok)return f(t,n.reason);m(t,n),!t.json&&!t.quiet&&console.log(`Using profile "${r}".`)}var Pt={list:Ye,get:be,preview:be,create:Ze,edit:Qe,publish:et,unpublish:tt,delete:ve,trash:ve,restore:rt},qt={list:ot,get:it,collections:nt,members:st,targets:at},Ot={list:lt,invitations:ct,invite:ut,role:dt,remove:pt,accept:ft,decline:ht},Tt={list:({options:t})=>vt(t),inspect:({id:t})=>xt({name:t}),install:({id:t,options:e})=>_t({name:t,options:e})},Ct={query:St,export:At};async function $r(t,e,r){let o=_({flags:R(t)}),i=new g,n=await M(o,i),a=await S(t,i);if(!a)return;let s;try{s=U(o)}catch(c){return f(t,c.message)}let l=new N({provider:s,credentialStore:a,profileId:n,apiBaseUrl:o.apiBaseUrl}),u=new D(l),y={...t,limit:t.limit===void 0?void 0:Number.parseInt(t.limit,10)};try{let c=await Pt[r]({client:u,id:e[0],options:y,stdin:process.stdin});if(m(t,{ok:!0,...c}),!t.json&&!t.quiet)if(r==="list"){for(let h of c.data||[])console.log(`${h.id} ${h.status} ${h.title||"(untitled)"}`);c.meta?.nextCursor&&console.log(`Next cursor: ${c.meta.nextCursor}`)}else r==="get"?console.log(`${c.title||"(untitled)"} [${c.status}]
|
|
96
|
+
${c.markdown||""}`):c.dryRun?console.log(`Dry run: ${r} validated; no changes sent.`):console.log(c.url||`${r} completed for ${c.id}.`)}catch(c){if(t.json&&c instanceof d){process.stdout.write(Y({ok:!1,error:{code:c.code,message:c.message,requestId:c.requestId,details:c.details}})+`
|
|
97
|
+
`),process.exitCode=c.status===412?3:1;return}return f(t,c,c.status===412?p.CONFLICT:p.ERROR)}}async function Rr(t,e,r){let o=await ne(t);if(!o)return;let i=new Q(o.http);try{let n=await qt[r]({client:i,id:e[0],options:t});if(m(t,{ok:!0,data:n}),t.json||t.quiet)return;if(r==="targets"){console.log("personal Personal Blog");for(let s of n.organizations||[]){console.log(`${s.target} ${s.role} ${s.name}`);for(let l of s.collections||[])console.log(` collection:${l.id} ${l.name}`)}return}let a=r==="list"?n.data||[]:Array.isArray(n)?n:[n];for(let s of a)console.log([s.id||s.userId||s.orgId,s.role,s.slug||s.username,s.name||s.displayName].filter(Boolean).join(" "))}catch(n){f(t,n,n.status===401||n.status===403?p.AUTH:p.ERROR)}}async function Pr(t,e,r){let o=await ne(t);if(!o)return;let i=new te(o.http);try{let n=await Ot[r]({client:i,id:e[0],options:t});if(m(t,{ok:!0,data:n}),t.json||t.quiet)return;if(n.dryRun){console.log(`Dry run: ${n.action} validated; no changes sent.`);return}let a=r==="invitations"?n:r==="list"?n.collaborators||[]:[n];for(let s of a)console.log([s.blogId||s.userId,s.status,s.role,s.username||s.title,s.notificationState].filter(Boolean).join(" "))}catch(n){f(t,n,n.status===401||n.status===403?p.AUTH:p.ERROR)}}async function qr(t,e,r){try{let o=await Tt[r]({id:e[0],options:t});if(m(t,{ok:!0,data:o}),t.json||t.quiet)return;if(r==="list")for(let i of o)console.log(`${i.name} CLI >= ${i.minimumCliVersion||"unknown"} ${i.description}`);else r==="inspect"?process.stdout.write(o.content):o.dryRun?console.log(`Dry run: install ${o.name} to ${o.target}${o.replace?" (replace)":""}.`):console.log(`Installed ${o.name} at ${o.target}.`)}catch(o){f(t,o)}}async function Or(t,e,r){let o=await ne(t);if(!o)return;let i=new re(o.http),n={...t,limit:t.limit===void 0?void 0:Number.parseInt(t.limit,10)};try{let a=await Ct[r]({client:i,options:n});if(m(t,{ok:!0,data:a}),t.json||t.quiet)return;if(r==="export"){console.log(`Exported ${a.rows} rows to ${a.output}.`);return}let s=a.data;if(console.log(`${s.scope.label} \xB7 ${s.dimension} \xB7 ${s.range.key}`),s.dimension==="overview")for(let[l,u]of Object.entries(s.values.totals))console.log(`${l} ${u} ${s.values.changes[l]}%`);else if(s.dimension==="timeline")s.values.labels.forEach((l,u)=>console.log(`${l} ${s.values.views[u]} ${s.values.reads[u]}`));else{for(let l of s.values)console.log(Object.values(l).join(" "));a.meta?.nextCursor&&console.log(`Next cursor: ${a.meta.nextCursor}`)}}catch(a){f(t,a,a.status===401||a.status===403?p.AUTH:p.ERROR)}}var $t={auth:{login:Rt,status:xr,whoami:_r,logout:Er,revoke:kr,profiles:Sr,use:Ar},blog:Object.fromEntries(Object.keys(Pt).map(t=>[t,(e,r)=>$r(e,r,t)])),org:Object.fromEntries(Object.keys(qt).map(t=>[t,(e,r)=>Rr(e,r,t)])),collab:Object.fromEntries(Object.keys(Ot).map(t=>[t,(e,r)=>Pr(e,r,t)])),skill:Object.fromEntries(Object.keys(Tt).map(t=>[t,(e,r)=>qr(e,r,t)])),analytics:Object.fromEntries(Object.keys(Ct).map(t=>[t,(e,r)=>Or(e,r,t)]))};async function Tr(){let t,e;try{({values:t,positionals:e}=gr({args:process.argv.slice(2),options:yr,allowPositionals:!0,strict:!0}))}catch(a){process.stderr.write(`Error: Invalid flag. ${a.message}
|
|
98
|
+
`),process.exitCode=p.USAGE;return}if(t.help||e.length===0){process.stdout.write(br);return}if(e[0]==="register"){await Ir(t);return}e=Ve(e);let[r,o]=e,i=$t[r];if(!i){process.stderr.write(`Error: Unknown command category "${r}".
|
|
99
|
+
`),process.stderr.write(`Available categories: ${Object.keys($t).join(", ")}
|
|
100
|
+
`),process.exitCode=p.USAGE;return}let n=i[o];if(!n){process.stderr.write(`Error: Unknown ${r} command "${o}".
|
|
101
|
+
`),process.stderr.write(`Available commands: ${Object.keys(i).map(a=>`${r} ${a}`).join(", ")}
|
|
102
|
+
`),process.exitCode=p.USAGE;return}await n(t,e.slice(2))}Tr();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elixpo/lixblogs-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "Official CLI for LixBlogs — publish, manage, and inspect blogs through the supported API. Built for creators and agent automation.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Elixpo",
|
|
@@ -15,22 +15,18 @@
|
|
|
15
15
|
],
|
|
16
16
|
"type": "module",
|
|
17
17
|
"bin": {
|
|
18
|
-
"lixblogs": "
|
|
18
|
+
"lixblogs": "dist/lixblogs.mjs"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
|
-
"
|
|
22
|
-
"bin",
|
|
21
|
+
"dist",
|
|
23
22
|
"skills",
|
|
24
|
-
"README.md"
|
|
25
|
-
"API.md",
|
|
26
|
-
"THREAT_MODEL.md",
|
|
27
|
-
"RELEASE.md",
|
|
28
|
-
"CHANGELOG.md"
|
|
23
|
+
"README.md"
|
|
29
24
|
],
|
|
30
25
|
"scripts": {
|
|
26
|
+
"build": "esbuild bin/lixblogs.mjs --bundle --platform=node --format=esm --target=node18 --minify --tree-shaking=true --legal-comments=none --external:@napi-rs/keyring --outfile=dist/lixblogs.mjs",
|
|
31
27
|
"test": "node --test tests/*.test.mjs",
|
|
32
28
|
"skills:sync": "node scripts/sync-skills.mjs",
|
|
33
|
-
"prepack": "npm run skills:sync && npm test",
|
|
29
|
+
"prepack": "npm run skills:sync && npm run build && npm test",
|
|
34
30
|
"postpack": "node scripts/sync-skills.mjs --clean",
|
|
35
31
|
"prepublishOnly": "npm test"
|
|
36
32
|
},
|
|
@@ -73,5 +69,8 @@
|
|
|
73
69
|
},
|
|
74
70
|
"publishConfig": {
|
|
75
71
|
"access": "public"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"esbuild": "^0.27.3"
|
|
76
75
|
}
|
|
77
76
|
}
|
package/API.md
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
# LixBlogs API v1 contract
|
|
2
|
-
|
|
3
|
-
The CLI is a public OAuth client. Accounts issues credentials; LixBlogs is the
|
|
4
|
-
resource server. The CLI must never connect to D1 or carry a client secret.
|
|
5
|
-
|
|
6
|
-
## Origins and discovery
|
|
7
|
-
|
|
8
|
-
- OAuth issuer: `https://accounts.elixpo.com`
|
|
9
|
-
- Token audience: `blogs.elixpo.com`
|
|
10
|
-
- Resource root: `https://blogs.elixpo.com/api/v1`
|
|
11
|
-
- Contract metadata: `GET /api/v1`
|
|
12
|
-
|
|
13
|
-
Production bearer tokens are EdDSA access tokens issued to an allowlisted CLI
|
|
14
|
-
client. LixBlogs verifies their signature, expiry, audience, client, scopes,
|
|
15
|
-
and local account before querying creator data. Tokens are never persisted or
|
|
16
|
-
logged by the resource API.
|
|
17
|
-
|
|
18
|
-
## Initial resources
|
|
19
|
-
|
|
20
|
-
| Method | Path | Scope | Behavior |
|
|
21
|
-
| --- | --- | --- | --- |
|
|
22
|
-
| `GET` | `/api/v1` | public | API and compatibility metadata |
|
|
23
|
-
| `GET` | `/api/v1/me` | `lixblogs:profile:read` | Current LixBlogs identity |
|
|
24
|
-
| `GET` | `/api/v1/blogs` | `lixblogs:blog:read` | Accessible blog metadata |
|
|
25
|
-
| `GET` | `/api/v1/blogs/{id}` | `lixblogs:blog:read` | One accessible blog and its content |
|
|
26
|
-
| `POST` | `/api/v1/blogs` | `lixblogs:blog:write` | Create a draft |
|
|
27
|
-
| `PATCH` | `/api/v1/blogs/{id}` | `lixblogs:blog:write` | Edit a draft or post |
|
|
28
|
-
| `POST` | `/api/v1/blogs/{id}/publish` | `lixblogs:blog:publish` | Publish a post |
|
|
29
|
-
| `POST` | `/api/v1/blogs/{id}/unpublish` | `lixblogs:blog:publish` | Return a post to draft |
|
|
30
|
-
| `DELETE` | `/api/v1/blogs/{id}` | `lixblogs:blog:delete` | Move a post to trash |
|
|
31
|
-
| `POST` | `/api/v1/blogs/{id}/restore` | `lixblogs:blog:delete` | Restore a trashed post |
|
|
32
|
-
| `GET` | `/api/v1/orgs` | `lixblogs:organizations:read` | Organizations the caller belongs to |
|
|
33
|
-
| `GET` | `/api/v1/orgs/{id}` | `lixblogs:organizations:read` | One caller-visible organization and role |
|
|
34
|
-
| `GET` | `/api/v1/orgs/{id}/collections` | `lixblogs:organizations:read` | Collections within a caller membership |
|
|
35
|
-
| `GET` | `/api/v1/orgs/{id}/members` | `lixblogs:organizations:read` | Members and roles within a caller membership |
|
|
36
|
-
| `GET` | `/api/v1/blogs/{id}/collaborators` | `lixblogs:collaboration:read` | Editorial team and effective role |
|
|
37
|
-
| `POST` | `/api/v1/blogs/{id}/collaborators` | `lixblogs:collaboration:write` | Invite or reassign a collaborator |
|
|
38
|
-
| `PATCH` | `/api/v1/blogs/{id}/collaborators` | `lixblogs:collaboration:write` | Change a collaborator role |
|
|
39
|
-
| `DELETE` | `/api/v1/blogs/{id}/collaborators` | `lixblogs:collaboration:write` | Remove a collaborator or leave a team |
|
|
40
|
-
| `GET` | `/api/v1/collaboration/invitations` | `lixblogs:collaboration:read` | Current identity's invitations |
|
|
41
|
-
| `POST` | `/api/v1/collaboration/invitations` | `lixblogs:collaboration:write` | Accept or decline an invitation |
|
|
42
|
-
| `GET` | `/api/v1/analytics` | `lixblogs:analytics:read` | Bounded creator analytics dimensions |
|
|
43
|
-
|
|
44
|
-
`GET /api/v1/analytics` accepts `scope=personal|org:<id>`, `range=7d|30d|90d|12m|custom`,
|
|
45
|
-
`from`, `to`, `dimension=overview|timeline|posts|sources|devices|countries`, `limit=1..100`,
|
|
46
|
-
and an opaque `cursor`. Organization queries additionally require `lixblogs:organizations:read`
|
|
47
|
-
and an owner, admin, or maintain membership. Responses contain aggregate creator data;
|
|
48
|
-
visitor identifiers are never returned.
|
|
49
|
-
|
|
50
|
-
`GET /api/v1/blogs` accepts `status=all|draft|published`, `limit=1..100`, and
|
|
51
|
-
an opaque `cursor`. Results include authored blogs, accepted collaborations,
|
|
52
|
-
and blogs belonging to organizations of which the caller is a member. Access
|
|
53
|
-
misses return `404` so resource existence is not disclosed.
|
|
54
|
-
|
|
55
|
-
Permanent deletion uses `DELETE /api/v1/blogs/{id}?permanent=true`. It requires
|
|
56
|
-
the additional `lixblogs:blog:delete:permanent` scope and an
|
|
57
|
-
`X-Confirm-Permanent-Delete` header equal to the blog ID.
|
|
58
|
-
|
|
59
|
-
## Response shape
|
|
60
|
-
|
|
61
|
-
Successful responses use:
|
|
62
|
-
|
|
63
|
-
```json
|
|
64
|
-
{ "data": {}, "meta": {} }
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
Errors use a stable machine-readable envelope:
|
|
68
|
-
|
|
69
|
-
```json
|
|
70
|
-
{
|
|
71
|
-
"error": {
|
|
72
|
-
"code": "invalid_token",
|
|
73
|
-
"message": "The access token is malformed.",
|
|
74
|
-
"requestId": "..."
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
Every response carries `X-LixBlogs-API-Version` and `X-Request-ID`. Authenticated
|
|
80
|
-
responses also expose bounded per-minute `X-RateLimit-*` values. Clients should
|
|
81
|
-
use error codes rather than matching human-readable messages.
|
|
82
|
-
|
|
83
|
-
## Concurrency and retries
|
|
84
|
-
|
|
85
|
-
Single-blog reads include a strong `ETag`. Future write endpoints require
|
|
86
|
-
`If-Match` so a stale CLI cannot silently overwrite a newer browser edit.
|
|
87
|
-
|
|
88
|
-
Mutation requests use an `Idempotency-Key` containing 8–128 URL-safe
|
|
89
|
-
characters. Reservations live for 24 hours:
|
|
90
|
-
|
|
91
|
-
- same key and same request: replay the retained response;
|
|
92
|
-
- same key while running: `idempotency_in_progress`;
|
|
93
|
-
- same key with different input: `idempotency_key_reused`.
|
|
94
|
-
|
|
95
|
-
Operational rows are pruned in bounded batches. Audit events record the caller,
|
|
96
|
-
client, action, resource, outcome, and request ID, never bearer material.
|
|
97
|
-
|
|
98
|
-
## Compatibility policy
|
|
99
|
-
|
|
100
|
-
The `/api/v1` URL and envelope are stable for the v1 lifetime. Fields may be
|
|
101
|
-
added without a major version change; existing fields and error codes are not
|
|
102
|
-
removed or redefined. A breaking change requires `/api/v2`. The metadata
|
|
103
|
-
endpoint advertises `minCliVersion`; clients below it must stop before making
|
|
104
|
-
authenticated requests and present an upgrade instruction.
|
package/CHANGELOG.md
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
Release notes are generated from merged pull requests. This file records contract-level changes that users must see before upgrading.
|
|
4
|
-
|
|
5
|
-
## 1.3.0
|
|
6
|
-
|
|
7
|
-
- Added scoped organization and editorial commands.
|
|
8
|
-
- Added bounded, read-only creator analytics with JSON and CSV export.
|
|
9
|
-
- Bundled five independently installable LixBlogs agent skills.
|
|
10
|
-
- Added packed-artifact, Accounts device-flow, Blogs resource-contract, provenance, and rollback release gates.
|
package/RELEASE.md
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# Release and compatibility policy
|
|
2
|
-
|
|
3
|
-
## Compatibility
|
|
4
|
-
|
|
5
|
-
- CLI `1.x` targets LixBlogs API `/api/v1` and the Accounts OAuth discovery contract.
|
|
6
|
-
- Additive response fields and commands are minor releases. Fixes without contract changes are patch releases.
|
|
7
|
-
- Removing a command, field, scope, or error code requires a CLI major release or API `/api/v2`.
|
|
8
|
-
- The resource metadata `minCliVersion` is authoritative. An older client must stop before authenticated requests and ask the user to upgrade.
|
|
9
|
-
- Node 18 is the supported runtime floor. Release gates run on Node 22.
|
|
10
|
-
|
|
11
|
-
## Release
|
|
12
|
-
|
|
13
|
-
1. Update the package version and `CHANGELOG.md` in a reviewed PR.
|
|
14
|
-
2. Run the manual **LixBlogs CLI release gate**. It tests the package, installs the exact tarball, checks bundled skills, verifies Blogs resource contracts, and runs the Accounts device-flow contract.
|
|
15
|
-
3. Tag the reviewed commit as `lixblogs-cli-vX.Y.Z`.
|
|
16
|
-
4. The publish workflow reruns the gate, checks that tag and package versions match, attests the tarball, publishes it through npm trusted publishing with provenance, and creates generated GitHub release notes.
|
|
17
|
-
|
|
18
|
-
No npm token is stored in this workflow. The npm package must configure this repository and `publish-lixblogs-cli.yml` as a trusted publisher.
|
|
19
|
-
|
|
20
|
-
## Smoke criteria
|
|
21
|
-
|
|
22
|
-
The packed artifact must install into an empty prefix, render `--help`, discover all five scoped skills, and pass auth, blog lifecycle, organization, collaboration, and analytics command tests. Accounts must pass device approval, refresh rotation, replay protection, and revocation. Blogs must pass bearer validation, concurrency/idempotency, analytics, and media request-boundary tests.
|
|
23
|
-
|
|
24
|
-
## Rollback
|
|
25
|
-
|
|
26
|
-
1. Stop a broken release with `npm deprecate @elixpo/lixblogs-cli@X.Y.Z "Do not use; upgrade to X.Y.N"`.
|
|
27
|
-
2. Restore the previous compatible version with `npm dist-tag add @elixpo/lixblogs-cli@GOOD latest`.
|
|
28
|
-
3. Open a patch PR; never reuse or delete the published version.
|
|
29
|
-
4. Re-run the release gate and publish a new patch tag.
|
|
30
|
-
5. If the API contract caused the failure, raise `minCliVersion` only after the compatible patch is available.
|
package/THREAT_MODEL.md
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
# LixBlogs CLI — Auth Threat Model
|
|
2
|
-
|
|
3
|
-
Tracking: elixpo/blogs.elixpo#137
|
|
4
|
-
Status: **Resolved by implementer** — this doc's existence and scope was an
|
|
5
|
-
open question on #137 ("should a threat model be written as part of this
|
|
6
|
-
issue, and what should it contain?"). Decision: yes, scoped to the five
|
|
7
|
-
areas #137 itself named. Flagged for the maintainer to expand or narrow.
|
|
8
|
-
|
|
9
|
-
## 1. Token storage
|
|
10
|
-
|
|
11
|
-
- Access and refresh tokens are stored in the OS keychain
|
|
12
|
-
(Keychain on macOS, Secret Service/libsecret on Linux, Credential Manager
|
|
13
|
-
on Windows) — never in a plain config file or environment variable by
|
|
14
|
-
default.
|
|
15
|
-
- A fallback (e.g. encrypted file on disk) is only used if the keychain is
|
|
16
|
-
unavailable, and only with explicit user opt-in at that moment — never a
|
|
17
|
-
silent default.
|
|
18
|
-
- Redaction: tokens must never appear in logs, `--json` output, telemetry,
|
|
19
|
-
or crash reports. This is verified by test (see `auth.test.mjs`), not
|
|
20
|
-
just documented — string values matching known token prefixes should be
|
|
21
|
-
masked in any error-serialization path.
|
|
22
|
-
- Risk if this fails: a leaked token grants whatever scopes it holds until
|
|
23
|
-
revoked or expired — this is why short-lived access tokens (with refresh
|
|
24
|
-
rotation) matter more than keychain storage alone.
|
|
25
|
-
|
|
26
|
-
## 2. Scope boundaries
|
|
27
|
-
|
|
28
|
-
- Scopes are least-privilege by default; publishing and destructive actions
|
|
29
|
-
require scopes distinct from read/draft scopes (per #135).
|
|
30
|
-
- Accounts publishes the registered LixBlogs scope list. Login defaults to
|
|
31
|
-
identity plus profile/blog read scopes; broader scopes must be requested
|
|
32
|
-
explicitly and remain bounded by the public client's registration.
|
|
33
|
-
- Risk: an overly broad default scope grant (e.g. `login` implicitly
|
|
34
|
-
granting `publish`) would mean any compromised session can publish
|
|
35
|
-
without the user having explicitly consented to that. Mitigation: the
|
|
36
|
-
CLI must request only the scopes a given command needs, not a blanket
|
|
37
|
-
"everything" scope at login time.
|
|
38
|
-
|
|
39
|
-
## 3. Blast radius of a compromised agent/CLI session
|
|
40
|
-
|
|
41
|
-
- Worst case if a token is stolen or an agent is compromised: whatever
|
|
42
|
-
scopes that specific token holds, until it's revoked or naturally
|
|
43
|
-
expires (access tokens are short-lived; refresh tokens are the more
|
|
44
|
-
valuable target).
|
|
45
|
-
- Mitigations already in scope: `lixblogs auth revoke`, tested refresh
|
|
46
|
-
rotation/reuse rejection, and multi-profile isolation (below).
|
|
47
|
-
- Not yet mitigated / open for later work: there's no mechanism yet for a
|
|
48
|
-
user to see "which sessions/devices currently hold a valid token for my
|
|
49
|
-
account" and revoke just one — this would materially reduce blast radius
|
|
50
|
-
and is worth a follow-up issue, not blocking this one.
|
|
51
|
-
|
|
52
|
-
## 4. Device-flow-specific risks
|
|
53
|
-
|
|
54
|
-
- **Code interception**: the user code is short and meant to be read aloud/
|
|
55
|
-
typed manually; the device code (long, unguessable) is what's actually
|
|
56
|
-
exchanged for a token. If a device code leaks (e.g. via a compromised
|
|
57
|
-
clipboard), same blast radius as a stolen token.
|
|
58
|
-
- **Polling abuse**: a malicious client could poll rapidly to try to beat
|
|
59
|
-
the user to approving/denying. Mitigated by `pollIntervalSeconds` and the
|
|
60
|
-
`slow_down` response (see MockAuthProvider) which forces callers to back
|
|
61
|
-
off — the real provider is expected to enforce this server-side too, not
|
|
62
|
-
just suggest it.
|
|
63
|
-
- **Expired/denied code handling**: both must be treated as fully dead ends
|
|
64
|
-
— no retry-with-same-code path. The mock encodes this by design (an
|
|
65
|
-
expired/denied device code never later returns `approved`).
|
|
66
|
-
- **Reused-code replay**: an already-approved device code should not be
|
|
67
|
-
usable to fetch a second, independent token. Not yet tested in the mock
|
|
68
|
-
— worth adding once the "one device code → one token exchange" contract
|
|
69
|
-
is confirmed for the real provider, since the mock doesn't currently
|
|
70
|
-
invalidate a code after issuing a token for it.
|
|
71
|
-
|
|
72
|
-
## 5. Multi-profile / account isolation
|
|
73
|
-
|
|
74
|
-
- Each named profile's tokens must be stored under a distinct keychain
|
|
75
|
-
entry — one profile's credential lookup must never return another
|
|
76
|
-
profile's token, even if both are logged in simultaneously.
|
|
77
|
-
- Switching the active profile must not require re-entering credentials for
|
|
78
|
-
profiles already logged in, but must also never leak which other profiles
|
|
79
|
-
exist to a scope that shouldn't know (e.g. `--json` output for one
|
|
80
|
-
profile shouldn't enumerate other profiles' identifiers).
|
|
81
|
-
- The active profile is non-sensitive registry metadata. Each profile's
|
|
82
|
-
credentials remain isolated in its own OS-keychain entry, and concurrent
|
|
83
|
-
refreshes for one profile share a single rotation operation.
|
|
84
|
-
|
|
85
|
-
## Explicitly out of scope for this document
|
|
86
|
-
|
|
87
|
-
- Threat modeling the API server itself (auth middleware, D1 access
|
|
88
|
-
patterns) — that's elixpo/blogs.elixpo#136's concern.
|
|
89
|
-
- Supply-chain risk on the npm package itself (dependency compromise,
|
|
90
|
-
publish-time integrity) — worth a separate doc once packaging/release
|
|
91
|
-
work (later phase) is underway.
|