@redaktyn/cli 1.5.9 → 1.6.1

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/redaktyn-scan.mjs CHANGED
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import{resolveServerUrl as O}from"./redaktyn-session.mjs";import{box as _,humanBytes as Y,k as m,rich as v,rows as V,say as R}from"./redaktyn-ui.mjs";import{execSync as k}from"node:child_process";import{createRequire as F}from"node:module";import{readFileSync as C,existsSync as z,readdirSync as W,statSync as P,writeFileSync as I,chmodSync as G}from"node:fs";import E from"node:path";import{fileURLToPath as M}from"node:url";const q=F(import.meta.url);function X(){try{return q("./redaktyn-shared.cjs")}catch{console.error("Cannot load @redaktyn/shared. Run `npm install` from the repo root, then `npm run build -w @redaktyn/shared`."),process.exit(2)}}const{deriveHmacKeyFromPassphrase:Q,hmacDigestHex:Z,normalizeSecretPlaintextForMatch:ee,normalizeSecretPlaintextCollapsed:te,normalizeSecretPlaintextLower:oe,extractAddedLinesFromUnifiedDiff:re}=X(),ne=5*1024*1024,y=512*1024,se=2e4,K=3e6,ie=[{name:"exact",transform:ee,lengthKey:"length"},{name:"whitespace-collapse",transform:te,lengthKey:"lengthStripped"},{name:"case-fold",transform:oe,lengthKey:"lengthLower"}],ce=new Set([".png",".jpg",".jpeg",".gif",".webp",".ico",".pdf",".zip",".gz",".exe",".dll",".so",".dylib",".woff",".woff2",".ttf",".eot",".mp4",".mp3",".wasm",".bin"]);function ae(e){const o={exclude:[],_:[]};for(let t=2;t<e.length;t++){const r=e[t];r==="--staged"?o.staged=!0:r==="--json"?o.json=!0:r==="--no-color"?o.noColor=!0:r==="--verbose"?o.verbose=!0:r==="--file"?o.file=e[++t]:r==="--dir"?o.dir=e[++t]:r==="--org-id"?o.orgId=e[++t]:r==="--server"?o.server=e[++t]:r==="--exclude"?o.exclude.push(e[++t]):r.startsWith("-")?(console.error(`Unknown flag: ${r}`),process.exit(1)):o._.push(r)}const n=o._[0];return{...o,cmd:n}}function le(e){try{const o=e.split(".")[1];if(!o)return null;const n=o+"=".repeat((4-o.length%4)%4),t=Buffer.from(n.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf8");return JSON.parse(t)}catch{return null}}function de(e,o){const n=e.replace(/\\/g,"/");if(n.includes("node_modules/")||n.startsWith(".git/"))return!0;for(const t of o)if(t.includes("*")){if(new RegExp("^"+t.replace(/\*/g,".*").replace(/\//g,"\\/")+"$").test(n))return!0}else if(n.includes(t))return!0;return!1}function j(e,o){const n=P(e);if(!n.isFile())return null;if(n.size>ne)return o&&console.warn(`Skipping large file (${n.size} bytes): ${e}`),null;const t=E.extname(e).toLowerCase();if(ce.has(t))return null;try{const r=C(e);return r.includes(0)?null:r.toString("utf8")}catch{return null}}function H(e,o,n=[]){for(const t of W(e)){const r=E.join(e,t),s=E.relative(process.cwd(),r);if(de(s,o))continue;P(r).isDirectory()?H(r,o,n):n.push(r)}return n}function*fe(e,o,n,t){const r=e.length>y?e.slice(0,y):e;e.length>y&&(console.warn(`Content truncated to ${y} characters for scan.`),t.truncatedChars=!0);let s=[];for(const i of ie){const l=new Set(o.map(c=>Number(c[i.lengthKey])).filter(c=>Number.isFinite(c)&&c>0));if(l.size===0)continue;const d=i.transform(r);t.passesRun.push(i.name);for(const c of l)if(!(c>d.length))for(let p=0;p+c<=d.length;p++){if(s.push(Z(d.slice(p,p+c),n)),t.total++,t.total>=K){t.exhausted=!0,s.length>0&&(yield s);return}s.length>=se&&(yield s,s=[])}}s.length>0&&(yield s)}async function he(e,o,n){const t=await fetch(e,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${o}`},body:JSON.stringify(n)}),r=await t.text();let s;try{s=r?JSON.parse(r):{}}catch{s={raw:r}}if(!t.ok){const i=new Error(s.error||t.statusText||"HTTP error");throw i.status=t.status,i.body=s,i}return s}async function J(e,o){const n=`${e.replace(/\/+$/,"")}/api/secrets`,t=await fetch(n,{headers:{Authorization:`Bearer ${o}`}});if(!t.ok){const s=await t.text().catch(()=>"");throw new Error(`GET /api/secrets failed: ${t.status} ${s.slice(0,200)}`)}const r=await t.text();try{return JSON.parse(r)}catch{throw new Error("GET /api/secrets: invalid JSON response")}}async function ue(){(process.argv.includes("--help")||process.argv.includes("-h"))&&(D(),process.exit(0));const e=ae(process.argv),o=e.cmd;if(o==="install-hooks"){ge();return}if(o==="uninstall-hooks"){me();return}if(o==="status"){await pe(e);return}!e.staged&&!e.file&&!e.dir&&(D(),process.exit(2));const n=process.env.REDAKTYN_DEVICE_TOKEN||process.env.REDAKTYN_TOKEN||process.env.REDAKTYN_JWT||process.env.REDAKTYN_PASSPHRASE;let t=null;if(!n){const{loadSession:a,freshSession:u}=await import("./redaktyn-session.mjs");if(a())try{t=await u()}catch(S){console.error(S.message),process.exit(2)}}const r=e.server||t?.serverUrl||O(),s=t?.token||process.env.REDAKTYN_DEVICE_TOKEN||process.env.REDAKTYN_TOKEN||process.env.REDAKTYN_JWT,i=t?"session":process.env.REDAKTYN_PASSPHRASE,l=e.orgId||process.env.REDAKTYN_ORG_ID;(!r||!s||!i)&&(console.error("Not connected. Run `redaktyn-connect` with an invite link, or set REDAKTYN_SERVER_URL, REDAKTYN_PASSPHRASE and REDAKTYN_DEVICE_TOKEN (or REDAKTYN_TOKEN)."),process.exit(2));const d=le(s),c=d?.orgId??d?.org_id??l;c||(console.error("Could not determine orgId (JWT payload orgId or REDAKTYN_ORG_ID)."),process.exit(2)),l&&d?.orgId&&l!==d.orgId&&(console.error("REDAKTYN_ORG_ID does not match JWT orgId."),process.exit(2));let p="",N=!1;if(e.staged)try{const a=k("git diff --cached --unified=0",{encoding:"utf8",maxBuffer:20971520});p=re(a)}catch{console.error("Not a git repository or git diff failed."),process.exit(2)}else if(e.file){const a=j(E.resolve(e.file),e.verbose);a===null&&(N=!0,console.error(`Could not read ${e.file} as text \u2014 nothing was scanned.`)),p=a??""}else if(e.dir){const a=H(E.resolve(e.dir),e.exclude||[]),u=[];for(const S of a){const h=j(S,e.verbose);h!==null&&u.push(h)}u.length===0&&(N=!0,console.error(`No readable text files under ${e.dir} \u2014 nothing was scanned.`)),p=u.join(`
3
- `)}else D(),process.exit(e.cmd?2:0);const x=await J(r,s);(!Array.isArray(x)||x.length===0)&&(console.error("No enforced secrets returned from server."),process.exit(2));const L=t?new Uint8Array(Buffer.from(t.key,"hex")):await Q(i,c),f={total:0,passesRun:[],exhausted:!1,truncatedChars:!1},U=`${r.replace(/\/+$/,"")}/api/cli/scan`,$=new Set,b=new Map;let A=0,T=0;for(const a of fe(p,x,L,f)){const u=[];for(const h of a)$.has(h)||($.add(h),u.push(h));if(u.length===0)continue;A+=Buffer.byteLength(JSON.stringify({digestHex:u})),T+=1;const S=await he(U,s,{digestHex:u});for(const h of Array.isArray(S.matches)?S.matches:[])h&&h.secretId&&b.set(h.secretId,h)}const g=[...b.values()],w=f.exhausted||f.truncatedChars||N;e.json&&(console.log(JSON.stringify({ok:g.length===0&&!w,matches:g,scan:{passes:f.passesRun,digests:f.total,complete:!w}},null,2)),process.exit(g.length>0||w?1:0));const B=`${Y(A)} of fingerprints in ${T} request${T===1?"":"s"}. 0 B of your code.`;if(v&&g.length>0&&(R(""),_("Blocked: a registered secret is in this content",["",...g.map(a=>`${m.red("\u2716")} ${m.bold(a.label)} ${m.dim(String(a.secretId).slice(0,8))}`),"","Remove the secret material, or rotate it and register the new","fingerprint, before committing.","",m.dim(`Sent to the server: ${Y(A)} of fingerprints in ${T} request${T===1?"":"s"}.`),m.dim("Sent of your code: 0 B."),""],{tone:"red",width:72}),R(""),process.exit(1)),v&&w&&(R(""),_("Not a clean result: the scan could not finish",["",...f.truncatedChars?[`${m.amber("\u25B2")} Content was truncated at ${y} characters.`]:[],...f.exhausted?[`${m.amber("\u25B2")} Hit the ${K} digest ceiling before the end.`]:[],...N?[`${m.amber("\u25B2")} Nothing was read from the path given.`]:[],"","Scan a narrower path with --file or --dir, or split the commit.",""],{tone:"amber",width:72}),R(""),process.exit(1)),g.length>0){console.error(""),console.error(`Redaktyn: ${g.length} registered fingerprint${g.length===1?"":"s"} matched in scanned content:`);for(const a of g)console.error(` \u2022 ${a.label} (${a.secretId})`);console.error(""),console.error("Remove the secret material, or rotate it and register the new fingerprint, before committing."),process.exit(1)}w&&(console.error(""),console.error("Redaktyn could not finish scanning this input, so this is not a clean result."),f.truncatedChars&&console.error(` \u2022 Content was truncated at ${y} characters.`),f.exhausted&&console.error(` \u2022 Hit the ${K} digest ceiling before the end of the input.`),N&&console.error(" \u2022 Nothing was read from the path given, so no content was checked."),console.error("Scan a narrower path with --file or --dir, or split the commit."),process.exit(1)),e.verbose&&(v?(R(""),_("Clean: no registered fingerprint found",["",...V([["Checked",`${f.total.toLocaleString("en-US")} windows, ${f.passesRun.join(", ")}`],["Sent to the server",B]],{indent:0,labelWidth:20}),""],{tone:"green",width:78}),R("")):console.log(`Redaktyn: no registered fingerprint matches (${f.passesRun.join(", ")}; ${f.total} windows).`)),process.exit(0)}async function pe(e){const o=e.json===!0,n=process.env.REDAKTYN_DEVICE_TOKEN||process.env.REDAKTYN_TOKEN||process.env.REDAKTYN_JWT;let t=null;if(!n&&!process.env.REDAKTYN_PASSPHRASE){const{loadSession:i,freshSession:l}=await import("./redaktyn-session.mjs");if(i())try{t=await l()}catch(d){const c={connected:!1,reason:String(d?.message||d)};console.log(o?JSON.stringify(c):c.reason),process.exit(1)}}const r=e.server||t?.serverUrl||O(),s=t?.token||n;if(!r||!s){const i={connected:!1,reason:"Not connected. Run `redaktyn-connect` with an invite link, or set REDAKTYN_SERVER_URL and REDAKTYN_TOKEN."};console.log(o?JSON.stringify(i):i.reason),process.exit(2)}try{const i=await J(r,s),l={connected:!0,source:t?"saved session":"environment",serverUrl:r,...t?.orgName?{workspace:t.orgName}:{},...t?.projectName?{project:t.projectName}:{},enrolledSecrets:Array.isArray(i)?i.length:0,hasPassphrase:t?!0:!!process.env.REDAKTYN_PASSPHRASE};console.log(o?JSON.stringify(l,null,2):Object.entries(l).map(([d,c])=>`${d}: ${c}`).join(`
4
- `)),process.exit(0)}catch(i){const l={connected:!1,error:String(i?.message||i)};console.log(o?JSON.stringify(l):l.error),process.exit(1)}}function ge(){const e=k("git rev-parse --show-toplevel",{encoding:"utf8"}).trim(),o=E.join(e,".git","hooks","pre-commit"),t=["#!/bin/sh","# Redaktyn pre-commit \u2014 blocks commits when registered fingerprints appear in staged text.",'export REDAKTYN_SERVER_URL="${REDAKTYN_SERVER_URL:-}"','export REDAKTYN_PASSPHRASE="${REDAKTYN_PASSPHRASE:-}"','export REDAKTYN_DEVICE_TOKEN="${REDAKTYN_DEVICE_TOKEN:-${REDAKTYN_TOKEN:-}}"','echo "Redaktyn: scanning staged changes..."',"set +e",`node "${M(import.meta.url)}" --staged`,"CODE=$?","set -e",'if [ "$CODE" -ne 0 ]; then',' echo "Redaktyn blocked this commit."'," exit 1","fi",'echo "Redaktyn: OK"',"exit 0",""].join(`
5
- `);I(o,t,{mode:493}),G(o,493),console.log("Installed pre-commit hook at",o)}function me(){const e=k("git rev-parse --show-toplevel",{encoding:"utf8"}).trim(),o=E.join(e,".git","hooks","pre-commit");if(!z(o)){console.log("No pre-commit hook found.");return}if(!C(o,"utf8").includes("Redaktyn")){console.log("pre-commit exists but is not a Redaktyn hook \u2014 not removing.");return}I(o,""),console.log("Removed Redaktyn pre-commit hook body at",o)}function D(){console.log(`
2
+ import{resolveServerUrl as V}from"./redaktyn-session.mjs";import{LOCK_HEADERS as pe,explainLockError as ge,lockNetwork as me}from"./redaktyn-lock-net.mjs";import{box as _,humanBytes as P,k as y,rich as K,rows as we,say as x}from"./redaktyn-ui.mjs";import{execFileSync as ke,spawnSync as ye}from"node:child_process";import{createRequire as Se}from"node:module";import{readFileSync as D,existsSync as W,mkdirSync as Ee,readdirSync as Re,realpathSync as xe,statSync as q,writeFileSync as Ne,chmodSync as Te,unlinkSync as ve}from"node:fs";import S from"node:path";import{fileURLToPath as $e}from"node:url";const Ae=Se(import.meta.url);function be(){try{return Ae("./redaktyn-shared.cjs")}catch{console.error("Cannot load @redaktyn/shared. Run `npm install` from the repo root, then `npm run build -w @redaktyn/shared`."),process.exit(2)}}const{deriveHmacKeyFromPassphrase:X,isOrgKeyLock:_e,scopeOf:Ke,unlockOrgKey:Oe,hmacDigestHex:Pe,normalizeSecretPlaintextForMatch:De,normalizeSecretPlaintextCollapsed:Ce,normalizeSecretPlaintextLower:je,extractAddedLinesFromUnifiedDiff:C,deriveProjectKey:Ie,deriveScanPrefilterSeed:Ue,scanPrefilterParams:Ye,scanPrefilterUnits:Le,scanPrefilterRoller:He,decodeScanPrefilter:Be}=be(),Q=5*1024*1024,Z=4*1024*1024,j=20*1024*1024,I=["package-lock.json","npm-shrinkwrap.json","yarn.lock","pnpm-lock.yaml","bun.lock","Cargo.lock","Gemfile.lock","poetry.lock","Pipfile.lock","uv.lock","composer.lock","go.sum","Package.resolved","packages.lock.json","pubspec.lock","mix.lock","flake.lock","Podfile.lock","gradle.lockfile"],Fe=e=>I.includes(S.posix.basename(e.replace(/\\/g,"/"))),Je=2e4,ee=3e6,Ge=[{name:"exact",transform:De,lengthKey:"length",fingerprint:"exact"},{name:"whitespace-collapse",transform:Ce,lengthKey:"lengthStripped",fingerprint:"collapsed"},{name:"case-fold",transform:je,lengthKey:"lengthLower",fingerprint:"lower"}],te=new Set([".png",".jpg",".jpeg",".gif",".webp",".ico",".pdf",".zip",".gz",".exe",".dll",".so",".dylib",".woff",".woff2",".ttf",".eot",".mp4",".mp3",".wasm",".bin"]);function ze(e){const r={exclude:[],_:[]};for(let n=2;n<e.length;n++){const o=e[n];o==="--staged"?r.staged=!0:o==="--json"?r.json=!0:o==="--no-color"?r.noColor=!0:o==="--verbose"?r.verbose=!0:o==="--file"?r.file=e[++n]:o==="--dir"?r.dir=e[++n]:o==="--org-id"?r.orgId=e[++n]:o==="--server"?r.server=e[++n]:o==="--exclude"?r.exclude.push(e[++n]):o.startsWith("-")?(console.error(`Unknown flag: ${o}`),process.exit(1)):r._.push(o)}const t=r._[0];return{...r,cmd:t}}function Me(e){try{const r=e.split(".")[1];if(!r)return null;const t=r+"=".repeat((4-r.length%4)%4),n=Buffer.from(t.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf8");return JSON.parse(n)}catch{return null}}function Ve(e,r){const t=e.replace(/\\/g,"/");if(t.includes("node_modules/")||t.startsWith(".git/"))return!0;for(const n of r)if(n.includes("*")){if(new RegExp("^"+n.split("*").map(s=>s.replace(/[.+?^${}()|[\]\\]/g,"\\$&")).join(".*")+"$").test(t))return!0}else if(t.includes(n))return!0;return!1}function oe(e,r){const t=q(e);if(!t.isFile())return null;if(t.size>Q)return r&&console.warn(`Skipping large file (${t.size} bytes): ${e}`),null;const n=S.extname(e).toLowerCase();if(te.has(n))return null;try{const o=D(e);return o.includes(0)?null:o.toString("utf8")}catch{return null}}function re(e,r,t=[],n=new Set,o={unreadable:[]}){let s;try{s=xe(e)}catch{return o.unreadable.push(e),t}if(n.has(s))return t;n.add(s);let a;try{a=Re(e)}catch{return o.unreadable.push(e),t}for(const c of a){const l=S.join(e,c),i=S.relative(process.cwd(),l);if(Ve(i,r))continue;let d;try{d=q(l)}catch{continue}d.isDirectory()?re(l,r,t,n,o):t.push(l)}return t}function We(e,r){const t=new Map;for(const n of e){const o=Number(n[r.lengthKey]);if(!Number.isFinite(o)||o<=0)continue;const s=Be(n.scanPrefilter);t.has(o)||t.set(o,new Set);const a=t.get(o);a!==null&&(s?a.add(s[r.fingerprint]):t.set(o,null))}return t}function*ne(e,r,t,n=Z){const o=e.length>n?e.slice(0,n):e;e.length>n&&(console.warn(`Content truncated to ${n} characters for scan.`),t.truncatedChars=!0);let s=[];for(const a of Ge){let c=null,l=null;for(const i of r){const d=We(i.secrets,a);if(d.size===0)continue;c===null&&(c=a.transform(o),l=Le(c),t.passesRun.includes(a.name)||t.passesRun.push(a.name));const u=Ye(Ue(i.key));for(const[h,m]of d){if(h>c.length)continue;const T=m?He(u,l,h):null;for(let w=0;w+h<=c.length;w++)if(t.windows++,!(T&&!m.has(T(w)))){if(s.push(Pe(c.slice(w,w+h),i.key)),t.total++,t.total>=ee){t.exhausted=!0,s.length>0&&(yield s);return}s.length>=Je&&(yield s,s=[])}}}}s.length>0&&(yield s)}async function qe({secrets:e,savedKey:r,orgKey:t,serverUrl:n,token:o,isAdmin:s}){if(r)return[{key:r,secrets:e}];const a=[...new Set(e.map(d=>d.projectId).filter(Boolean))];let c=null;if(a.length>0&&s)try{const d=await fetch(`${n.replace(/\/+$/,"")}/api/projects`,{headers:{Authorization:`Bearer ${o}`}}),u=d.ok?await d.json():null;Array.isArray(u)&&(c=new Map(u.map(h=>[h.id,h.usesOrgKey===!0])))}catch{c=null}const l={key:t,secrets:[]},i=new Map;for(const d of e){const u=d.projectId,h=u&&c?c.get(u):void 0;(!u||h===!0||h===void 0)&&l.secrets.push(d),u&&h!==!0&&(i.has(u)||i.set(u,{key:Ie(t,u),secrets:[]}),i.get(u).secrets.push(d))}return[l,...i.values()].filter(d=>d.secrets.length>0)}async function Xe(e,r,t){const n=await fetch(e,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${r}`},body:JSON.stringify(t)}),o=await n.text();let s;try{s=o?JSON.parse(o):{}}catch{s={raw:o}}if(!n.ok){const a=new Error(s.error||n.statusText||"HTTP error");throw a.status=n.status,a.body=s,a}return s}async function se(e,r,t=!1){const n=`${e.replace(/\/+$/,"")}/api/secrets`,o=await fetch(n,{headers:{Authorization:`Bearer ${r}`}});if(t&&(o.status===401||o.status===403))throw new Error("This machine's Redaktyn session is no longer valid: the device was removed or its session ended. Ask your admin for a new invite link and run `redaktyn-connect` again.");if(!o.ok){const a=await o.text().catch(()=>"");throw new Error(`GET /api/secrets failed: ${o.status} ${a.slice(0,200)}`)}const s=await o.text();try{return{secrets:JSON.parse(s),closed:o.headers.get("x-redaktyn-scope")==="closed"}}catch{throw new Error("GET /api/secrets: invalid JSON response")}}function ie(){const e=["REDAKTYN_DEVICE_TOKEN","REDAKTYN_TOKEN","REDAKTYN_JWT"].find(o=>process.env[o]),r=e?process.env[e]:void 0,t=process.env.REDAKTYN_PASSPHRASE||void 0,n=r&&!t?`${e} is set without REDAKTYN_PASSPHRASE`:t&&!r?"REDAKTYN_PASSPHRASE is set without a token":null;return{token:r,passphrase:t,complete:!!(r&&t),partial:n}}function Qe(e){if(e?.unreachable===!0)return!0;const r=e?.cause?.code??e?.code;return["ECONNREFUSED","ENOTFOUND","EAI_AGAIN","ETIMEDOUT","ECONNRESET","EHOSTUNREACH","ENETUNREACH","UND_ERR_CONNECT_TIMEOUT"].includes(r)?!0:e?.name==="TypeError"&&/fetch failed/i.test(String(e?.message))}let $={serverUrl:null,staged:!1};function ce(e){if(!Qe(e))return String(e?.message||e);const t=[`Could not reach the Redaktyn server${$.serverUrl?` at ${$.serverUrl.replace(/\/+$/,"")}`:""}, so nothing was checked. This is not a clean result.`];return $.staged&&t.push("To commit without the check, at your own risk: git commit --no-verify"),t.join(`
3
+ `)}async function Ze(e,r,t,n){const o=e.replace(/\/+$/,""),s={Authorization:`Bearer ${r}`},a=await fetch(`${o}/api/org/key-lock`,{headers:{...s,...pe}});if(a.status===404)return X(t,n);if(!a.ok)throw new Error(`GET /api/org/key-lock failed: ${a.status}`);const{lock:c}=await a.json();if(!c)return X(t,n);if(!_e(c))throw new Error("The server sent a key lock this CLI does not understand. Update it.");let l;try{l=await Oe(c,t,n,me({server:o,token:r},{scopeOf:Ke}))}catch(i){throw ge(i)}if(!l)throw new Error("REDAKTYN_PASSPHRASE is not this workspace's passphrase.");return l}function N(e,r={}){return ke("git",e,{encoding:"utf8",maxBuffer:j,stdio:["ignore","pipe","pipe"],...r})}function ae(e,r){const t=ye("git",e,{cwd:r,encoding:"utf8",maxBuffer:j,stdio:["ignore","pipe","pipe"]});if(t.error?.code==="ENOBUFS")return{patch:t.stdout.slice(0,t.stdout.lastIndexOf(`
4
+ `)+1),cut:!0};if(t.error)throw t.error;if(t.status!==0)throw new Error(t.stderr);return{patch:t.stdout,cut:!1}}const U=["diff","--cached","--unified=0","--no-color","--no-ext-diff","--no-textconv"];function et(e){const r=N(["rev-parse","--show-toplevel"]).trim(),t=ae([...U,"--",".",...I.map(i=>`:(exclude,glob)**/${i}`)],r),n=ae([...U,"--",...I.map(i=>`:(glob)**/${i}`)],r),o=[C(t.patch)],s=[C(n.patch)],c=N(["diff","--cached","--no-ext-diff","--no-textconv","--numstat","--no-renames","-z"],{cwd:r}).split("\0").filter(i=>i.startsWith("- - ")).map(i=>i.slice(4));for(const i of c){if(te.has(S.extname(i).toLowerCase()))continue;let d;try{const u=Number(N(["cat-file","-s",`:${i}`],{cwd:r}).trim());if(!(u<=Q)){e&&console.warn(`Skipping large staged file (${u} bytes): ${i}`);continue}d=N(["cat-file","blob",`:${i}`],{cwd:r,encoding:"buffer"})}catch{continue}d.includes(0)||(Fe(i)?s:o).push(C(N([...U,"--text","--",`:(literal)${i}`],{cwd:r})))}const l=i=>i.filter(d=>d.length>0).join(`
5
+ `);return{text:l(o),lockfiles:l(s),cut:t.cut||n.cut}}async function tt(){(process.argv.includes("--help")||process.argv.includes("-h"))&&(L(),process.exit(0));const e=ze(process.argv),r=e.cmd;if(r==="install-hooks"){rt();return}if(r==="uninstall-hooks"){nt();return}if(r==="status"){await ot(e);return}!e.staged&&!e.file&&!e.dir&&(L(),process.exit(2));const t=ie();let n=null;if($.staged=e.staged===!0,!t.complete){const{loadSession:f,freshSession:R}=await import("./redaktyn-session.mjs"),k=f();if(k){t.partial&&e.verbose&&console.error(`[redaktyn] ${t.partial}; using this machine's saved connection.`),$.serverUrl=k.serverUrl??null;try{n=await R()}catch(g){console.error(ce(g)),process.exit(2)}}}const o=e.server||n?.serverUrl||V();$.serverUrl=o;const s=n?.token||t.token,a=n?"session":t.passphrase,c=e.orgId||process.env.REDAKTYN_ORG_ID;(!o||!s||!a)&&(console.error("Not connected. Run `redaktyn-connect` with an invite link, or set REDAKTYN_SERVER_URL, REDAKTYN_PASSPHRASE and REDAKTYN_DEVICE_TOKEN (or REDAKTYN_TOKEN)."),process.exit(2));const l=Me(s),i=l?.orgId??l?.org_id??c;i||(console.error("Could not determine orgId (JWT payload orgId or REDAKTYN_ORG_ID)."),process.exit(2)),c&&l?.orgId&&c!==l.orgId&&(console.error("REDAKTYN_ORG_ID does not match JWT orgId."),process.exit(2));let d="",u="",h=!1,m=0,T=!1;if(e.staged)try{({text:d,lockfiles:u,cut:T}=et(e.verbose))}catch{console.error("Not a git repository or git diff failed."),process.exit(2)}else if(e.file){const f=oe(S.resolve(e.file),e.verbose);f===null&&(h=!0,console.error(`Could not read ${e.file} as text \u2014 nothing was scanned.`)),d=f??""}else if(e.dir){const f={unreadable:[]},R=re(S.resolve(e.dir),e.exclude||[],[],new Set,f);if(m=f.unreadable.length,f.unreadable.length>0){for(const g of f.unreadable.slice(0,5))console.error(`Could not read the folder ${g} \u2014 it was not scanned.`);f.unreadable.length>5&&console.error(`\u2026 and ${f.unreadable.length-5} more folders.`)}const k=[];for(const g of R){const M=oe(g,e.verbose);M!==null&&k.push(M)}k.length===0&&(h=!0,console.error(`No readable text files under ${e.dir} \u2014 nothing was scanned.`)),d=k.join(`
6
+ `)}else L(),process.exit(e.cmd?2:0);const{secrets:w,closed:H}=await se(o,s,!!n);if(Array.isArray(w)||(console.error("GET /api/secrets did not answer with a list."),process.exit(2)),w.length===0){const f=H?"Redaktyn: this machine's project is closed, so there are no secrets to check against.":"Redaktyn: no secrets are registered yet, so there is nothing to check against.";e.json?console.log(JSON.stringify({ok:!0,matches:[],registered:0,...H?{projectClosed:!0}:{},scan:{passes:[],digests:0,windows:0,complete:!0}},null,2)):console.error(f),process.exit(0)}const B=n?new Uint8Array(Buffer.from(n.key,"hex")):null,de=B?null:await Ze(o,s,a,i),F=await qe({secrets:w,savedKey:B,orgKey:de,serverUrl:o,token:s,isAdmin:l?.type==="admin"}),p={total:0,windows:0,passesRun:[],exhausted:!1,truncatedChars:!1},fe=`${o.replace(/\/+$/,"")}/api/cli/scan`,J=new Set,G=new Map;let O=0,A=0;const ue=function*(){yield*ne(d,F,p),!p.exhausted&&u&&(yield*ne(u,F,p,1/0))};for(const f of ue()){const R=[];for(const g of f)J.has(g)||(J.add(g),R.push(g));if(R.length===0)continue;O+=Buffer.byteLength(JSON.stringify({digestHex:R})),A+=1;const k=await Xe(fe,s,{digestHex:R});for(const g of Array.isArray(k.matches)?k.matches:[])g&&g.secretId&&G.set(g.secretId,g)}const E=[...G.values()],v=p.exhausted||p.truncatedChars||T||h||m>0,z=E.length>0||v&&!e.staged,b=[...T?[`The staged changes are over ${P(j)}; only the start of them was read.`]:[],...p.truncatedChars?[`Content was truncated at ${Z} characters.`]:[],...p.exhausted?[`Hit the ${ee} digest ceiling before the end.`]:[]];e.json&&(console.log(JSON.stringify({ok:!z,matches:E,scan:{passes:p.passesRun,digests:p.total,windows:p.windows,complete:!v}},null,2)),process.exit(z?1:0));const he=`${P(O)} of fingerprints in ${A} request${A===1?"":"s"}. 0 B of your code.`;if(K&&E.length>0&&(x(""),_("Blocked: a registered secret is in this content",["",...E.map(f=>`${y.red("\u2716")} ${y.bold(f.label)} ${y.dim(String(f.secretId).slice(0,8))}`),"","Remove the secret material, or rotate it and register the new","fingerprint, before committing.","",y.dim(`Sent to the server: ${P(O)} of fingerprints in ${A} request${A===1?"":"s"}.`),y.dim("Sent of your code: 0 B."),""],{tone:"red",width:72}),x(""),process.exit(1)),K&&v&&e.staged&&(x(""),_("Commit allowed, but not all of it was checked",["",...b.map(f=>`${y.amber("\u25B2")} ${f}`),"","Nothing registered was found in the part that was checked.","Smaller commits are checked in full.",""],{tone:"amber",width:72}),x(""),process.exit(0)),K&&v&&(x(""),_("Not a clean result: the scan could not finish",["",...b.map(f=>`${y.amber("\u25B2")} ${f}`),...h?[`${y.amber("\u25B2")} Nothing was read from the path given.`]:[],...m>0?[`${y.amber("\u25B2")} ${m} folder${m===1?"":"s"} could not be read.`]:[],"","Scan a narrower path with --file or --dir, or split the commit.",""],{tone:"amber",width:72}),x(""),process.exit(1)),E.length>0){console.error(""),console.error(`Redaktyn: ${E.length} registered fingerprint${E.length===1?"":"s"} matched in scanned content:`);for(const f of E)console.error(` \u2022 ${f.label} (${f.secretId})`);console.error(""),console.error("Remove the secret material, or rotate it and register the new fingerprint, before committing."),process.exit(1)}if(v&&e.staged){console.error(""),console.error("Redaktyn: commit allowed, but not all of it was checked.");for(const f of b)console.error(` \u2022 ${f}`);console.error("Nothing registered was found in the part that was checked. Smaller commits are checked in full."),process.exit(0)}if(v){console.error(""),console.error("Redaktyn could not finish scanning this input, so this is not a clean result.");for(const f of b)console.error(` \u2022 ${f}`);h&&console.error(" \u2022 Nothing was read from the path given, so no content was checked."),m>0&&console.error(` \u2022 ${m} folder${m===1?"":"s"} could not be read, so their contents were not checked.`),console.error("Scan a narrower path with --file or --dir, or split the commit."),process.exit(1)}e.verbose&&(K?(x(""),_("Clean: no registered fingerprint found",["",...we([["Checked",`${p.windows.toLocaleString("en-US")} windows, ${p.passesRun.join(", ")}`],["Sent to the server",he]],{indent:0,labelWidth:20}),""],{tone:"green",width:78}),x("")):console.log(`Redaktyn: no registered fingerprint matches (${p.passesRun.join(", ")}; ${p.windows} windows).`)),process.exit(0)}async function ot(e){const r=e.json===!0,t=ie(),n=t.token;let o=null;if(!t.complete){const{loadSession:c,freshSession:l}=await import("./redaktyn-session.mjs");if(c()){t.partial&&e.verbose&&console.error(`[redaktyn] ${t.partial}; using this machine's saved connection.`);try{o=await l()}catch(i){const d={connected:!1,reason:String(i?.message||i)};console.log(r?JSON.stringify(d):d.reason),process.exit(1)}}}const s=e.server||o?.serverUrl||V(),a=o?.token||n;if(!s||!a){const c={connected:!1,reason:"Not connected. Run `redaktyn-connect` with an invite link, or set REDAKTYN_SERVER_URL and REDAKTYN_TOKEN."};console.log(r?JSON.stringify(c):c.reason),process.exit(2)}try{const{secrets:c}=await se(s,a,!!o),l={connected:!0,source:o?"saved session":"environment",serverUrl:s,...o?.orgName?{workspace:o.orgName}:{},...o?.projectName?{project:o.projectName}:{},enrolledSecrets:Array.isArray(c)?c.length:0,hasPassphrase:o?!0:!!process.env.REDAKTYN_PASSPHRASE};console.log(r?JSON.stringify(l,null,2):Object.entries(l).map(([i,d])=>`${i}: ${d}`).join(`
7
+ `)),process.exit(0)}catch(c){const l={connected:!1,error:String(c?.message||c)};console.log(r?JSON.stringify(l):l.error),process.exit(1)}}const Y="# Redaktyn pre-commit";function le(){try{N(["rev-parse","--show-toplevel"])}catch{console.error("Not inside a git repository."),process.exit(2)}let e=null;try{e=N(["config","--get","core.hooksPath"]).trim()||null}catch{e=null}const r=S.resolve(process.cwd(),N(["rev-parse","--git-path","hooks"]).trim());return{hooksPath:e,dir:r,hookPath:S.join(r,"pre-commit")}}function rt(){const{hooksPath:e,dir:r,hookPath:t}=le(),o=`node "${$e(import.meta.url)}" --staged`;if(e&&(console.error(`This repository's hooks run from ${e} (core.hooksPath), so nothing was written.`),console.error("Add this line to the pre-commit hook there, or to your hook manager's pre-commit step:"),console.error(` ${o}`),process.exit(1)),W(t)){const a=D(t,"utf8");a.trim()!==""&&!a.includes(Y)&&(console.error(`There is already a pre-commit hook at ${t}, and it is not Redaktyn's. Nothing was changed.`),console.error("To run both, add this line to it:"),console.error(` ${o}`),process.exit(1))}Ee(r,{recursive:!0});const s=["#!/bin/sh",`${Y} \u2014 blocks commits when registered fingerprints appear in staged text.`,'export REDAKTYN_SERVER_URL="${REDAKTYN_SERVER_URL:-}"','export REDAKTYN_PASSPHRASE="${REDAKTYN_PASSPHRASE:-}"','export REDAKTYN_DEVICE_TOKEN="${REDAKTYN_DEVICE_TOKEN:-${REDAKTYN_TOKEN:-}}"','echo "Redaktyn: scanning staged changes..."',"set +e",o,"CODE=$?","set -e",'if [ "$CODE" -ne 0 ]; then',' echo "Redaktyn blocked this commit."'," exit 1","fi",'echo "Redaktyn: OK"',"exit 0",""].join(`
8
+ `);Ne(t,s,{mode:493}),Te(t,493),console.log("Installed pre-commit hook at",t)}function nt(){const{hookPath:e}=le();if(!W(e)){console.log("No pre-commit hook found.");return}if(!D(e,"utf8").includes(Y)){console.log("pre-commit exists but is not a Redaktyn hook \u2014 not removing.");return}ve(e),console.log("Removed Redaktyn pre-commit hook at",e)}function L(){console.log(`
6
9
  redaktyn-scan \u2014 Redaktyn digest scanner (ZK: HMAC hex batches only)
7
10
 
8
11
  Commands:
9
12
  (default) --staged | --file <path> | --dir <path>
10
13
  status Show connection + registered fingerprint count
11
- install-hooks Write .git/hooks/pre-commit
12
- uninstall-hooks Clear Redaktyn pre-commit hook
14
+ install-hooks Write this repository's pre-commit hook (never over one that is not Redaktyn's)
15
+ uninstall-hooks Remove the Redaktyn pre-commit hook
13
16
 
14
17
  Flags:
15
18
  --staged Scan git staged additions (git diff --cached)
@@ -23,4 +26,4 @@ Flags:
23
26
 
24
27
  Environment:
25
28
  REDAKTYN_SERVER_URL, REDAKTYN_PASSPHRASE, REDAKTYN_DEVICE_TOKEN (or REDAKTYN_TOKEN)
26
- `)}ue().catch(e=>{console.error(e?.message||e),process.exit(2)});
29
+ `)}tt().catch(e=>{console.error(ce(e)),process.exit(2)});
@@ -1,7 +1,8 @@
1
- import{createHash as U,randomBytes as k,webcrypto as g}from"node:crypto";import{existsSync as w,mkdirSync as C,readFileSync as v,rmSync as O,writeFileSync as K,chmodSync as D,renameSync as P}from"node:fs";import{homedir as F,hostname as j}from"node:os";import{join as p}from"node:path";const $=20*60*60*1e3,S=32,L="https://redaktyn.com";function _(e){return(e||process.env.REDAKTYN_SERVER_URL||process.env.REDAKTYN_SERVER||L).replace(/\/+$/,"")}class i extends Error{}function h(){return process.env.REDAKTYN_CONFIG_DIR||(process.env.XDG_CONFIG_HOME?p(process.env.XDG_CONFIG_HOME,"redaktyn"):p(F(),".config","redaktyn"))}const E=()=>p(h(),"session.json"),W=()=>p(h(),"device-id");function G(){C(h(),{recursive:!0,mode:448});try{D(h(),448)}catch{}}function x(e,t){G();const n=`${e}.${process.pid}.tmp`;K(n,t,{mode:384});try{D(n,384)}catch{}P(n,e)}function Y(){const e=E();if(!w(e))return null;try{const t=JSON.parse(v(e,"utf8"));if(t&&t.token&&t.key&&t.serverUrl&&t.orgId)return t}catch{}return null}function b(e){x(E(),JSON.stringify(e,null,2)+`
2
- `)}function te(){const e=E();return w(e)&&O(e),!0}const T=()=>p(h(),"admin.json");function J(){const e=T();if(!w(e))return null;try{const t=JSON.parse(v(e,"utf8"));if(t&&t.token&&t.serverUrl)return t}catch{}return null}function I(e){x(T(),JSON.stringify(e,null,2)+`
3
- `)}function ne(){const e=T();return w(e)&&O(e),!0}function V(){const e=W();if(w(e)){const n=v(e,"utf8").trim();if(/^cli-[a-f0-9]{32}$/.test(n))return n}const t=`cli-${k(16).toString("hex")}`;return x(e,t+`
4
- `),t}const B=e=>Buffer.from(e.replace(/-/g,"+").replace(/_/g,"/"),"base64"),H=e=>Buffer.from(e).toString("base64").replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");function q(e){let t;try{t=new URL(String(e).trim())}catch{throw new i("That is not an invite link. It looks like https://\u2026/connect?code=\u2026#w=\u2026")}const n=t.searchParams.get("code"),r=new URLSearchParams(t.hash.replace(/^#/,"")).get("w");if(!n)throw new i("The link has no invite code.");if(!r)throw new i("The link has no key part (the bit after #w=). Copy the whole link, including everything after the #, and quote it so the shell does not cut it.");if(B(r).length!==S)throw new i("The key part of the link is the wrong length.");return{origin:t.origin,code:n,w:r}}async function X(e,t){if(!e||typeof e!="object"||e.v!==1&&e.v!==2||typeof e.iv!="string"||typeof e.ct!="string")throw new i("The server sent no sealed key for this invite.");const n=await g.subtle.importKey("raw",B(t),{name:"AES-GCM"},!1,["decrypt"]);try{const r=await g.subtle.decrypt({name:"AES-GCM",iv:Buffer.from(e.iv,"base64")},n,Buffer.from(e.ct,"base64")),o=Buffer.from(r);if(o.length!==S)throw new Error("length");return o}catch{throw new i("Could not open the sealed key with this link. The link may be incomplete or altered.")}}async function oe(e,t){const n=k(S),r=k(12),o=await g.subtle.importKey("raw",n,{name:"AES-GCM"},!1,["encrypt"]),c=Buffer.from(await g.subtle.encrypt({name:"AES-GCM",iv:r},o,e));return{w:H(n),blob:{v:t,iv:r.toString("base64"),ct:c.toString("base64")}}}const R=e=>e.replace(/\/+$/,"");async function l(e,t,n,{token:r,body:o}={}){let c;try{c=await fetch(`${R(e)}${n}`,{method:t,headers:{...r?{Authorization:`Bearer ${r}`}:{},...o?{"Content-Type":"application/json"}:{}},...o?{body:JSON.stringify(o)}:{}})}catch(d){throw new i(`Cannot reach ${R(e)}: ${d.message}`)}const s=await c.text();let a=null;try{a=s?JSON.parse(s):null}catch{a=null}return{status:c.status,ok:c.ok,data:a,text:s}}async function re(e,{serverUrl:t}={}){const{origin:n,code:r,w:o}=q(e),c=t||process.env.REDAKTYN_SERVER_URL||n,s=await l(c,"POST","/api/auth/connect",{body:{inviteCode:r,extensionId:V(),client:"cli"}});if(!s.ok){const y=s.data?.error||s.text.slice(0,200)||`HTTP ${s.status}`;throw new i(`The server refused the invite (${s.status}): ${y}`)}const a=s.data||{};if(!a.token||!a.keyBlob)throw new i("The server accepted the invite but sent no sealed key, so nothing could be set up. Ask for a new link.");const d=await X(a.keyBlob,o),f={serverUrl:R(c),orgId:a.orgId,orgName:a.orgName??null,projectId:a.projectId??null,projectName:a.projectName??null,projectUsesOrgKey:a.projectUsesOrgKey??null,canEnrol:a.canEnrol===!0,token:a.token,tokenIssuedAt:Date.now(),key:d.toString("hex"),keyFingerprint:U("sha256").update(d).digest("hex").slice(0,8),connectedAt:new Date().toISOString()};return b(f),f}async function ae(e=Y()){if(!e)throw new i("Not connected. Run: redaktyn-connect '<invite link>'");if(Date.now()-(e.tokenIssuedAt||0)<$)return e;const t=await l(e.serverUrl,"POST","/api/auth/refresh",{token:e.token});if(!t.ok||!t.data?.token){const r=t.data?.code,o=r==="ENROLLMENT_REVOKED"||r==="SESSION_REVOKED"?"This device was removed by an admin.":"The session could not be renewed (it may have passed its maximum age).";throw new i(`${o} Ask for a new invite link and run redaktyn-connect again.`)}const n={...e,token:t.data.token,tokenIssuedAt:Date.now()};return b(n),n}async function ie(e,t,{serverUrl:n}={}){const r=_(n),o=await l(r,"POST","/api/auth/login",{body:{email:e,password:t}});if(o.status===429)throw new i("Too many attempts for this account. Wait a few minutes and try again.");if(!o.ok||!o.data?.token)throw new i(o.status===401?"Wrong email or password.":`Login failed (${o.status}): ${o.data?.error||o.text.slice(0,160)}`);const c=o.data.user?.role;if(c!=="OWNER"&&c!=="ADMIN")throw new i(`This account is a ${c??"non-admin"}. Bulk import and invites need an OWNER or ADMIN login.`);const s={serverUrl:r,token:o.data.token,tokenIssuedAt:Date.now(),loggedInAt:new Date().toISOString(),email:o.data.user.email,role:c,orgId:o.data.org?.id??null,orgName:o.data.org?.name??null};return I(s),s}async function se(e=J()){if(!e)throw new i("Not logged in. Run: redaktyn-login");if(Date.now()-(e.tokenIssuedAt||0)<$)return e;const t=await l(e.serverUrl,"POST","/api/auth/refresh",{token:e.token});if(!t.ok||!t.data?.token)throw new i("Your login has expired. Run: redaktyn-login");const n={...e,token:t.data.token,tokenIssuedAt:Date.now()};return I(n),n}function ce(e){return new Promise(t=>{const n=process.stdin;if(!n.isTTY||typeof n.setRawMode!="function"){let s="";n.setEncoding("utf8"),n.on("data",a=>s+=a),n.on("end",()=>t(s.split(/\r?\n/)[0]??""));return}process.stderr.write(e);let r="";n.setRawMode(!0),n.resume(),n.setEncoding("utf8");const o=()=>{n.setRawMode(!1),n.pause(),n.off("data",c),process.stderr.write(`
5
- `),t(r)},c=s=>{for(const a of s){if(a==="\r"||a===`
6
- `||a==="")return o();a===""&&(n.setRawMode(!1),process.stderr.write(`
7
- `),process.exit(130)),a==="\x7F"||a==="\b"?r=r.slice(0,-1):r+=a}};n.on("data",c)})}async function ue({serverUrl:e,onCode:t,sleep:n=r=>new Promise(o=>setTimeout(o,r))}={}){const r=_(e),o=await l(r,"POST","/api/cli-login/start",{body:{clientName:j()}});if(o.status===429)throw new i("Too many sign-in requests. Wait a few minutes and try again.");if(!o.ok||!o.data?.deviceCode)throw new i(`Could not start a browser sign-in (${o.status}). This server may be older; use: redaktyn-login --password`);const{deviceCode:c,userCode:s,verificationUrl:a,expiresIn:d}=o.data;let f=(o.data.interval||2)*1e3;const y=Date.now()+(d||600)*1e3;t&&t({userCode:s,verificationUrl:a,expiresIn:d});let N=0;for(;Date.now()<y;){await n(f);let u;try{u=await l(r,"POST","/api/cli-login/poll",{body:{deviceCode:c}}),N=0}catch(M){if(++N>=5)throw M;continue}if(u.status===429){f=((u.data?.interval||5)+1)*1e3;continue}if(u.status===403)throw new i("The sign-in was denied in the browser.");if(u.status===410)throw new i("The sign-in request expired. Run the command again.");if(!u.ok)throw new i(`Sign-in failed (${u.status}): ${u.data?.error||u.text.slice(0,160)}`);if(u.data?.status!=="approved")continue;const m=u.data.user?.role;if(m!=="OWNER"&&m!=="ADMIN")throw new i(`That account is a ${m??"non-admin"}. Bulk import and invites need an OWNER or ADMIN.`);const A={serverUrl:r,token:u.data.token,tokenIssuedAt:Date.now(),loggedInAt:new Date().toISOString(),email:u.data.user.email,role:m,orgId:u.data.org?.id??null,orgName:u.data.org?.name??null};return I(A),A}throw new i("Timed out waiting for approval. Run the command again.")}export{L as DEFAULT_SERVER_URL,S as INVITE_KEY_BYTES,i as SessionError,$ as TOKEN_REFRESH_AFTER_MS,l as apiJson,H as bytesToB64u,re as connectWithInvite,ne as deleteAdminSession,te as deleteSession,V as deviceId,se as freshAdminSession,ae as freshSession,J as loadAdminSession,Y as loadSession,ue as loginWithBrowser,ie as loginWithPassword,q as parseInviteLink,ce as readHidden,_ as resolveServerUrl,I as saveAdminSession,b as saveSession,oe as sealInviteKey,h as sessionDir,X as unsealInviteKey};
1
+ import{createHash as Z,createHmac as Q,randomBytes as I,webcrypto as S}from"node:crypto";import{existsSync as T,mkdirSync as ee,readFileSync as x,rmSync as K,writeFileSync as te,chmodSync as F,renameSync as ne}from"node:fs";import{homedir as re,hostname as oe}from"node:os";import{join as k}from"node:path";const L=20*60*60*1e3,$=32,ae="https://redaktyn.com";function M(e){return(e||process.env.REDAKTYN_SERVER_URL||process.env.REDAKTYN_SERVER||ae).replace(/\/+$/,"")}class a extends Error{}function v(){return process.env.REDAKTYN_CONFIG_DIR||(process.env.XDG_CONFIG_HOME?k(process.env.XDG_CONFIG_HOME,"redaktyn"):k(re(),".config","redaktyn"))}const C=()=>k(v(),"session.json"),ie=()=>k(v(),"device-id");function se(){ee(v(),{recursive:!0,mode:448});try{F(v(),448)}catch{}}function R(e,t){se();const n=`${e}.${process.pid}.tmp`;te(n,t,{mode:384});try{F(n,384)}catch{}ne(n,e)}const H=()=>k(v(),"oprf-pins.json");function Te(){const e=()=>{try{const t=JSON.parse(x(H(),"utf8"));return t&&typeof t=="object"&&!Array.isArray(t)?t:{}}catch{return{}}};return{get(t,n){const o=e()[`${t}|${n}`];return typeof o=="string"?o:null},set(t,n,o){R(H(),JSON.stringify({...e(),[`${t}|${n}`]:o})+`
2
+ `)}}}function _(){const e=C();if(!T(e))return null;try{const t=JSON.parse(x(e,"utf8"));if(t&&t.token&&t.key&&t.serverUrl&&t.orgId)return t}catch{}return null}function W(e){R(C(),JSON.stringify(e,null,2)+`
3
+ `)}function Ne(){const e=C();return T(e)&&K(e),!0}const P=()=>k(v(),"admin.json");function ce(){const e=P();if(!T(e))return null;try{const t=JSON.parse(x(e,"utf8"));if(t&&t.token&&t.serverUrl)return t}catch{}return null}function b(e){R(P(),JSON.stringify(e,null,2)+`
4
+ `)}function Ee(){const e=P();return T(e)&&K(e),!0}function de(){const e=ie();if(T(e)){const n=x(e,"utf8").trim();if(/^cli-[a-f0-9]{32}$/.test(n))return n}const t=`cli-${I(16).toString("hex")}`;return R(e,t+`
5
+ `),t}const A=e=>Buffer.from(e.replace(/-/g,"+").replace(/_/g,"/"),"base64"),ue=e=>Buffer.from(e).toString("base64").replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");function le(e){const t=String(e).trim();for(const[n,o]of[['"','"'],["'","'"],["<",">"],["`","`"]])if(t.length>2&&t.startsWith(n)&&t.endsWith(o))return t.slice(1,-1).trim();return t}function D(e){let t;try{t=new URL(le(e))}catch{throw new a("That is not an invite link. It looks like https://\u2026/connect?code=\u2026#w=\u2026")}const n=t.searchParams.get("code"),o=new URLSearchParams(t.hash.replace(/^#/,"")),r=o.has("w2"),s=o.get("w2")??o.get("w");if(!n)throw new a("The link has no invite code.");if(!s)throw new a("The link has no key part (the bit after #w=). Copy the whole link, including everything after the #, and quote it so the shell does not cut it.");if(A(s).length!==$)throw new a("The key part of the link is the wrong length.");return{origin:t.origin,code:n,w:s,needsCode:r}}const U="0123456789ABCDEFGHJKMNPQRSTVWXYZ",N=16,Y=e=>String(e??"").toUpperCase().replace(/[^0-9A-Z]/g,"").replace(/O/g,"0").replace(/[IL]/g,"1");function J(e,t){const n=Q("sha256",e).update(`redaktyn-invite-code|${t}`).digest();return U[n[0]>>3]+U[(n[0]&7)<<2|n[1]>>6]}function Ie(e){return D(e).needsCode}function fe(e,t){const n=Y(t);return n.length!==N+2?!1:J(A(D(e).w),n.slice(0,N))===n.slice(N)}async function V(e,t,n){const o=await S.subtle.importKey("raw",e,"HKDF",!1,["deriveKey"]);return S.subtle.deriveKey({name:"HKDF",hash:"SHA-256",salt:Buffer.from("redaktyn-invite-wrap-v2"),info:Buffer.from(t)},o,{name:"AES-GCM",length:256},!1,[n])}async function he(e,t,n){if(!e||typeof e!="object"||e.v!==1&&e.v!==2||typeof e.iv!="string"||typeof e.ct!="string")throw new a("The server sent no sealed key for this invite.");if(e.s!==void 0&&e.s!==1)throw new a("This invite was made by a newer version. Update and try again.");if(e.s===1&&!n)throw new a("This invite needs the security code your admin sent with the link.");const o=e.s===1?await V(A(t),Y(n).slice(0,N),"decrypt"):await S.subtle.importKey("raw",A(t),{name:"AES-GCM"},!1,["decrypt"]);try{const r=await S.subtle.decrypt({name:"AES-GCM",iv:Buffer.from(e.iv,"base64")},o,Buffer.from(e.ct,"base64")),s=Buffer.from(r);if(s.length!==$)throw new Error("length");return s}catch{throw new a("Could not open the sealed key with this link. The link may be incomplete or altered.")}}async function xe(e,t){const n=I($),o=I(12);let r="";for(const p of I(N))r+=U[p&31];const s=(r+J(n,r)).match(/.{1,4}/g).join("-"),d=await V(n,r,"encrypt"),c=Buffer.from(await S.subtle.encrypt({name:"AES-GCM",iv:o},d,e));return{w:ue(n),code:s,blob:{v:t,s:1,iv:o.toString("base64"),ct:c.toString("base64")}}}const j=e=>e.replace(/\/+$/,"");async function m(e,t,n,{token:o,body:r,headers:s}={}){let d;try{d=await fetch(`${j(e)}${n}`,{method:t,headers:{...o?{Authorization:`Bearer ${o}`}:{},...r?{"Content-Type":"application/json"}:{},...s??{}},...r?{body:JSON.stringify(r)}:{}})}catch(y){const w=new a(`Cannot reach ${j(e)}: ${y.message}`);throw w.unreachable=!0,w}const c=await d.text();let p=null;try{p=c?JSON.parse(c):null}catch{p=null}return{status:d.status,ok:d.ok,data:p,text:c}}const pe=4e3,we=e=>new Promise(t=>setTimeout(t,e));async function me(e,t,n){n?.({expiresAt:t.expiresAt});const o=Date.parse(t.expiresAt);for(;Date.now()<o;){await we(pe);let r;try{r=await m(e,"GET",`/api/device-approvals/status/${encodeURIComponent(t.approvalId)}`,{headers:{"X-Approval-Secret":t.approvalSecret}})}catch{continue}if(r.status===404)break;if(r.data?.status==="APPROVED")return;if(r.data?.status==="REJECTED")throw new a("Your admin declined this device. Ask them for a new link if you think that was a mistake.");if(r.data?.status==="EXPIRED")break}throw new a("Your admin did not answer in time. Run the command again to ask again.")}async function ge(e,{serverUrl:t}={}){try{const{origin:n,code:o}=D(e),r=await m(t||process.env.REDAKTYN_SERVER_URL||n,"GET",`/api/auth/invite-info?code=${encodeURIComponent(o)}`);return!r.ok||!r.data?null:{assignedName:r.data.assignedName??null,orgId:typeof r.data.orgId=="string"?r.data.orgId:null,orgName:r.data.orgName??null,managed:r.data.managed===!0}}catch{return null}}function G(e,t){const n=o=>o.orgName?`the ${o.orgName} workspace`:"another workspace";return new a(`This machine is connected to ${n(e)}, and this link is for ${t.orgName?`the ${t.orgName} workspace`:"a different one"}. Connecting would replace that session, and ${e.orgName??"its"} repositories would no longer be checked on this machine. ${t.spent?"The link was used, but this machine kept its current session.":"The link was not used and still works."} To switch, run redaktyn-connect --switch with the link, or disconnect first (redaktyn-connect --logout, or "Redaktyn: Disconnect this device" in VS Code).`)}function X(e){const t=typeof e=="string"?e.trim().slice(0,100):"";return t.length>0?t:void 0}async function Re(e,{serverUrl:t,onWaiting:n,userName:o,inviteCode:r,replaceOtherWorkspace:s=!1}={}){const{origin:d,code:c,w:p,needsCode:y}=D(e);if(y&&!fe(e,r))throw new a(r?"That security code does not fit this link. Check it against what your admin sent. Nothing was used up.":"This link also needs the security code your admin sent with it.");const w=t||process.env.REDAKTYN_SERVER_URL||d,h=s?null:_();if(h?.orgId){const f=await ge(e,{serverUrl:w});if(f?.orgId?f.orgId!==h.orgId:!!(f?.orgName&&h.orgName&&f.orgName!==h.orgName))throw G(h,{orgName:f.orgName,spent:!1})}let i,u;for(let f=0;u=await m(w,"POST","/api/auth/connect",{body:{inviteCode:c,extensionId:de(),client:"cli",...X(o)?{userName:X(o)}:{},...i?{approval:i}:{}}}),u.status===202;f+=1){const g=u.data||{},O=g.approvalSecret??(i&&i.id===g.approvalId?i.secret:void 0);if(f>=3||!g.approvalId||!O||!g.expiresAt)throw new a("Your admin has not approved this device yet. Run the command again in a moment.");i={id:g.approvalId,secret:O},await me(w,{approvalId:i.id,approvalSecret:O,expiresAt:g.expiresAt},n)}if(u.status===409&&u.data?.code==="ALREADY_ON_PROJECT"){const f=String(u.data.error??"").match(/already protecting the (.+?) project\./)?.[1]??_()?.projectName;throw new a(`This machine is already connected to ${f?`the ${f} project`:"another project"}, and one machine covers one project for now. The link was not used and still works: ask your admin to remove this machine from ${f??"that project"}, then run redaktyn-connect with the same link again.`)}if(!u.ok){const f=u.data?.error||u.text.slice(0,200)||`HTTP ${u.status}`;throw new a(`The server refused the invite (${u.status}): ${f}`)}const l=u.data||{};if(!l.token||!l.keyBlob)throw new a("The server accepted the invite but sent no sealed key, so nothing could be set up. Ask for a new link.");if(h?.orgId&&l.orgId&&l.orgId!==h.orgId)throw G(h,{orgName:l.orgName??null,spent:!0});const E=await he(l.keyBlob,p,r),B={serverUrl:j(w),orgId:l.orgId,orgName:l.orgName??null,projectId:l.projectId??null,projectName:l.projectName??null,projectUsesOrgKey:l.projectUsesOrgKey??null,canEnrol:l.canEnrol===!0,token:l.token,tokenIssuedAt:Date.now(),key:E.toString("hex"),keyFingerprint:Z("sha256").update(E).digest("hex").slice(0,8),connectedAt:new Date().toISOString()};return W(B),B}function q(e){try{const t=JSON.parse(Buffer.from(String(e).split(".")[1],"base64url").toString("utf8")).exp;return typeof t=="number"&&t*1e3>Date.now()+6e4}catch{return!1}}const z=e=>e.status===401||e.status===403;async function Ae(e=_()){if(!e)throw new a("Not connected. Run: redaktyn-connect '<invite link>'");if(Date.now()-(e.tokenIssuedAt||0)<L)return e;const t=await m(e.serverUrl,"POST","/api/auth/refresh",{token:e.token});if(!t.ok||!t.data?.token){if(!z(t)){if(q(e.token))return e;throw new a(`The server could not renew this machine's session just now (HTTP ${t.status}). Try again in a few minutes; you do not need a new invite link.`)}const o=t.data?.code,r=o==="ENROLLMENT_REVOKED"||o==="SESSION_REVOKED"?"This device was removed by an admin.":"The session could not be renewed (it may have passed its maximum age).";throw new a(`${r} Ask for a new invite link and run redaktyn-connect again.`)}const n={...e,token:t.data.token,tokenIssuedAt:Date.now()};return W(n),n}async function De(e,t,{serverUrl:n}={}){const o=M(n),r=await m(o,"POST","/api/auth/login",{body:{email:e,password:t}});if(r.status===429)throw new a("Too many attempts for this account. Wait a few minutes and try again.");if(!r.ok||!r.data?.token)throw new a(r.status===401?"Wrong email or password.":`Login failed (${r.status}): ${r.data?.error||r.text.slice(0,160)}`);const s=r.data.user?.role;if(s!=="OWNER"&&s!=="ADMIN")throw new a(`This account is a ${s??"non-admin"}. Bulk import and invites need an OWNER or ADMIN login.`);const d={serverUrl:o,token:r.data.token,tokenIssuedAt:Date.now(),loggedInAt:new Date().toISOString(),email:r.data.user.email,role:s,orgId:r.data.org?.id??null,orgName:r.data.org?.name??null};return b(d),d}async function Oe(e=ce()){if(!e)throw new a("Not logged in. Run: redaktyn-login");if(Date.now()-(e.tokenIssuedAt||0)<L)return e;const t=await m(e.serverUrl,"POST","/api/auth/refresh",{token:e.token});if(!t.ok||!t.data?.token){if(z(t))throw new a("Your login has expired. Run: redaktyn-login");if(q(e.token))return e;throw new a(`The server could not renew your login just now (HTTP ${t.status}). Try again in a few minutes.`)}const n={...e,token:t.data.token,tokenIssuedAt:Date.now()};return b(n),n}function $e(e){return new Promise(t=>{const n=process.stdin;if(!n.isTTY||typeof n.setRawMode!="function"){let d="";n.setEncoding("utf8"),n.on("data",c=>d+=c),n.on("end",()=>t(d.split(/\r?\n/)[0]??""));return}process.stderr.write(e);let o="";n.setRawMode(!0),n.resume(),n.setEncoding("utf8");const r=()=>{n.setRawMode(!1),n.pause(),n.off("data",s),process.stderr.write(`
6
+ `),t(o)},s=d=>{for(const c of d){if(c==="\r"||c===`
7
+ `||c==="")return r();c===""&&(n.setRawMode(!1),process.stderr.write(`
8
+ `),process.exit(130)),c==="\x7F"||c==="\b"?o=o.slice(0,-1):o+=c}};n.on("data",s)})}async function Ce({serverUrl:e,onCode:t,sleep:n=o=>new Promise(r=>setTimeout(r,o))}={}){const o=M(e),r=await m(o,"POST","/api/cli-login/start",{body:{clientName:oe()}});if(r.status===429)throw new a("Too many sign-in requests. Wait a few minutes and try again.");if(!r.ok||!r.data?.deviceCode)throw new a(`Could not start a browser sign-in (${r.status}). This server may be older; use: redaktyn-login --password`);const{deviceCode:s,userCode:d,verificationUrl:c,expiresIn:p}=r.data;let y=(r.data.interval||2)*1e3;const w=Date.now()+(p||600)*1e3;t&&t({userCode:d,verificationUrl:c,expiresIn:p});let h=0;for(;Date.now()<w;){await n(y);let i;try{i=await m(o,"POST","/api/cli-login/poll",{body:{deviceCode:s}}),h=0}catch(E){if(++h>=5)throw E;continue}if(i.status===429){y=((i.data?.interval||5)+1)*1e3;continue}if(i.status===403)throw new a("The sign-in was denied in the browser.");if(i.status===410)throw new a("The sign-in request expired. Run the command again.");if(!i.ok)throw new a(`Sign-in failed (${i.status}): ${i.data?.error||i.text.slice(0,160)}`);if(i.data?.status!=="approved")continue;const u=i.data.user?.role;if(u!=="OWNER"&&u!=="ADMIN")throw new a(`That account is a ${u??"non-admin"}. Bulk import and invites need an OWNER or ADMIN.`);const l={serverUrl:o,token:i.data.token,tokenIssuedAt:Date.now(),loggedInAt:new Date().toISOString(),email:i.data.user.email,role:u,orgId:i.data.org?.id??null,orgName:i.data.org?.name??null};return b(l),l}throw new a("Timed out waiting for approval. Run the command again.")}export{ae as DEFAULT_SERVER_URL,$ as INVITE_KEY_BYTES,a as SessionError,L as TOKEN_REFRESH_AFTER_MS,m as apiJson,ue as bytesToB64u,X as cleanUserName,Re as connectWithInvite,Ee as deleteAdminSession,Ne as deleteSession,de as deviceId,Oe as freshAdminSession,Ae as freshSession,fe as inviteCodeFitsLink,ge as inviteInfo,Ie as linkNeedsInviteCode,ce as loadAdminSession,_ as loadSession,Ce as loginWithBrowser,De as loginWithPassword,Te as oprfPinStore,D as parseInviteLink,$e as readHidden,M as resolveServerUrl,b as saveAdminSession,W as saveSession,xe as sealInviteKey,v as sessionDir,he as unsealInviteKey};
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import{pathToFileURL as N}from"node:url";import{realpathSync as E}from"node:fs";import{createInterface as I}from"node:readline";import{createRequire as L}from"node:module";import{SessionError as n,apiJson as b,freshAdminSession as O,loadAdminSession as A,resolveServerUrl as C}from"./redaktyn-session.mjs";import{askWorkspaceKey as T}from"./redaktyn-passphrase.mjs";import{LOCK_HEADERS as P,lockNetwork as K}from"./redaktyn-lock-net.mjs";import{banner as B,k as U,rich as _,rows as H,say as d,step as u}from"./redaktyn-ui.mjs";const $=L(import.meta.url),D=$("./package.json").version;function Y(){console.log(`redaktyn-share: put your workspace's key lock behind a key server you run
3
+
4
+ new-key Make a key for the key-share server. Prints a SECRET key (keep it on that server only)
5
+ and the public key you give to "enable".
6
+ enable Move the lock onto your key share.
7
+ --url <https://...> Where your key-share server answers (https; http only for localhost)
8
+ --public-key <64 hex> Its public key. Compared with what the server itself says, so a wrong address cannot be enrolled.
9
+ --kid <id> Which key it is (default c1)
10
+ --yes Do not ask for confirmation
11
+ status Whether the workspace uses a key share, and which.
12
+ check Is the share reachable, and is it the key the lock names? No passphrase needed.
13
+ disable Move the lock back to Redaktyn's server alone.
14
+
15
+ --server <url> API address (default https://redaktyn.com; self-hosted only)
16
+
17
+ Needs: redaktyn-login as the workspace owner (or REDAKTYN_TOKEN), and the workspace passphrase for enable and disable.
18
+ If the share asks for a token: REDAKTYN_SHARE_TOKEN.`)}function F(s){const e={cmd:null,url:null,publicKey:null,kid:"c1",yes:!1,server:null,help:!1};for(let r=2;r<s.length;r++){const t=s[r],o=()=>{const a=s[++r];if(a===void 0||a.startsWith("--"))throw new n(`${t} needs a value`);return a};if(t==="--help"||t==="-h")e.help=!0;else if(t==="--url")e.url=o();else if(t==="--public-key")e.publicKey=o().toLowerCase();else if(t==="--kid")e.kid=o();else if(t==="--yes"||t==="-y")e.yes=!0;else if(t==="--server")e.server=o();else if(!t.startsWith("-")&&!e.cmd)e.cmd=t;else throw new n(`Unknown option ${t}. Try --help.`)}return e}function y(){try{return $("./redaktyn-shared.cjs")}catch{throw new n("Cannot load the shared code. From a checkout, run `npm install` then `npm run build -w @redaktyn/shared`; otherwise reinstall the CLI.")}}function W(s){try{return JSON.parse(Buffer.from(s.split(".")[1],"base64url").toString()).orgId}catch{return null}}function q(s){return new Promise(e=>{const r=I({input:process.stdin,output:process.stderr});r.question(s,t=>{r.close(),e(/^\s*y/i.test(t))})})}const p=s=>`${s.slice(0,8)}\u2026${s.slice(-8)}`;async function w(s,e){const r=await b(s,"GET","/api/org/key-lock",{token:e,headers:P});if(r.status===404)throw new n("This server does not support key locks.");if(!r.ok)throw new n(r.data?.error??`Could not read the workspace's key lock (HTTP ${r.status}).`);return r.data?.lock??null}async function x(s,e){const r={url:String(e.url??"").replace(/\/+$/,""),kid:e.kid,publicKey:String(e.publicKey??"")};if(!s.isKeyShare(r))throw new n("That is not a usable key share. It needs an https address (http only for localhost), a --kid of letters, digits, - or _, and --public-key as 64 lowercase hex characters.");let t;try{t=await fetch(`${r.url}/`,{signal:AbortSignal.timeout(15e3)})}catch{throw new n(`Could not reach ${new URL(r.url).host}. Is the key-share server running at that address?`)}const o=await t.json().catch(()=>null);if(!t.ok||typeof o?.publicKey!="string")throw new n(`${new URL(r.url).host} did not answer like a key-share server (HTTP ${t.status}).`);if(o.publicKey.toLowerCase()!==r.publicKey)throw new n(`The server at ${new URL(r.url).host} says its public key is ${p(o.publicKey)}, not ${p(r.publicKey)}. Nothing was changed. Check the address and the key, in case the address points at the wrong server.`);return r}async function f(s){const e=process.env.REDAKTYN_TOKEN,r=e?null:await O(A()),t=s.server||(r&&!e?r.serverUrl:null)||C(),o=e||r.token,a=W(o);if(!a)throw new n("That token carries no org id.");const c=r?`Signed in as ${r.email}${r.orgName?` (${r.orgName})`:""}.`:null;return{server:t,token:o,orgId:a,who:c}}async function M(){const s=y(),e=s.oprfGenerateSecretKey(),r=Buffer.from(e).toString("hex"),t=Buffer.from(s.oprfPublicKey(e)).toString("hex");return console.log(`Secret key (keep it on your key-share server only; never give it to Redaktyn or paste it anywhere else):
19
+
20
+ OPRF_SHARE_KEY=${r}
21
+
22
+ Public key (this is what you give to "redaktyn-share enable"; it is not secret):
23
+
24
+ ${t}
25
+
26
+ Set OPRF_SHARE_KEY as a secret on the server (see infra/oprf-share/README.md), deploy it, then:
27
+
28
+ redaktyn-share enable --url https://<your server> --public-key ${t}
29
+
30
+ Back the secret key up in two places first. If it is lost while the share is on, nothing can open the workspace:
31
+ recovery codes do not help (they return the passphrase, not this key). The way back is the owner's reset, which makes a new
32
+ workspace key and means every secret is registered again.`),0}async function j(s){const e=y(),{server:r,token:t}=await f(s),o=await w(r,t);return o?o.v!==2?(console.log("No key share. This workspace's key opens with Redaktyn's server and the passphrase."),0):(console.log(`Key share ON
33
+ address ${o.share.url}
34
+ key id ${o.share.kid}
35
+ public key ${o.share.publicKey}
36
+ Opening this workspace needs your key-share server as well as Redaktyn's.`),0):(console.log("This workspace does not use a key lock, so a key share does not apply to it."),0)}async function G(s){const e=y(),{server:r,token:t}=await f(s),o=await w(r,t);if(!o||o.v!==2)return console.log("No key share is on for this workspace."),0;const a=u(`Asking ${new URL(o.share.url).host} to answer one blinded element`),{blinded:c}=e.oprfBlind(new TextEncoder().encode("redaktyn-share check")),l=K({server:r,token:t},e);try{const h=await l.evaluateShare({share:o.share,blinded:Buffer.from(c).toString("base64")});e.checkShareAnswer(o.share.publicKey,c,h)}catch(h){throw a.fail("The key share did not answer as the lock requires"),h instanceof n?h:new n(h.message)}return a.succeed("Reachable"),console.log(`Key share OK: ${new URL(o.share.url).host} proved it holds the key the lock names (${p(o.share.publicKey)}).`),0}async function R({shared:s,ctx:e,passphrase:r,orgKey:t,share:o}){const a=K(e,s),c=u(o?"Locking the workspace key behind your key share":"Locking the workspace key with Redaktyn's server alone");let l;try{l=await s.lockOrgKey(t,s.splitLockCredential(r).passphrase,e.orgId,a,o)}catch(i){throw c.fail("Could not lock the key"),i instanceof n?i:new n(i.message)}c.succeed("Locked");const h=u("Checking the new lock opens, before saving it");try{const i=await s.unlockOrgKey(l,s.splitLockCredential(r).passphrase,e.orgId,a);if(!i||Buffer.compare(Buffer.from(i),Buffer.from(t))!==0)throw new Error("it did not open to the same key")}catch(i){throw h.fail("The new lock did not open"),new n(`The new lock did not open when it was tried here (${i.message}), so it was not saved. Nothing was changed.`)}h.succeed("Opens to the same key");const S=s.computePassphraseVerifyHmac(t),g=u("Saving it"),k=await b(e.server,"PUT","/api/org/key-lock/replace",{token:e.token,body:{lock:l,verifier:S}});if(!k.ok)throw g.fail("The server did not accept the new lock"),new n(k.status===402?"A customer-held key share is an Enterprise feature. Nothing was changed.":k.status===403?"Only the workspace owner can change how its key is locked. Nothing was changed.":`${k.data?.error??`HTTP ${k.status}`} Nothing was changed.`);g.succeed("Saved");const m=u("Checking the saved lock opens");try{const i=await w(e.server,e.token),v=await s.unlockOrgKey(i,s.splitLockCredential(r).passphrase,e.orgId,a);if(!v||Buffer.compare(Buffer.from(v),Buffer.from(t))!==0)throw new Error("it did not open to the same key")}catch(i){throw m.fail("The saved lock did not open"),new n(`The lock was saved but the server's copy did not open (${i.message}). Run: redaktyn-share check . If it still will not open, the way back is the owner's reset in the dashboard, which registers every secret again.`)}m.succeed("Opens to the same key")}async function J(s){if(!s.url||!s.publicKey)throw new n("enable needs --url and --public-key. Run: redaktyn-share new-key to make them.");const e=y(),r=await x(e,s),t=await f(s);if(!await w(t.server,t.token))throw new n("This workspace is not key-locked, so a key share cannot be added. Nothing was changed.");H([["Key share",r.url],["Key id",r.kid],["Public key",p(r.publicKey)]]);const a="From now on this workspace opens only while that server answers. If its secret key is ever lost, recovery codes will not help: the way back is the owner's reset, which registers every secret again.";if(d(_?` ${U.dim(a)}`:a),!s.yes){if(!process.stdin.isTTY)throw new n("Run this in a terminal, or add --yes.");if(!await q(" Have you kept two copies of that secret key, and want to move the lock behind this key share? [y/N] "))throw new n("Nothing was changed.")}const{passphrase:c,orgKey:l}=await T({server:t.server,token:t.token,orgId:t.orgId,who:t.who,record:!1});return await R({shared:e,ctx:t,passphrase:c,orgKey:l,share:r}),d(""),d("Done. Redaktyn can no longer open this workspace's key without your key-share server."),0}async function V(s){const e=y(),r=await f(s),t=await w(r.server,r.token);if(!t||t.v!==2)return console.log("No key share is on for this workspace."),0;const{passphrase:o,orgKey:a}=await T({server:r.server,token:r.token,orgId:r.orgId,who:r.who,record:!1});return await R({shared:e,ctx:r,passphrase:o,orgKey:a,share:void 0}),d(""),d("Done. The workspace key opens with Redaktyn's server and the passphrase again."),0}async function z(s){const e=F(s);if(e.help||!e.cmd)return Y(),e.help?0:1;switch(e.cmd!=="new-key"&&B("key share",D),e.cmd){case"new-key":return M();case"status":return j(e);case"check":return G(e);case"enable":return J(e);case"disable":return V(e);default:throw new n(`Unknown command "${e.cmd}". Try --help.`)}}function Q(){try{return E(process.argv[1])}catch{return process.argv[1]}}process.argv[1]&&import.meta.url===N(Q()).href&&z(process.argv).then(s=>process.exit(s),s=>{console.error(s.message),process.exit(s instanceof n?1:2)});export{z as main};