@pinchpass/cli 1.1.0 → 1.2.0
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 +28 -4
- package/dist/args.js +30 -0
- package/dist/binary.js +124 -0
- package/dist/env.js +62 -0
- package/dist/index.js +317 -269
- package/dist/json-stream.js +48 -0
- package/package.json +24 -12
- package/scripts/install-binary.js +189 -75
- package/skills/pinchpass/SKILL.md +23 -4
package/README.md
CHANGED
|
@@ -14,8 +14,26 @@ in the URL fragment.
|
|
|
14
14
|
npm i -g @pinchpass/cli
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
```sh
|
|
18
|
+
brew install --cask rubybear-lgtm/tap/pinchpass
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The npm postinstall downloads the prebuilt `pinchpass` binary to `~/.local/bin`.
|
|
22
|
+
It is version-aware: when the binary it finds (on `PATH`, or in `~/.local/bin`,
|
|
23
|
+
`~/.openclaw/bin`, `~/.pi/bin`) does not report the package's version, it
|
|
24
|
+
downloads the matching release asset and replaces it. Downloads are verified
|
|
25
|
+
against the release's `checksums.txt`. Set `PINCHPASS_SKIP_DOWNLOAD=1` to skip.
|
|
26
|
+
|
|
27
|
+
## Updating
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pinchpass --version # pinchpass v1.2.0 (abc1234)
|
|
31
|
+
npm update -g @pinchpass/cli # re-runs the postinstall
|
|
32
|
+
brew upgrade --cask pinchpass
|
|
33
|
+
go install github.com/rubybear-lgtm/pinchpass@latest
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The extension warns when the binary it resolves is older than the package.
|
|
19
37
|
|
|
20
38
|
## Pi (pi.dev)
|
|
21
39
|
|
|
@@ -30,10 +48,15 @@ That registers:
|
|
|
30
48
|
- **`request_secret` tool** — two collection modes:
|
|
31
49
|
- `via: "modal"` (default in the TUI): a Pi dialog collects each secret
|
|
32
50
|
directly into `.env` — the value never reaches the LLM, never enters the
|
|
33
|
-
transcript, and no server is started. Cancelable with `Esc`.
|
|
51
|
+
transcript, and no server is started. Cancelable with `Esc`. `ttl` does not
|
|
52
|
+
apply here and is ignored.
|
|
34
53
|
- `via: "link"`: spawns the CLI, streams the one-time E2E-encrypted link to
|
|
35
|
-
the user immediately, blocks until they submit or the TTL expires.
|
|
54
|
+
the user immediately, blocks until they submit or the TTL expires. The URL
|
|
55
|
+
(including its `#k=` decryption-key fragment) is shown to the model and
|
|
56
|
+
stays in the transcript, so prefer the modal when the secret is sensitive.
|
|
36
57
|
- `via` defaults to `"auto"`: modal in the TUI, link everywhere else.
|
|
58
|
+
- `tunnel: true` always uses the link, even in the TUI — the modal cannot
|
|
59
|
+
serve a remote user.
|
|
37
60
|
- **`pinchpass` skill** — full usage guidance for the model.
|
|
38
61
|
|
|
39
62
|
The binary is resolved from `PATH`, `~/.local/bin`, `~/.openclaw/bin`, and
|
|
@@ -58,6 +81,7 @@ pinchpass request <secret-name>... [flags]
|
|
|
58
81
|
| `-out` | `.env` | Output .env file path |
|
|
59
82
|
| `-ttl` | 30 | Minutes until the link expires |
|
|
60
83
|
| `-port` | random | Local port to bind |
|
|
84
|
+
| `-listen-addr` | `127.0.0.1` | Address to listen on |
|
|
61
85
|
| `-json` | false | Machine-readable JSON output |
|
|
62
86
|
|
|
63
87
|
## License
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Tool/CLI argument types and translation to pinchpass CLI flags. */
|
|
2
|
+
/**
|
|
3
|
+
* Decide how to collect a secret.
|
|
4
|
+
*
|
|
5
|
+
* A tunnel or an explicit `link` request always uses the browser link, since
|
|
6
|
+
* the modal cannot leave the local machine. The modal is only reachable from
|
|
7
|
+
* the interactive TUI; everywhere else (and for `via: "auto"`) the link wins.
|
|
8
|
+
*/
|
|
9
|
+
export function chooseVia(via, tunnel, mode) {
|
|
10
|
+
const requested = via ?? "auto";
|
|
11
|
+
const forceLink = requested === "link" || tunnel === true;
|
|
12
|
+
const modal = !forceLink && mode === "tui";
|
|
13
|
+
return {
|
|
14
|
+
via: modal ? "modal" : "link",
|
|
15
|
+
downgraded: requested === "modal" && !modal,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Build the `pinchpass request ...` argv for the given tool arguments. */
|
|
19
|
+
export function buildArgs(params) {
|
|
20
|
+
const args = ["request", ...params.names, "-json"];
|
|
21
|
+
if (params.tunnel)
|
|
22
|
+
args.push("-tunnel");
|
|
23
|
+
if (params.note)
|
|
24
|
+
args.push("-note", params.note);
|
|
25
|
+
if (typeof params.ttl === "number")
|
|
26
|
+
args.push("-ttl", String(params.ttl));
|
|
27
|
+
if (params.out)
|
|
28
|
+
args.push("-out", params.out);
|
|
29
|
+
return args;
|
|
30
|
+
}
|
package/dist/binary.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Locate the pinchpass binary without requiring PATH configuration. */
|
|
2
|
+
import { accessSync, constants, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
/** Real binaries are multi-MB; a failed download can leave a tiny stub behind. */
|
|
7
|
+
export const MIN_VALID_BYTES = 100 * 1024;
|
|
8
|
+
/** Extra directories where the postinstall script may have placed the binary. */
|
|
9
|
+
export const FALLBACK_DIRS = [
|
|
10
|
+
join(homedir(), ".local", "bin"),
|
|
11
|
+
join(homedir(), ".openclaw", "bin"),
|
|
12
|
+
join(homedir(), ".pi", "bin"),
|
|
13
|
+
];
|
|
14
|
+
export const BINARY_MISSING = `The pinchpass binary is not installed. Install it with one of:
|
|
15
|
+
|
|
16
|
+
npm i -g @pinchpass/cli # downloads the prebuilt binary for your platform
|
|
17
|
+
go install github.com/rubybear-lgtm/pinchpass@latest
|
|
18
|
+
|
|
19
|
+
Then try request_secret again.`;
|
|
20
|
+
export function binaryName(platform = process.platform) {
|
|
21
|
+
return platform === "win32" ? "pinchpass.exe" : "pinchpass";
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Search explicit directories for an executable `pinchpass`.
|
|
25
|
+
*
|
|
26
|
+
* A candidate that looks like a real binary (>= MIN_VALID_BYTES) wins over one
|
|
27
|
+
* that does not, so a stale stub earlier in PATH cannot shadow a good install.
|
|
28
|
+
*/
|
|
29
|
+
export function findBinaryIn(dirs, platform = process.platform) {
|
|
30
|
+
const name = binaryName(platform);
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const executable = [];
|
|
33
|
+
for (const dir of dirs) {
|
|
34
|
+
if (!dir || seen.has(dir))
|
|
35
|
+
continue;
|
|
36
|
+
seen.add(dir);
|
|
37
|
+
const candidate = join(dir, name);
|
|
38
|
+
try {
|
|
39
|
+
accessSync(candidate, constants.X_OK);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
executable.push(candidate);
|
|
45
|
+
}
|
|
46
|
+
for (const candidate of executable) {
|
|
47
|
+
try {
|
|
48
|
+
if (statSync(candidate).size >= MIN_VALID_BYTES)
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// keep looking
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return executable[0] ?? null;
|
|
56
|
+
}
|
|
57
|
+
/** Find pinchpass on PATH, then in the known install directories. */
|
|
58
|
+
export function findBinary() {
|
|
59
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
60
|
+
const pathDirs = (process.env.PATH ?? "").split(sep).filter(Boolean);
|
|
61
|
+
return findBinaryIn([...pathDirs, ...FALLBACK_DIRS]);
|
|
62
|
+
}
|
|
63
|
+
const PACKAGE_NAME = "@pinchpass/cli";
|
|
64
|
+
const UPDATE_HINT = `npm i -g ${PACKAGE_NAME} (or go install github.com/rubybear-lgtm/pinchpass@latest)`;
|
|
65
|
+
/** The version of this npm package, read from its own package.json. */
|
|
66
|
+
export function packageVersion() {
|
|
67
|
+
try {
|
|
68
|
+
const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
69
|
+
return JSON.parse(raw).version ?? null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Extract x.y.z from `pinchpass --version` output (null for older builds). */
|
|
76
|
+
export function parseVersion(text) {
|
|
77
|
+
const match = /(?:^|\s)v?(\d+\.\d+\.\d+)(?=\s|$)/.exec(text ?? "");
|
|
78
|
+
return match ? match[1] : null;
|
|
79
|
+
}
|
|
80
|
+
/** Numeric x.y.z comparison. Returns -1, 0, or 1. */
|
|
81
|
+
export function compareVersions(a, b) {
|
|
82
|
+
const pa = a.split(".").map(Number);
|
|
83
|
+
const pb = b.split(".").map(Number);
|
|
84
|
+
for (let i = 0; i < 3; i++) {
|
|
85
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
86
|
+
if (diff !== 0)
|
|
87
|
+
return diff < 0 ? -1 : 1;
|
|
88
|
+
}
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
const versionCache = new Map();
|
|
92
|
+
/** The version a binary reports, or null when it predates --version. */
|
|
93
|
+
export function binaryVersion(bin) {
|
|
94
|
+
if (versionCache.has(bin))
|
|
95
|
+
return versionCache.get(bin) ?? null;
|
|
96
|
+
let result = null;
|
|
97
|
+
try {
|
|
98
|
+
const out = execFileSync(bin, ["--version"], {
|
|
99
|
+
encoding: "utf8",
|
|
100
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
101
|
+
timeout: 5000,
|
|
102
|
+
});
|
|
103
|
+
result = parseVersion(out);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
result = null;
|
|
107
|
+
}
|
|
108
|
+
versionCache.set(bin, result);
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
/** A one-line update hint when the resolved binary is older than the package. */
|
|
112
|
+
export function updateNotice(bin) {
|
|
113
|
+
const pkg = packageVersion();
|
|
114
|
+
if (!pkg)
|
|
115
|
+
return null;
|
|
116
|
+
const installed = binaryVersion(bin);
|
|
117
|
+
if (!installed) {
|
|
118
|
+
return `this pinchpass binary predates version reporting — update it: ${UPDATE_HINT}`;
|
|
119
|
+
}
|
|
120
|
+
if (compareVersions(installed, pkg) < 0) {
|
|
121
|
+
return `pinchpass v${installed} is older than this package (v${pkg}) — update it: ${UPDATE_HINT}`;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.env` writing helpers for the modal collection path.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `store.EnvStore.Save` in the Go CLI: values are written as
|
|
5
|
+
* `KEY="value"` with shell-safe escaping, and an existing key is replaced in
|
|
6
|
+
* place. Writes go through a temp file + rename so a crash or a concurrent
|
|
7
|
+
* reader never observes a half-written file.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
/** Shell-escape a value for a double-quoted .env line (mirrors store.go). */
|
|
13
|
+
export function escapeEnvValue(s) {
|
|
14
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
|
|
15
|
+
}
|
|
16
|
+
/** Render the file contents with `key` set to `value`, replacing any existing line. */
|
|
17
|
+
export function renderEnv(existing, key, value) {
|
|
18
|
+
const line = `${key}="${escapeEnvValue(value)}"`;
|
|
19
|
+
const out = [];
|
|
20
|
+
let replaced = false;
|
|
21
|
+
for (const l of existing.split("\n")) {
|
|
22
|
+
const t = l.trim();
|
|
23
|
+
if (t === "")
|
|
24
|
+
continue;
|
|
25
|
+
if (t.startsWith(key + "=")) {
|
|
26
|
+
out.push(line);
|
|
27
|
+
replaced = true;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
out.push(l);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!replaced)
|
|
34
|
+
out.push(line);
|
|
35
|
+
return out.join("\n") + "\n";
|
|
36
|
+
}
|
|
37
|
+
/** Write/update a single KEY="value" line in a .env file. */
|
|
38
|
+
export function writeEnvKey(path, key, value) {
|
|
39
|
+
const dir = dirname(path);
|
|
40
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
let existing = "";
|
|
42
|
+
try {
|
|
43
|
+
existing = readFileSync(path, "utf8");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// new file
|
|
47
|
+
}
|
|
48
|
+
const tmp = join(dir, `.pinchpass-${randomBytes(6).toString("hex")}.tmp`);
|
|
49
|
+
try {
|
|
50
|
+
writeFileSync(tmp, renderEnv(existing, key, value), { mode: 0o600 });
|
|
51
|
+
renameSync(tmp, path);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
try {
|
|
55
|
+
unlinkSync(tmp);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// nothing to clean up
|
|
59
|
+
}
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|