@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.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Incremental parser for the CLI's `-json` output.
3
+ *
4
+ * The CLI prints two pretty-printed JSON objects (before and after waiting),
5
+ * so a byte stream can split them across chunks. `takeJsonBlobs` returns every
6
+ * complete top-level object and leaves any partial trailing object in `rest`.
7
+ */
8
+ export function takeJsonBlobs(text) {
9
+ const blobs = [];
10
+ let depth = 0;
11
+ let start = -1;
12
+ let inString = false;
13
+ let escaped = false;
14
+ for (let i = 0; i < text.length; i++) {
15
+ const c = text[i];
16
+ if (inString) {
17
+ if (escaped)
18
+ escaped = false;
19
+ else if (c === "\\")
20
+ escaped = true;
21
+ else if (c === '"')
22
+ inString = false;
23
+ continue;
24
+ }
25
+ if (c === '"') {
26
+ inString = true;
27
+ continue;
28
+ }
29
+ if (c === "{") {
30
+ if (depth === 0)
31
+ start = i;
32
+ depth++;
33
+ }
34
+ else if (c === "}") {
35
+ depth = Math.max(0, depth - 1);
36
+ if (depth === 0 && start >= 0) {
37
+ try {
38
+ blobs.push(JSON.parse(text.slice(start, i + 1)));
39
+ }
40
+ catch {
41
+ // skip malformed output
42
+ }
43
+ start = -1;
44
+ }
45
+ }
46
+ }
47
+ return { blobs, rest: start >= 0 ? text.slice(start) : "" };
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinchpass/cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "One-time E2E-encrypted secret request links — native for Pi and OpenClaw agents",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,32 +24,44 @@
24
24
  "README.md"
25
25
  ],
26
26
  "pi": {
27
- "extensions": ["./dist/index.js"],
28
- "skills": ["./skills"]
27
+ "extensions": [
28
+ "./dist/index.js"
29
+ ],
30
+ "skills": [
31
+ "./skills"
32
+ ],
33
+ "image": "https://raw.githubusercontent.com/rubybear-lgtm/PinchPass/master/pinchpass.png"
29
34
  },
30
35
  "openclaw": {
31
- "extensions": ["./dist/index.js"]
36
+ "extensions": [
37
+ "./dist/index.js"
38
+ ]
32
39
  },
33
40
  "scripts": {
34
41
  "build": "tsc && node scripts/copy-skill.js",
42
+ "test": "npm run build && node --test \"test/**/*.test.js\"",
35
43
  "postinstall": "node scripts/install-binary.js",
36
44
  "prepublishOnly": "npm run build"
37
45
  },
38
- "dependencies": {
39
- "@opencode-ai/plugin": "^1.16.2"
40
- },
41
46
  "peerDependencies": {
42
47
  "@earendil-works/pi-ai": "*",
43
48
  "@earendil-works/pi-coding-agent": "*",
44
49
  "@earendil-works/pi-tui": "*",
50
+ "@opencode-ai/plugin": "^1.16.2",
45
51
  "typebox": "*"
46
52
  },
53
+ "peerDependenciesMeta": {
54
+ "@opencode-ai/plugin": {
55
+ "optional": true
56
+ }
57
+ },
47
58
  "devDependencies": {
48
- "@earendil-works/pi-ai": "*",
49
- "@earendil-works/pi-coding-agent": "^0.83.0",
50
- "@earendil-works/pi-tui": "*",
51
- "typebox": "^1.3.7",
52
- "typescript": "^5.9.0"
59
+ "@earendil-works/pi-ai": "^1.2.0",
60
+ "@earendil-works/pi-coding-agent": "^1.2.0",
61
+ "@earendil-works/pi-tui": "^1.2.0",
62
+ "@opencode-ai/plugin": "^1.19.0",
63
+ "typebox": "^1.3.37",
64
+ "typescript": "^7.0.2"
53
65
  },
