@initx-plugin/cp 0.0.24 → 0.1.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/README.md +38 -10
- package/dist/index.d.mts +1 -11
- package/dist/index.d.ts +1 -11
- package/dist/index.mjs +6 -1
- package/package.json +12 -12
package/README.md
CHANGED
|
@@ -6,34 +6,62 @@
|
|
|
6
6
|
|
|
7
7
|
## @initx-plugin/cp
|
|
8
8
|
|
|
9
|
-
Copy plugin for `initx
|
|
9
|
+
Copy plugin for `initx`. Ships with built-in presets (SSH / GPG / CWD) and an optional GitHub-backed data store so you can copy private snippets from anywhere.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx initx plugin add cp
|
|
13
|
+
```
|
|
10
14
|
|
|
11
15
|
## Usage
|
|
12
16
|
|
|
17
|
+
### Built-in presets
|
|
18
|
+
|
|
13
19
|
```bash
|
|
14
|
-
npx initx
|
|
20
|
+
npx initx cp cwd # current working directory
|
|
21
|
+
npx initx cp ssh # SSH public key (picks from .ssh/ or interactively)
|
|
22
|
+
npx initx cp gpg # GPG public key
|
|
15
23
|
```
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
### GitHub-backed data store
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
Point `cp` at a private GitHub repo and a personal access token, then store named snippets as files. Any `cp <key>` that is not a built-in preset will look up the file from the repo and copy it to your clipboard. Files are cached locally under `~/.initx/cp/cache/`.
|
|
28
|
+
|
|
29
|
+
#### 1. Configure the store
|
|
30
|
+
|
|
31
|
+
Either walk through an interactive setup or set values one by one:
|
|
20
32
|
|
|
21
33
|
```bash
|
|
22
|
-
npx initx cp
|
|
34
|
+
npx initx cp-config setup
|
|
35
|
+
# or
|
|
36
|
+
npx initx cp-config set repo owner/private-repo
|
|
37
|
+
npx initx cp-config set token ghp_xxxxxxxxxxxxxxxxxxxx
|
|
38
|
+
# optional
|
|
39
|
+
npx initx cp-config set branch main # default: main
|
|
40
|
+
npx initx cp-config set path data # path inside the repo
|
|
23
41
|
```
|
|
24
42
|
|
|
25
|
-
|
|
43
|
+
`cp-config setup` prompts for each value in turn. Press Enter to keep the current value, type to overwrite. The token prompt is always masked.
|
|
44
|
+
|
|
45
|
+
Token is saved in plaintext at `~/.initx/cp/config.json`. Prefer a fine-grained PAT scoped to only the target repository.
|
|
46
|
+
|
|
47
|
+
#### 2. Add / fetch / remove data keys
|
|
26
48
|
|
|
27
49
|
```bash
|
|
28
|
-
npx initx cp
|
|
50
|
+
npx initx cp-config set my-token "abc 123"
|
|
51
|
+
npx initx cp my-token # copies "abc 123" to clipboard
|
|
52
|
+
npx initx cp-config get my-token
|
|
53
|
+
npx initx cp-config list
|
|
54
|
+
npx initx cp-config rm my-token
|
|
29
55
|
```
|
|
30
56
|
|
|
31
|
-
|
|
57
|
+
Each key becomes a file at `<path>/<key>` inside the repo, with the value as the file content. Keys cannot contain `/` and may not be `.` or `..`. On `ix cp <key>`, the cached value is used when available; otherwise the file is fetched from GitHub and cached.
|
|
58
|
+
|
|
59
|
+
#### 3. Inspect config
|
|
32
60
|
|
|
33
61
|
```bash
|
|
34
|
-
npx initx cp
|
|
62
|
+
npx initx cp-config status # shows config (token redacted)
|
|
35
63
|
```
|
|
36
64
|
|
|
37
65
|
## Documentation
|
|
38
66
|
|
|
39
|
-
[initx](https://github.com/initx-collective/initx)
|
|
67
|
+
[initx](https://github.com/initx-collective/initx)
|
package/dist/index.d.mts
CHANGED
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
import { InitxPlugin, InitxMatcherRules, InitxContext } from '@initx-plugin/core';
|
|
2
2
|
|
|
3
|
-
declare enum CpType {
|
|
4
|
-
SSH = "ssh",
|
|
5
|
-
GPG = "gpg",
|
|
6
|
-
CWD = "cwd"
|
|
7
|
-
}
|
|
8
|
-
|
|
9
3
|
declare class CpPlugin extends InitxPlugin {
|
|
10
4
|
rules: InitxMatcherRules;
|
|
11
|
-
handle(
|
|
12
|
-
[CpType.SSH](): Promise<void>;
|
|
13
|
-
[CpType.GPG](): Promise<void>;
|
|
14
|
-
[CpType.CWD](): Promise<void>;
|
|
15
|
-
private copy;
|
|
5
|
+
handle(ctx: InitxContext, ...args: string[]): Promise<void>;
|
|
16
6
|
}
|
|
17
7
|
|
|
18
8
|
export { CpPlugin as default };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
import { InitxPlugin, InitxMatcherRules, InitxContext } from '@initx-plugin/core';
|
|
2
2
|
|
|
3
|
-
declare enum CpType {
|
|
4
|
-
SSH = "ssh",
|
|
5
|
-
GPG = "gpg",
|
|
6
|
-
CWD = "cwd"
|
|
7
|
-
}
|
|
8
|
-
|
|
9
3
|
declare class CpPlugin extends InitxPlugin {
|
|
10
4
|
rules: InitxMatcherRules;
|
|
11
|
-
handle(
|
|
12
|
-
[CpType.SSH](): Promise<void>;
|
|
13
|
-
[CpType.GPG](): Promise<void>;
|
|
14
|
-
[CpType.CWD](): Promise<void>;
|
|
15
|
-
private copy;
|
|
5
|
+
handle(ctx: InitxContext, ...args: string[]): Promise<void>;
|
|
16
6
|
}
|
|
17
7
|
|
|
18
8
|
export { CpPlugin as default };
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
|
-
import{existsSync as
|
|
1
|
+
import{InitxPlugin as X}from"@initx-plugin/core";import{existsSync as p,readFileSync as C,writeFileSync as F,mkdirSync as P,renameSync as Q,unlinkSync as O,readdirSync as Z}from"node:fs";import ee,{homedir as U}from"node:os";import{resolve as h}from"node:path";import L,{cwd as te}from"node:process";import{logger as a,inquirer as l,gpgList as ne,c as re,loadingFunction as d}from"@initx-plugin/utils";import{writeText as ae}from"tinyclip";import{Buffer as K}from"node:buffer";const E=h(U(),".initx","cp"),R=h(E,"config.json");function oe(){return p(E)||P(E,{recursive:!0}),E}function w(){if(!p(R))return{};try{return JSON.parse(C(R,"utf8"))}catch{return{}}}function I(e){oe(),F(R,`${JSON.stringify(e,null,2)}
|
|
2
|
+
`,"utf8")}function ie(e){return w()[e]}function b(e,t){const n=w();n[e]=t,I(n)}function se(e){const t=w();delete t[e],I(t)}function y(){const e=w();if(!e.repo)throw new Error("cp-config repo is not set. Run `ix cp-config set repo <owner/repo>` first.");if(!e.token)throw new Error("cp-config token is not set. Run `ix cp-config set token <github_pat>` first.");return{repo:e.repo,token:e.token,branch:e.branch??"main",path:e.path??"data"}}class m extends Error{constructor(t,n,r){super(`GitHub API error: ${t} ${n}${r?` - ${r}`:""}`),this.status=t,this.statusText=n,this.body=r,this.name="GitHubApiError"}}const ce="initx-plugin-cp";function ue(e){return e.split("/").map(encodeURIComponent).join("/")}class fe{constructor(t,n,r="main"){this.repo=t,this.token=n,this.branch=r,this.validateRepo(t)}validateRepo(t){if(!t.includes("/"))throw new Error(`Invalid repo format: "${t}". Expected "owner/repo".`)}apiUrl(t){const[n,r]=this.repo.split("/",2);return`https://api.github.com/repos/${encodeURIComponent(n)}/${encodeURIComponent(r)}/contents/${ue(t)}?ref=${encodeURIComponent(this.branch)}`}headers(){return{Authorization:`Bearer ${this.token}`,Accept:"application/vnd.github+json","X-GitHub-Api-Version":"2022-11-28","User-Agent":ce}}async getFile(t){const n=await fetch(this.apiUrl(t),{headers:this.headers()});if(n.status===404)return null;if(!n.ok)throw new m(n.status,n.statusText,await n.text());const r=await n.json();if(r.type!=="file")throw new m(n.status,n.statusText,`Path "${t}" is not a file.`);return r}async listDir(t){const n=await fetch(this.apiUrl(t),{headers:this.headers()});if(n.status===404)return[];if(!n.ok)throw new m(n.status,n.statusText,await n.text());const r=await n.json();if(!Array.isArray(r))throw new m(n.status,n.statusText,`Path "${t}" is not a directory.`);return r}async putFile(t,n,r,o){const i={message:r??`cp: update ${t}`,content:K.from(n,"utf8").toString("base64"),branch:this.branch,...o?{sha:o}:{}},c=await fetch(this.apiUrl(t),{method:"PUT",headers:{...this.headers(),"Content-Type":"application/json"},body:JSON.stringify(i)});if(!c.ok)throw new m(c.status,c.statusText,await c.text())}async deleteFile(t,n,r){const o={message:r??`cp: delete ${t}`,sha:n,branch:this.branch},i=await fetch(this.apiUrl(t),{method:"DELETE",headers:{...this.headers(),"Content-Type":"application/json"},body:JSON.stringify(o)});if(!i.ok)throw new m(i.status,i.statusText,await i.text())}decodeContent(t){const n=t.content.replace(/\n/g,"");return K.from(n,t.encoding||"base64").toString("utf8")}}function v(){const e=y();return new fe(e.repo,e.token,e.branch)}const H=h(U(),".initx","cp","cache");function pe(){p(H)||P(H,{recursive:!0})}function S(e){return h(H,e)}function V(e){const t=S(e);if(!p(t))return null;try{return JSON.parse(C(t,"utf8"))}catch{return null}}function J(e,t,n){pe();const r={content:t,sha:n,fetchedAt:Date.now()};return F(S(e),JSON.stringify(r),"utf8"),r}function he(e){const t=S(e);p(t)&&O(t)}function le(e,t){const n=S(e);if(!p(n))return null;const r=V(e),o=S(t);try{Q(n,o)}catch{r&&J(t,r.content,r.sha),O(n)}return r}function k(e){if(!e)throw new Error("Key cannot be empty.");if(e.includes("/"))throw new Error(`Key cannot contain / or "${e}". Nested paths are not supported.`);if(e==="."||e==="..")throw new Error(`Key cannot be "${e}".`);if(de(e))throw new Error("Key cannot contain control characters.");if(e.length>200)throw new Error("Key is too long (max 200 characters).")}function de(e){for(let t=0;t<e.length;t++){const n=e.charCodeAt(t);if(n<32||n===127)return!0}return!1}function x(e,t){return k(e),`${t}/${e}`}async function Y(e,t){k(e);const n=v(),r=y(),o=x(e,r.path);{const u=V(e);if(u)return u.content}const i=await n.getFile(o);if(!i)return null;const c=n.decodeContent(i);return J(e,c,i.sha),c}async function B(){const e=v(),t=y();return(await e.listDir(t.path)).filter(n=>n.type==="file").map(n=>n.name)}async function ge(e){k(e);const t=v(),n=y(),r=x(e,n.path),o=await t.getFile(r);o&&await t.deleteFile(r,o.sha,`cp: delete ${e}`)}async function we(e,t){if(k(e),k(t),e===t)throw new Error(`Old and new keys are the same ("${e}"). Nothing to rename.`);const n=v(),r=y(),o=x(e,r.path),i=x(t,r.path),c=await n.getFile(o);if(!c)throw new Error(`Data key "${e}" not found.`);if(await n.getFile(i))throw new Error(`Data key "${t}" already exists. Remove it first if you want to overwrite.`);await n.putFile(i,c.content,`cp: rename ${e} -> ${t}`);try{const u=(await n.getFile(o))?.sha??c.sha;await n.deleteFile(o,u,`cp: rename ${e} -> ${t}`)}catch(u){throw new Error(`Renamed "${e}" to "${t}", but failed to delete the old file: ${u.message}. Both files now exist in the repo \u2014 run \`ix cp-config rm ${e}\` to clean up.`)}le(e,t)}async function ye(e,t){k(e);const n=v(),r=y(),o=x(e,r.path),i=await n.getFile(o);await n.putFile(o,t,`cp: set ${e}`,i?.sha),he(e)}const A=h(U(),".initx","cp"),D=h(A,"recent.json");function T(){if(!p(D))return{order:[]};try{const e=JSON.parse(C(D,"utf8"));return Array.isArray(e.order)?{order:e.order.filter(t=>typeof t=="string")}:{order:[]}}catch{return{order:[]}}}function j(e){p(A)||P(A,{recursive:!0}),F(D,`${JSON.stringify(e,null,2)}
|
|
3
|
+
`,"utf8")}function me(){return T().order}function N(e){const t=T(),n=t.order.indexOf(e);n!==-1&&t.order.splice(n,1),t.order.unshift(e),j(t)}function ke(e){const t=T(),n=t.order.indexOf(e);n!==-1&&(t.order.splice(n,1),j(t))}function $e(e,t){const n=T(),r=n.order.indexOf(e);r!==-1&&(n.order[r]=t,j(n))}var g=(e=>(e.SSH="ssh",e.GPG="gpg",e.CWD="cwd",e))(g||{}),f=(e=>(e.SET="set",e.GET="get",e.LIST="list",e.LS="ls",e.RM="rm",e.RENAME="rename",e.STATUS="status",e.SETUP="setup",e.HELP="help",e))(f||{});const be=["repo","token","branch","path"],_=new Set(Object.values(g)),ve={[g.SSH]:Ee,[g.GPG]:Te,[g.CWD]:Ge};async function q(e){const[t]=e;if(!t){a.error(`Please enter the copy type. Available presets: ${Object.values(g).join(", ")}`);return}if(_.has(t)){await xe(t);return}await Ce(t)}async function Se(){if(!L.stdin.isTTY){a.error("cp --list requires an interactive TTY.");return}const e=Object.values(g);let t=[];try{t=await d("Loading data keys",()=>B())}catch(s){a.warn(`Could not load data keys: ${s.message}`)}const n=new Set([...e,...t]),r=me().filter(s=>n.has(s)),o=new Set,i=[];for(const s of r)o.has(s)||(i.push(s),o.add(s));for(const s of[...t].sort())o.has(s)||(i.push(s),o.add(s));for(const s of e)o.has(s)||(i.push(s),o.add(s));if(i.length===0){a.warn("No copyable keys found.");return}const c=i.map(s=>{const z=_.has(s);return{name:s,value:s,description:z?"preset":"data"}}),u=await l.search("Select a key to copy",c);await q([u])}async function xe(e){const t=ve[e];if(!t){a.error(`Unknown preset: ${e}`);return}await t(),N(e)}async function Ce(e){try{const t=await d(`Fetching "${e}"`,()=>Y(e));if(t===null){a.error(`Data key "${e}" not found. Use \`ix cp-config set ${e} <value>\` to create it.`);return}await G(t),a.success(`Data key "${e}" copied to clipboard`),N(e)}catch(t){a.error(`Failed to fetch "${e}": ${t.message}`)}}async function G(e){await ae(e)}async function Ee(){const e=h(ee.homedir(),".ssh");if(!p(e)){a.error(`SSH directory not found, path: ${e}`);return}const t=Z(e).filter(i=>i.endsWith(".pub"));if(t.length===0){a.error("SSH key not found");return}let n;if(t.length===1)n=t[0];else{const i=await l.select("Select SSH key",t);n=t[i]}const r=h(e,n),o=C(r,"utf8");await G(o),a.success("Key copied to clipboard")}async function Te(){const e=await ne();if(e.length===0){a.error("No GPG keys found");return}let t=0;e.length>1&&(t=await l.select("Select GPG key",e.map(({key:o,name:i,email:c})=>`${o} - ${i} <${c}>`)));const{key:n}=e[t],r=await re("gpg",["--armor","--export",n]);await G(r.content),a.success("GPG public key copied to clipboard")}async function Ge(){await G(te()),a.success("Current working directory copied to clipboard")}const $=new Set(be);async function Fe(e){const[t,...n]=e;if(!t||f.HELP===t||t==="--help"||t==="-h"){W();return}switch(t){case f.SET:await Pe(n);break;case f.GET:await Ue(n[0]);break;case f.LIST:case f.LS:await Re();break;case f.RM:await He(n[0]);break;case f.RENAME:await Ae(n);break;case f.STATUS:M();break;case f.SETUP:await De();break;default:a.error(`Unknown command: ${t}`),W()}}async function Pe(e){const[t,...n]=e;if(!t){a.error("Usage: cp-config set <key> <value>");return}if($.has(t)){const o=n.join(" ").trim();if(!o&&t!=="token"&&t!=="branch"&&t!=="path"){a.error(`Usage: cp-config set ${t} <value>`);return}t==="token"&&a.warn("Token is saved in plaintext at ~/.initx/cp/config.json. Prefer a fine-grained GitHub PAT with minimal scopes."),b(t,o||(t==="branch"?"main":t==="path"?"data":"")),a.success(`Config "${t}" saved`);return}const r=n.join(" ");if(!r){a.error("Usage: cp-config set <key> <value>");return}try{await d(`Saving "${t}" to GitHub`,()=>ye(t,r)),a.success(`Data "${t}" saved`),N(t)}catch(o){a.error(`Failed to save data: ${o.message}`)}}async function Ue(e){if(!e){a.error("Usage: cp-config get <key>");return}if($.has(e)){const t=ie(e);if(!t){a.warn(`Config "${e}" is not set`);return}console.log(t);return}try{const t=await d(`Fetching "${e}" from GitHub`,()=>Y(e));if(t===null){a.error(`Data key "${e}" not found`);return}console.log(t)}catch(t){a.error(`Failed to get data: ${t.message}`)}}async function Re(){try{const e=await d("Listing data keys",()=>B());if(e.length===0){a.info("No data keys configured");return}console.log(e.join(`
|
|
4
|
+
`))}catch(e){a.error(`Failed to list data: ${e.message}`)}}async function He(e){if(!e){a.error("Usage: cp-config rm <key>");return}if($.has(e)){se(e),a.success(`Config "${e}" removed`);return}try{await d(`Removing "${e}" from GitHub`,()=>ge(e)),a.success(`Data "${e}" removed`),ke(e)}catch(t){a.error(`Failed to remove data: ${t.message}`)}}async function Ae(e){const[t,n]=e;if(!t||!n){a.error("Usage: cp-config rename <oldKey> <newKey>");return}if($.has(t)||$.has(n)){a.error(`Cannot rename reserved config keys (${Array.from($).join(", ")}).`);return}try{await d(`Renaming "${t}" to "${n}"`,()=>we(t,n)),$e(t,n),a.success(`Data "${t}" renamed to "${n}"`)}catch(r){a.error(`Failed to rename data: ${r.message}`)}}function M(){const e={...w()};typeof e.token=="string"&&e.token&&(e.token="***"),console.log(JSON.stringify(e,null,2))}async function De(){if(!L.stdin.isTTY){a.error("cp-config setup requires an interactive TTY. Use `cp-config set <key> <value>` instead.");return}const e=w();a.info("cp-config setup \u2014 press Enter to keep the current value, type to overwrite.");try{const t=e.repo?`GitHub repo (current: ${e.repo})`:"GitHub repo (owner/repo)",n=await l.input(t,{default:e.repo??void 0,required:!0,pattern:/^[^/\s]+\/[^/\s]+$/,patternError:'Repo must be in the form "owner/repo".'});b("repo",n),a.success('Config "repo" saved');const r=e.token?"GitHub PAT (Enter to keep current value, or paste a new token)":"GitHub PAT",o=await l.password(r,{mask:"*"});o&&o!==e.token?(a.warn("Token is saved in plaintext at ~/.initx/cp/config.json. Prefer a fine-grained GitHub PAT with minimal scopes."),b("token",o),a.success('Config "token" saved')):!o&&e.token?a.info("Token unchanged."):!o&&!e.token&&a.warn("Token not set. You can set it later via `cp-config set token <value>`.");const i=e.branch?`Branch (current: ${e.branch})`:"Branch",c=await l.input(i,{default:e.branch||"main"});b("branch",c||"main"),a.success('Config "branch" saved');const u=e.path?`Path inside repo (current: ${e.path})`:"Path inside repo",s=await l.input(u,{default:e.path||"data"});b("path",s||"data"),a.success('Config "path" saved'),console.log(`
|
|
5
|
+
Current config:`),M()}catch(t){a.warn(`Setup cancelled. ${t.message||""} Existing config left untouched.`)}}function W(){console.log(["Usage: cp-config <command> [args]","","Commands:"," setup Interactive setup for repo / token / branch / path"," set <key> <value> Set a config value (repo, token, branch, path) or a data key"," get <key> Get a config or data value"," list, ls List data keys"," rm <key> Remove a config or data key"," rename <old> <new> Rename a data key (file) without changing its value"," status Show config (token redacted)"," help Show this help","","Config keys: repo, token, branch, path. Anything else is treated as a data key.","","Examples:"," cp-config setup"," cp-config set repo owner/private-repo"," cp-config set token ghp_xxxxxxxxxxxx",' cp-config set my-secret "hello world"'," cp-config get my-secret"," cp-config list"," cp-config rm my-secret"," cp-config rename my-secret renamed-secret"].join(`
|
|
6
|
+
`))}class je extends X{rules=[{matching:"cp",description:"Copy SSH / GPG / CWD / data key to clipboard"},{matching:"cp-config",description:"Configure cp plugin (repo, token, branch, path) and data keys"}];async handle(t,...n){if(t.optionsList.includes("--list")){await Se();return}if(t.key==="cp-config"){await Fe(n);return}await q(n)}}export{je as default};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@initx-plugin/cp",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.1.1",
|
|
5
5
|
"description": "initx plugin, copy some text to clipboard",
|
|
6
6
|
"author": "imba97",
|
|
7
7
|
"license": "MIT",
|
|
@@ -28,20 +28,20 @@
|
|
|
28
28
|
"@initx-plugin/utils": "*"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"
|
|
31
|
+
"tinyclip": "^1.0.3"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@antfu/eslint-config": "^
|
|
35
|
-
"@initx-plugin/core": "^0.
|
|
36
|
-
"@initx-plugin/utils": "^0.
|
|
37
|
-
"@types/node": "^
|
|
38
|
-
"bumpp": "^
|
|
39
|
-
"eslint": "^10.
|
|
40
|
-
"nano-staged": "^0.
|
|
41
|
-
"simple-git-hooks": "^2.
|
|
42
|
-
"typescript": "
|
|
34
|
+
"@antfu/eslint-config": "^9.5.1",
|
|
35
|
+
"@initx-plugin/core": "^0.4.1",
|
|
36
|
+
"@initx-plugin/utils": "^0.4.3",
|
|
37
|
+
"@types/node": "^26.6.4",
|
|
38
|
+
"bumpp": "^12.3.0",
|
|
39
|
+
"eslint": "^10.12.0",
|
|
40
|
+
"nano-staged": "^1.0.2",
|
|
41
|
+
"simple-git-hooks": "^2.14.0",
|
|
42
|
+
"typescript": "6.0.3",
|
|
43
43
|
"unbuild": "^3.6.1",
|
|
44
|
-
"vitest": "^
|
|
44
|
+
"vitest": "^5.0.3"
|
|
45
45
|
},
|
|
46
46
|
"simple-git-hooks": {
|
|
47
47
|
"pre-commit": "pnpm nano-staged"
|