54
66
  "publishConfig": {
55
67
  "access": "public"
@@ -1,27 +1,94 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Downloads the prebuilt pinchpass binary for the current platform.
3
+ * Install or update the pinchpass binary for the current platform.
4
4
  *
5
- * Install locations (first hit wins):
6
- * 1. If `pinchpass` is already on PATH, nothing to do.
7
- * 2. If it already exists in a known install dir, nothing to do.
8
- * 3. Install to ~/.local/bin (universal user bin — the PinchPass Pi
9
- * extension and OpenClaw plugin both find it there without PATH changes).
5
+ * npm runs this as a `postinstall`, so it must be idempotent and it must make
6
+ * `npm update -g @pinchpass/cli` actually upgrade the binary:
10
7
  *
11
- * The Pi extension resolves the binary from PATH, then from
12
- * ~/.local/bin, ~/.openclaw/bin, and ~/.pi/bin — so PATH is never required.
8
+ * 1. Look for an existing `pinchpass` (PATH, then the known install dirs).
9
+ * 2. If one already reports the package's version, do nothing.
10
+ * 3. Otherwise download the release asset pinned to the package version,
11
+ * verify it against the release's checksums.txt, and install it to
12
+ * ~/.local/bin.
13
+ *
14
+ * Set PINCHPASS_SKIP_DOWNLOAD=1 to skip the download entirely (used by CI).
13
15
  */
14
- import { execSync } from "child_process";
15
- import { chmodSync, existsSync, mkdirSync, statSync } from "fs";
16
- import { homedir } from "os";
17
- import { join } from "path";
16
+ import { createHash } from "node:crypto";
17
+ import { execFileSync } from "node:child_process";
18
+ import {
19
+ chmodSync,
20
+ existsSync,
21
+ mkdirSync,
22
+ readFileSync,
23
+ renameSync,
24
+ statSync,
25
+ unlinkSync,
26
+ writeFileSync,
27
+ } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { dirname, join } from "node:path";
30
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
31
 
19
- const VERSION = "latest";
20
32
  const REPO = "rubybear-lgtm/PinchPass";
21
33
 
22
- /** Real pinchpass binaries are multi-MB; "installed" stubs are tiny (e.g. a failed curl). */
34
+ /** Real binaries are multi-MB; a failed download leaves a tiny stub behind. */
23
35
  const MIN_VALID_BYTES = 100 * 1024;
24
36
 
37
+ const here = dirname(fileURLToPath(import.meta.url));
38
+
39
+ export function packageVersion() {
40
+ return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version;
41
+ }
42
+
43
+ export function binaryName(platform = process.platform) {
44
+ return platform === "win32" ? "pinchpass.exe" : "pinchpass";
45
+ }
46
+
47
+ export function assetName(platform = process.platform, arch = process.arch) {
48
+ const os = { darwin: "darwin", linux: "linux", win32: "windows" }[platform];
49
+ if (!os) throw new Error(`Unsupported platform: ${platform}`);
50
+ return `pinchpass-${os}-${arch === "arm64" ? "arm64" : "amd64"}`;
51
+ }
52
+
53
+ /** Extract an x.y.z version from `pinchpass --version` output. */
54
+ export function parseVersion(text) {
55
+ const match = /(?:^|\s)v?(\d+\.\d+\.\d+)(?=\s|$)/.exec(text ?? "");
56
+ return match ? match[1] : null;
57
+ }
58
+
59
+ /** Numeric x.y.z comparison. Returns -1, 0, or 1. */
60
+ export function compareVersions(a, b) {
61
+ const pa = a.split(".").map(Number);
62
+ const pb = b.split(".").map(Number);
63
+ for (let i = 0; i < 3; i++) {
64
+ const diff = (pa[i] || 0) - (pb[i] || 0);
65
+ if (diff !== 0) return diff < 0 ? -1 : 1;
66
+ }
67
+ return 0;
68
+ }
69
+
70
+ export function sha256(bytes) {
71
+ return createHash("sha256").update(bytes).digest("hex");
72
+ }
73
+
74
+ /** Find the SHA-256 for `asset` in a checksums.txt body. */
75
+ export function checksumFor(text, asset) {
76
+ for (const line of (text ?? "").split("\n")) {
77
+ const [hash, name] = line.trim().split(/\s+/);
78
+ if (name === asset && /^[0-9a-f]{64}$/i.test(hash)) return hash.toLowerCase();
79
+ }
80
+ return null;
81
+ }
82
+
83
+ export function knownDirs(home = homedir()) {
84
+ return [join(home, ".local", "bin"), join(home, ".openclaw", "bin"), join(home, ".pi", "bin")];
85
+ }
86
+
87
+ function searchDirs() {
88
+ const sep = process.platform === "win32" ? ";" : ":";
89
+ return [...(process.env.PATH ?? "").split(sep).filter(Boolean), ...knownDirs()];
90
+ }
91
+
25
92
  function isValidBinary(path) {
26
93
  try {
27
94
  return statSync(path).size >= MIN_VALID_BYTES;
@@ -30,78 +97,125 @@ function isValidBinary(path) {
30
97
  }
31
98
  }
32
99
 
33
- function detectBinary() {
34
- const arch = process.arch === "arm64" ? "arm64" : "amd64";
35
- const osMap = { darwin: "darwin", linux: "linux", win32: "windows" };
36
- const plat = osMap[process.platform];
37
- if (!plat) throw new Error(`Unsupported platform: ${process.platform}`);
38
- return { name: `pinchpass-${plat}-${arch}`, plat, arch };
39
- }
40
-
41
- function onPath(name) {
100
+ /** Read the version a binary reports; null for pre-1.2.0 builds (no --version). */
101
+ export function readBinaryVersion(bin) {
42
102
  try {
43
- const found = execSync(
44
- process.platform === "win32" ? `where ${name}` : `which ${name}`,
45
- { stdio: ["ignore", "pipe", "ignore"], shell: true },
46
- )
47
- .toString()
48
- .trim()
49
- .split(/\r?\n/)[0];
50
- return found || null;
103
+ const out = execFileSync(bin, ["--version"], {
104
+ encoding: "utf8",
105
+ stdio: ["ignore", "pipe", "ignore"],
106
+ timeout: 5000,
107
+ });
108
+ return parseVersion(out);
51
109
  } catch {
52
110
  return null;
53
111
  }
54
112
  }
55
113
 
56
- async function install() {
57
- const { name } = detectBinary();
58
- const exe = process.platform === "win32" ? ".exe" : "";
59
- const knownDirs = [
60
- join(homedir(), ".local", "bin"),
61
- join(homedir(), ".openclaw", "bin"),
62
- join(homedir(), ".pi", "bin"),
63
- ];
64
-
65
- // 1. Already on PATH?
66
- const onPathLocation = onPath(`pinchpass${exe}`);
67
- if (onPathLocation && isValidBinary(onPathLocation)) {
68
- console.log(`pinchpass already available on PATH at ${onPathLocation}`);
69
- return;
114
+ export function findExisting(name, dirs = searchDirs()) {
115
+ for (const dir of dirs) {
116
+ const candidate = join(dir, name);
117
+ if (existsSync(candidate) && isValidBinary(candidate)) return candidate;
70
118
  }
119
+ return null;
120
+ }
71
121
 
72
- // 2. Already installed in a known dir? (skip broken stubs from failed downloads)
73
- for (const dir of knownDirs) {
74
- const candidate = join(dir, `pinchpass${exe}`);
75
- if (existsSync(candidate) && isValidBinary(candidate)) {
76
- console.log(`pinchpass already installed at ${candidate}`);
77
- return;
122
+ async function fetchBytes(url) {
123
+ const res = await fetch(url, { redirect: "follow" });
124
+ if (!res.ok) return { ok: false, status: res.status };
125
+ return { ok: true, bytes: Buffer.from(await res.arrayBuffer()) };
126
+ }
127
+
128
+ /** Download the asset for `version`, falling back to `latest` if it is missing. */
129
+ export async function downloadAsset(asset, version) {
130
+ const versioned = `https://github.com/${REPO}/releases/download/v${version}/${asset}`;
131
+ const first = await fetchBytes(versioned);
132
+ if (first.ok) {
133
+ return { bytes: first.bytes, base: versioned.slice(0, -asset.length - 1) };
134
+ }
135
+
136
+ const latest = `https://github.com/${REPO}/releases/latest/download/${asset}`;
137
+ const second = await fetchBytes(latest);
138
+ if (second.ok) {
139
+ return { bytes: second.bytes, base: latest.slice(0, -asset.length - 1), fallback: true };
140
+ }
141
+ throw new Error(`could not download ${asset} (v${version}: ${first.status}, latest: ${second.status})`);
142
+ }
143
+
144
+ /** Returns "verified", "missing", or throws on a checksum mismatch. */
145
+ export async function verifyChecksum(asset, bytes, base) {
146
+ const res = await fetch(`${base}/checksums.txt`, { redirect: "follow" });
147
+ if (!res.ok) return "missing";
148
+ const expected = checksumFor(await res.text(), asset);
149
+ if (!expected) return "missing";
150
+ if (expected !== sha256(bytes)) throw new Error(`checksum mismatch for ${asset}`);
151
+ return "verified";
152
+ }
153
+
154
+ function writeExecutable(target, bytes) {
155
+ mkdirSync(dirname(target), { recursive: true });
156
+ const tmp = `${target}.${process.pid}.tmp`;
157
+ writeFileSync(tmp, bytes, { mode: 0o755 });
158
+ if (process.platform !== "win32") chmodSync(tmp, 0o755);
159
+ try {
160
+ renameSync(tmp, target);
161
+ } catch (err) {
162
+ try {
163
+ unlinkSync(tmp);
164
+ } catch {
165
+ // nothing to clean up
78
166
  }
167
+ throw err;
168
+ }
169
+ }
170
+
171
+ export async function install({ log = console.log, warn = console.warn } = {}) {
172
+ if (process.env.PINCHPASS_SKIP_DOWNLOAD) {
173
+ log("pinchpass: PINCHPASS_SKIP_DOWNLOAD set; skipping binary download");
174
+ return { installed: false, reason: "skipped" };
175
+ }
176
+
177
+ const version = packageVersion();
178
+ const name = binaryName();
179
+ const existing = findExisting(name);
180
+ const existingVersion = existing ? readBinaryVersion(existing) : null;
181
+
182
+ if (existing && existingVersion === version) {
183
+ log(`pinchpass ${version} already installed at ${existing}`);
184
+ return { installed: false, reason: "up-to-date", path: existing };
185
+ }
186
+
187
+ const asset = assetName();
188
+ const { bytes, base, fallback } = await downloadAsset(asset, version);
189
+ if (fallback) {
190
+ warn(`pinchpass: no release asset for v${version}; installed the latest release instead`);
79
191
  }
80
192
 
81
- // 3. Install to ~/.local/bin (universal user bin).
82
- const installDir = join(homedir(), ".local", "bin");
83
- const dest = join(installDir, `pinchpass${exe}`);
84
- const url =
85
- VERSION === "latest"
86
- ? `https://github.com/${REPO}/releases/latest/download/${name}`
87
- : `https://github.com/${REPO}/releases/download/v${VERSION}/${name}`;
88
-
89
- console.log(`Downloading pinchpass from ${url}...`);
90
-
91
- mkdirSync(installDir, { recursive: true });
92
- execSync(`curl -sL "${url}" -o "${dest}"`, { stdio: "inherit" });
93
- if (process.platform !== "win32") chmodSync(dest, 0o755);
94
-
95
- console.log(`Installed pinchpass to ${dest}`);
96
- const onPathNow = onPath(`pinchpass${exe}`);
97
- if (!onPathNow) {
98
- console.log(`Add ${installDir} to your PATH if you want to use the CLI directly.`);
99
- console.log("(The PinchPass Pi extension and OpenClaw plugin find the binary without PATH changes.)");
193
+ const checksum = await verifyChecksum(asset, bytes, base);
194
+ if (checksum !== "verified") {
195
+ warn(`pinchpass: checksums.txt unavailable for ${asset}; installed without verification`);
100
196
  }
197
+
198
+ const target = join(homedir(), ".local", "bin", name);
199
+ writeExecutable(target, bytes);
200
+
201
+ const from = existingVersion ? `v${existingVersion}` : "no previous binary";
202
+ log(`pinchpass: ${from} -> ${fallback ? "the latest release" : `v${version}`} at ${target}`);
203
+
204
+ if (existing && existing !== target && !knownDirs().some((dir) => existing.startsWith(dir))) {
205
+ warn(`pinchpass: ${existing} shadows the new binary on PATH — remove or update it`);
206
+ }
207
+ return { installed: true, version, path: target, checksum };
101
208
  }
102
209
 
103
- install().catch((err) => {
104
- console.error(`Failed to install pinchpass binary: ${err.message}`);
105
- console.log(`To build from source: cd pinchpass && go build -o pinchpass .`);
106
- process.exit(1);
107
- });
210
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
211
+
212
+ if (invokedDirectly) {
213
+ install().catch((err) => {
214
+ const message = err instanceof Error ? err.message : String(err);
215
+ // Never fail the npm install: an existing binary or `go install` still works.
216
+ console.warn(`pinchpass: could not install the binary — ${message}`);
217
+ console.warn(
218
+ "pinchpass: install it later with `go install github.com/rubybear-lgtm/pinchpass@latest` or `npm i -g @pinchpass/cli`",
219
+ );
220
+ });
221
+ }
@@ -26,6 +26,11 @@ which pinchpass
26
26
 
27
27
  If missing, install it (pick one):
28
28
 
29
+ ```bash
30
+ # Homebrew (macOS and Linux, via the tap)
31
+ brew install --cask rubybear-lgtm/tap/pinchpass
32
+ ```
33
+
29
34
  ```bash
30
35
  # Pi (native package — registers the request_secret tool and this skill)
31
36
  pi install npm:@pinchpass/cli
@@ -37,7 +42,7 @@ npm i -g @pinchpass/cli
37
42
  ```
38
43
 
39
44
  ```bash
40
- # From source (requires Go 1.25+)
45
+ # From source (requires Go 1.26+)
41
46
  go install github.com/rubybear-lgtm/pinchpass@latest
42
47
  ```
43
48
 
@@ -48,6 +53,10 @@ curl -sL https://github.com/rubybear-lgtm/PinchPass/releases/latest/download/pin
48
53
  chmod +x /usr/local/bin/pinchpass
49
54
  ```
50
55
 
56
+ Check the build with `pinchpass --version`, and update through the channel you
57
+ installed with — `brew upgrade --cask pinchpass`, `npm update -g @pinchpass/cli`,
58
+ or `go install github.com/rubybear-lgtm/pinchpass@latest`.
59
+
51
60
  ## Usage
52
61
 
53
62
  Always use `-json` mode so you can parse the output programmatically.
@@ -99,14 +108,24 @@ two collection modes (`via` param):
99
108
  - **`modal`** (default in the interactive TUI): a Pi dialog collects each
100
109
  secret directly into the `.env` file. The value goes straight from the
101
110
  user's keyboard to disk — never to the LLM, never into the transcript, no
102
- server, no network. Cancelable with `Esc`.
111
+ server, no network. Cancelable with `Esc`. `ttl` does not apply to this mode
112
+ and is ignored.
103
113
  - **`link`**: spawns the CLI, streams the one-time E2E-encrypted browser link
104
114
  to the user immediately, blocks until submit/TTL. Use when the user is not
105
115
  in the same Pi session (remote, tunneled, or RPC clients without a dialog
106
116
  handler).
107
117
 
108
- `via` defaults to `auto`: modal in the TUI, link elsewhere. Prefer the tool on
109
- Pi; use the CLI workflow below on other agents.
118
+ `via` defaults to `auto`: modal in the TUI, link elsewhere. Setting
119
+ `tunnel: true` always uses the link, even in the TUI, because the modal cannot
120
+ serve a remote user.
121
+
122
+ **Privacy note:** in `link` mode the full URL — including the `#k=` decryption
123
+ key fragment — is returned to the model and recorded in the transcript. Only
124
+ `modal` keeps the secret out of the transcript entirely. Prefer `modal` when
125
+ the user is local and the value is sensitive; use `link` when you must send a
126
+ URL over a channel the model can see.
127
+
128
+ Prefer the tool on Pi; use the CLI workflow below on other agents.
110
129
 
111
130
  ## Workflow
112
131