onepatch 0.3.0 → 0.5.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 +14 -5
- package/bin/onepatch.cjs +203 -0
- package/checksums.json +7 -0
- package/package.json +34 -10
- package/src/cli.ts +16 -0
- package/src/hook.ts +108 -0
- package/src/install.ts +150 -0
- package/src/runtime.ts +35 -0
- package/src/update.ts +127 -36
- package/src/credentials.test.ts +0 -49
- package/src/update.test.ts +0 -128
- package/src/workos.test.ts +0 -87
package/README.md
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
The [OnePatch](https://onepatch.dev) CLI. Query your telemetry, read and reply to agent chats, and inspect incidents from a terminal or from a coding agent. It speaks to the same MCP endpoint the product serves at `/mcp`, so it has exact functional parity with connecting an MCP client.
|
|
4
4
|
|
|
5
|
-
Requires [Bun](https://bun.sh).
|
|
6
|
-
|
|
7
5
|
```sh
|
|
8
|
-
|
|
6
|
+
npm install -g onepatch # needs Node
|
|
7
|
+
# or, without Node:
|
|
8
|
+
curl -fsSL https://releases.onepatch.dev/install.sh | sh
|
|
9
|
+
|
|
9
10
|
onepatch login
|
|
10
11
|
```
|
|
11
12
|
|
|
13
|
+
The CLI is a single self-contained binary (compiled with Bun, so Bun is not needed). `npm install -g onepatch` installs a small Node launcher that fetches the binary for your platform from `https://releases.onepatch.dev/v<version>/` and verifies its SHA-256 against the checksum shipped inside the package; the installer script does the same without Node. Releases carry binaries for macOS (arm64, x64), Linux (x64, arm64), and Windows (x64).
|
|
14
|
+
|
|
12
15
|
`login` uses a device flow: it prints a URL, you approve in the browser, and credentials land in `~/.config/onepatch/credentials.json` (0600). Tokens refresh automatically.
|
|
13
16
|
|
|
14
17
|
## Commands
|
|
@@ -27,13 +30,19 @@ onepatch incidents read <num> [--raw]
|
|
|
27
30
|
|
|
28
31
|
onepatch tools # list the server's MCP tools
|
|
29
32
|
onepatch whoami
|
|
33
|
+
|
|
34
|
+
onepatch install # wire this machine's coding agents (Claude Code, Codex, Cursor)
|
|
30
35
|
```
|
|
31
36
|
|
|
32
37
|
Pass `-` to read SQL or message text from stdin. `--json` prints raw MCP content blocks. `--api <url>` (or `ONEPATCH_API_URL`) targets a different deployment.
|
|
33
38
|
|
|
39
|
+
## Coding agents
|
|
40
|
+
|
|
41
|
+
`onepatch install` detects Claude Code, Codex, and Cursor and wires each one to OnePatch: the OnePatch plugin (a skill that teaches the agent the tools above) plus the remote MCP server, discovered from the deployment's `/.well-known/onepatch-cli`. It is idempotent — re-run it any time to repair or upgrade. Target one agent with `onepatch install <claude|codex|cursor>`, or install by hand from [claude-code-plugin](https://github.com/1patch/claude-code-plugin), [codex-plugin](https://github.com/1patch/codex-plugin), or [cursor-plugin](https://github.com/1patch/cursor-plugin).
|
|
42
|
+
|
|
34
43
|
## Updates
|
|
35
44
|
|
|
36
|
-
The CLI keeps itself current. Once a day, in a detached background process, it asks the npm registry for the latest version; when one exists it reinstalls itself through
|
|
45
|
+
The CLI keeps itself current. Once a day, in a detached background process, it asks the npm registry for the latest version; when one exists it either reinstalls itself through the package manager that owns the copy (npm or bun) or, for a standalone binary, downloads the new release, verifies it, and swaps it in place. It prints a one-line notice to stderr. The command you typed is never delayed and never fails because of update machinery. Set `ONEPATCH_NO_UPDATE=1` to disable it, or run `onepatch update` to update on demand. Running from a source checkout never auto-updates. Self-update of a standalone binary isn't available on Windows yet; re-run the installer there.
|
|
37
46
|
|
|
38
47
|
## Programmatic use
|
|
39
48
|
|
|
@@ -47,4 +56,4 @@ const digest = await op.chats.start("investigate the p95 spike on /checkout");
|
|
|
47
56
|
await op.close();
|
|
48
57
|
```
|
|
49
58
|
|
|
50
|
-
All methods return the same plain-text digests the MCP tools produce. `op.call(name, args)` reaches any tool directly.
|
|
59
|
+
All methods return the same plain-text digests the MCP tools produce. `op.call(name, args)` reaches any tool directly. The programmatic API runs under Bun (it imports the TypeScript sources shipped in the package) and needs `@modelcontextprotocol/sdk` installed alongside; the CLI itself has no dependencies.
|
package/bin/onepatch.cjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The npm package's entry point. It is a launcher, not the CLI: it finds (or
|
|
3
|
+
// fetches) the prebuilt onepatch binary for this machine and hands over to
|
|
4
|
+
// it. Plain Node, no dependencies, so `npm install -g onepatch` needs nothing
|
|
5
|
+
// but Node. The binary is downloaded from the release host
|
|
6
|
+
// (https://releases.onepatch.dev/v<version>/) that matches this package's
|
|
7
|
+
// version and verified against the SHA-256 recorded in checksums.json at
|
|
8
|
+
// publish time, so npm's integrity check on this package extends to the
|
|
9
|
+
// binary it fetches.
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
const { spawnSync, spawn } = require("node:child_process");
|
|
13
|
+
const crypto = require("node:crypto");
|
|
14
|
+
const fs = require("node:fs");
|
|
15
|
+
const os = require("node:os");
|
|
16
|
+
const path = require("node:path");
|
|
17
|
+
|
|
18
|
+
const root = path.join(__dirname, "..");
|
|
19
|
+
const pkg = require(path.join(root, "package.json"));
|
|
20
|
+
const RELEASE_BASE = process.env.ONEPATCH_RELEASE_BASE || "https://releases.onepatch.dev";
|
|
21
|
+
|
|
22
|
+
function releaseTarget(platform = process.platform, arch = process.arch) {
|
|
23
|
+
const osName =
|
|
24
|
+
platform === "darwin"
|
|
25
|
+
? "darwin"
|
|
26
|
+
: platform === "linux"
|
|
27
|
+
? "linux"
|
|
28
|
+
: platform === "win32"
|
|
29
|
+
? "windows"
|
|
30
|
+
: null;
|
|
31
|
+
if (osName === null || (arch !== "x64" && arch !== "arm64")) return null;
|
|
32
|
+
if (osName === "windows" && arch !== "x64") return null;
|
|
33
|
+
return `${osName}-${arch}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assetName(target) {
|
|
37
|
+
return `onepatch-${target}.tar.gz`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assetUrl(version, target, base = RELEASE_BASE) {
|
|
41
|
+
return `${base}/v${version}/${assetName(target)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function binaryName(target) {
|
|
45
|
+
return target.startsWith("windows") ? "onepatch.exe" : "onepatch";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sha256(buffer) {
|
|
49
|
+
return crypto.createHash("sha256").update(buffer).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readChecksums() {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(path.join(root, "checksums.json"), "utf8"));
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Where the binary lives once fetched. The package's own vendor/ directory is
|
|
61
|
+
// preferred (postinstall fills it, uninstall removes it); a per-user cache is
|
|
62
|
+
// the fallback when the package directory isn't writable at run time.
|
|
63
|
+
function vendorDir() {
|
|
64
|
+
return path.join(root, "vendor");
|
|
65
|
+
}
|
|
66
|
+
function cacheDir() {
|
|
67
|
+
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
|
68
|
+
return path.join(base, "onepatch", "bin", pkg.version);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findBinary(target) {
|
|
72
|
+
if (process.env.ONEPATCH_BIN) return process.env.ONEPATCH_BIN;
|
|
73
|
+
for (const dir of [vendorDir(), cacheDir()]) {
|
|
74
|
+
const candidate = path.join(dir, binaryName(target));
|
|
75
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function download(url) {
|
|
81
|
+
const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
|
|
82
|
+
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
|
|
83
|
+
return Buffer.from(await res.arrayBuffer());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Fetch, verify, and unpack the binary into `dir`. Staged in a sibling
|
|
87
|
+
// directory and renamed in, so a concurrent launcher never sees a half-written
|
|
88
|
+
// file.
|
|
89
|
+
async function fetchBinary(target, dir, { quiet } = {}) {
|
|
90
|
+
const checksums = readChecksums();
|
|
91
|
+
const expected = checksums?.[target];
|
|
92
|
+
if (!expected) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`this onepatch package carries no checksum for ${target}; refusing to download`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (!quiet) process.stderr.write(`Downloading onepatch ${pkg.version} for ${target}…\n`);
|
|
98
|
+
const tarball = await download(assetUrl(pkg.version, target));
|
|
99
|
+
const actual = sha256(tarball);
|
|
100
|
+
if (actual !== expected) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`checksum mismatch for ${assetName(target)}: expected ${expected}, got ${actual}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
106
|
+
const stage = fs.mkdtempSync(path.join(dir, ".stage-"));
|
|
107
|
+
try {
|
|
108
|
+
const archive = path.join(stage, assetName(target));
|
|
109
|
+
fs.writeFileSync(archive, tarball);
|
|
110
|
+
const tar = spawnSync("tar", ["-xzf", archive, "-C", stage], { stdio: "ignore" });
|
|
111
|
+
if (tar.status !== 0) throw new Error("tar failed to extract the onepatch archive");
|
|
112
|
+
const bin = path.join(stage, binaryName(target));
|
|
113
|
+
fs.chmodSync(bin, 0o755);
|
|
114
|
+
fs.renameSync(bin, path.join(dir, binaryName(target)));
|
|
115
|
+
} finally {
|
|
116
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
return path.join(dir, binaryName(target));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function ensureBinary(target, opts) {
|
|
122
|
+
const found = findBinary(target);
|
|
123
|
+
if (found) return found;
|
|
124
|
+
try {
|
|
125
|
+
return await fetchBinary(target, vendorDir(), opts);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err && err.code !== "EACCES" && err.code !== "EPERM" && err.code !== "EROFS") throw err;
|
|
128
|
+
return await fetchBinary(target, cacheDir(), opts);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isSourceCheckout() {
|
|
133
|
+
return (
|
|
134
|
+
fs.existsSync(path.join(root, "src", "cli.ts")) &&
|
|
135
|
+
!fs.existsSync(path.join(root, "checksums.json"))
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function main(argv) {
|
|
140
|
+
const args = argv.slice(2);
|
|
141
|
+
|
|
142
|
+
// npm's postinstall: prefetch so the first real invocation is instant.
|
|
143
|
+
// Never fail the install over it — the launcher fetches on demand anyway.
|
|
144
|
+
if (args[0] === "--postinstall") {
|
|
145
|
+
if (isSourceCheckout()) return 0;
|
|
146
|
+
const target = releaseTarget();
|
|
147
|
+
if (target === null) return 0;
|
|
148
|
+
try {
|
|
149
|
+
await ensureBinary(target, { quiet: true });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
process.stderr.write(
|
|
152
|
+
`onepatch: could not prefetch the binary (${err.message}); it will download on first use.\n`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A developer's checkout: run the sources under Bun, like before.
|
|
159
|
+
if (isSourceCheckout()) {
|
|
160
|
+
const run = spawnSync("bun", [path.join(root, "src", "cli.ts"), ...args], { stdio: "inherit" });
|
|
161
|
+
return run.status === null ? 1 : run.status;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const target = releaseTarget();
|
|
165
|
+
if (target === null) {
|
|
166
|
+
process.stderr.write(`onepatch: no prebuilt binary for ${process.platform}/${process.arch}.\n`);
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let bin = findBinary(target);
|
|
171
|
+
if (bin === null) {
|
|
172
|
+
// Hooks run on every agent prompt and must never wait on a download:
|
|
173
|
+
// fetch in the background and inject nothing this time.
|
|
174
|
+
if (args[0] === "hook") {
|
|
175
|
+
spawn(process.execPath, [__filename, "--postinstall"], {
|
|
176
|
+
detached: true,
|
|
177
|
+
stdio: "ignore",
|
|
178
|
+
}).unref();
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
bin = await ensureBinary(target);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const run = spawnSync(bin, args, { stdio: "inherit" });
|
|
185
|
+
if (run.error) throw run.error;
|
|
186
|
+
if (run.signal) {
|
|
187
|
+
process.kill(process.pid, run.signal);
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
190
|
+
return run.status === null ? 1 : run.status;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { releaseTarget, assetName, assetUrl, binaryName, sha256 };
|
|
194
|
+
|
|
195
|
+
if (require.main === module) {
|
|
196
|
+
main(process.argv).then(
|
|
197
|
+
(code) => process.exit(code),
|
|
198
|
+
(err) => {
|
|
199
|
+
process.stderr.write(`onepatch: ${err?.message ?? err}\n`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
}
|
package/checksums.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"darwin-arm64": "3fa7a51e2e5972cc804251700d380c990fb3b5f3dab340a7aa6ba716279ece64",
|
|
3
|
+
"darwin-x64": "6de2f3ba8420633b5d0f08164e3d1c4d370ea85c7d228f95ead4592420458397",
|
|
4
|
+
"linux-arm64": "fc535ba8717d073a12e7668516fad6a584289bf9968d65aaa47cfeb4474cad9e",
|
|
5
|
+
"linux-x64": "7600a66ca84211a81dafcf054fd4ed5ea29e751bf394735eb170779b6d2672dc",
|
|
6
|
+
"windows-x64": "e60500b926849a64fb38315fed258aa860e673b92b69f7e5ce7dc8bbe006c326"
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onepatch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "OnePatch CLI: query your telemetry, chats, and incidents from a terminal or a coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://onepatch.dev",
|
|
@@ -11,34 +11,58 @@
|
|
|
11
11
|
"bugs": {
|
|
12
12
|
"url": "https://github.com/1patch/cli/issues"
|
|
13
13
|
},
|
|
14
|
-
"keywords": [
|
|
14
|
+
"keywords": [
|
|
15
|
+
"onepatch",
|
|
16
|
+
"observability",
|
|
17
|
+
"sre",
|
|
18
|
+
"cli",
|
|
19
|
+
"mcp",
|
|
20
|
+
"opentelemetry"
|
|
21
|
+
],
|
|
15
22
|
"type": "module",
|
|
16
23
|
"bin": {
|
|
17
|
-
"onepatch": "./
|
|
24
|
+
"onepatch": "./bin/onepatch.cjs"
|
|
18
25
|
},
|
|
19
26
|
"exports": {
|
|
20
27
|
".": "./src/index.ts"
|
|
21
28
|
},
|
|
22
|
-
"files": [
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"src",
|
|
32
|
+
"!src/*.test.ts",
|
|
33
|
+
"checksums.json",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
23
37
|
"engines": {
|
|
24
|
-
"
|
|
38
|
+
"node": ">=18"
|
|
25
39
|
},
|
|
26
40
|
"publishConfig": {
|
|
27
41
|
"access": "public"
|
|
28
42
|
},
|
|
29
43
|
"scripts": {
|
|
44
|
+
"postinstall": "node bin/onepatch.cjs --postinstall",
|
|
45
|
+
"build": "scripts/build-binaries.sh",
|
|
46
|
+
"mirror": "scripts/mirror-plugins.sh",
|
|
30
47
|
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
|
31
48
|
"test": "bun test",
|
|
32
49
|
"fmt": "biome check --write .",
|
|
33
|
-
"check": "biome ci . && bun run typecheck && bun test"
|
|
34
|
-
|
|
35
|
-
"dependencies": {
|
|
36
|
-
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
50
|
+
"check": "biome ci . && bun run typecheck && bun test",
|
|
51
|
+
"mirror": "scripts/mirror-plugins.sh"
|
|
37
52
|
},
|
|
38
53
|
"devDependencies": {
|
|
39
54
|
"@biomejs/biome": "^2.5.8",
|
|
40
55
|
"@types/bun": "^1.3.14",
|
|
41
56
|
"@typescript/native-preview": "^7.0.0-dev.20260506.1",
|
|
42
|
-
"typescript": "^5.5.0"
|
|
57
|
+
"typescript": "^5.5.0",
|
|
58
|
+
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@modelcontextprotocol/sdk": {
|
|
65
|
+
"optional": true
|
|
66
|
+
}
|
|
43
67
|
}
|
|
44
68
|
}
|
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { decodeJwtClaims, getValidAccessToken } from "./auth";
|
|
|
6
6
|
import { OnepatchClient } from "./client";
|
|
7
7
|
import { fetchBootstrap, resolveApiUrl } from "./config";
|
|
8
8
|
import { deleteCredentials, loadCredentials, saveCredentials } from "./credentials";
|
|
9
|
+
import { runHook } from "./hook";
|
|
10
|
+
import { runInstall } from "./install";
|
|
9
11
|
import { currentVersion, maybeAutoUpdate, runUpdate } from "./update";
|
|
10
12
|
import { pollForDeviceToken, startDeviceAuthorization } from "./workos";
|
|
11
13
|
|
|
@@ -30,6 +32,11 @@ Usage:
|
|
|
30
32
|
onepatch incidents read <num> [--raw] Read one incident by number
|
|
31
33
|
|
|
32
34
|
onepatch tools List the server's MCP tools
|
|
35
|
+
onepatch hook user-prompt-submit Agent-hook injector (wired by the plugins; reads
|
|
36
|
+
the event on stdin, prints context to inject)
|
|
37
|
+
onepatch install [claude|codex|cursor]
|
|
38
|
+
Wire this machine's coding agents to OnePatch
|
|
39
|
+
(plugins + MCP server; no argument = all detected)
|
|
33
40
|
onepatch update Update the CLI to the latest version now
|
|
34
41
|
|
|
35
42
|
Global flags:
|
|
@@ -136,6 +143,10 @@ async function main(): Promise<void> {
|
|
|
136
143
|
// Internal: `onepatch update --check` only refreshes the cached
|
|
137
144
|
// latest-version state; the background updater spawns it.
|
|
138
145
|
check: { type: "boolean", default: false },
|
|
146
|
+
// Internal: `onepatch hook --refresh` repopulates the hook cache; the
|
|
147
|
+
// foreground hook invocation spawns it detached.
|
|
148
|
+
refresh: { type: "boolean", default: false },
|
|
149
|
+
agent: { type: "string" },
|
|
139
150
|
},
|
|
140
151
|
});
|
|
141
152
|
|
|
@@ -150,6 +161,10 @@ async function main(): Promise<void> {
|
|
|
150
161
|
return;
|
|
151
162
|
}
|
|
152
163
|
|
|
164
|
+
// Hooks fire on every prompt, so they skip even the auto-update file read
|
|
165
|
+
// and must never write anything but their injection to stdout.
|
|
166
|
+
if (noun === "hook") return await runHook(resolveApiUrl(flags.api), verb, flags.refresh);
|
|
167
|
+
|
|
153
168
|
if (noun === "update") return await runUpdate({ checkOnly: flags.check });
|
|
154
169
|
// Every other command triggers the zero-cost background update pass.
|
|
155
170
|
maybeAutoUpdate();
|
|
@@ -157,6 +172,7 @@ async function main(): Promise<void> {
|
|
|
157
172
|
const api = resolveApiUrl(flags.api);
|
|
158
173
|
|
|
159
174
|
if (noun === "login") return await login(api);
|
|
175
|
+
if (noun === "install") return await runInstall(api, verb);
|
|
160
176
|
if (noun === "logout") {
|
|
161
177
|
console.log(deleteCredentials(api) ? `Logged out of ${api}.` : `No credentials for ${api}.`);
|
|
162
178
|
return;
|
package/src/hook.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// `onepatch hook user-prompt-submit` — the example agent-hook injector the
|
|
2
|
+
// plugins wire up (UserPromptSubmit in Claude Code and Codex). Whatever this
|
|
3
|
+
// prints to stdout is injected into the agent's context for the turn, so the
|
|
4
|
+
// contract is strict: never block the user's prompt (no foreground network —
|
|
5
|
+
// serve from a cache, refresh detached, like update.ts) and never break it
|
|
6
|
+
// (any failure means print nothing and exit 0).
|
|
7
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { OnepatchClient } from "./client";
|
|
10
|
+
import { configDir, ensureConfigDir, loadCredentials } from "./credentials";
|
|
11
|
+
import { selfCommand } from "./runtime";
|
|
12
|
+
|
|
13
|
+
export const HOOK_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
14
|
+
// A refresh normally lands in seconds; this only bounds how long a failed one
|
|
15
|
+
// suppresses retries.
|
|
16
|
+
const REFRESH_RETRY_MS = 2 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
export type HookCache = {
|
|
19
|
+
fetchedAt?: number;
|
|
20
|
+
refreshStartedAt?: number;
|
|
21
|
+
// The context line to inject; "" means "healthy, nothing worth injecting".
|
|
22
|
+
line?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function hookCachePath(): string {
|
|
26
|
+
return join(configDir(), "hook-status.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readHookCache(): HookCache {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(hookCachePath(), "utf8")) as HookCache;
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeHookCache(cache: HookCache): void {
|
|
38
|
+
ensureConfigDir();
|
|
39
|
+
writeFileSync(hookCachePath(), `${JSON.stringify(cache)}\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type HookAction = { emit: string; refresh: boolean };
|
|
43
|
+
|
|
44
|
+
// Pure decision: what to print and whether to kick a background refresh.
|
|
45
|
+
// A stale line still gets emitted — slightly old incident context beats none,
|
|
46
|
+
// and the refresh makes the next prompt current.
|
|
47
|
+
export function decideHookAction(cache: HookCache, now: number): HookAction {
|
|
48
|
+
const fresh = cache.fetchedAt !== undefined && now - cache.fetchedAt < HOOK_CACHE_TTL_MS;
|
|
49
|
+
const refreshInFlight =
|
|
50
|
+
cache.refreshStartedAt !== undefined && now - cache.refreshStartedAt < REFRESH_RETRY_MS;
|
|
51
|
+
return { emit: cache.line ?? "", refresh: !fresh && !refreshInFlight };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Compress the list_incidents digest into one context line. The digest leads
|
|
55
|
+
// with "<N> open incidents · …" and lists one incident per line
|
|
56
|
+
// ("INC-193 · P2 · waiting on human · <title> · updated <ts>"); we keep the
|
|
57
|
+
// count and the two newest rows, minus the updated-at tail.
|
|
58
|
+
export function buildContextLine(incidentsText: string): string {
|
|
59
|
+
const lines = incidentsText.split("\n").map((l) => l.trim());
|
|
60
|
+
const rows = lines
|
|
61
|
+
.filter((l) => /^INC-\d+ · /.test(l))
|
|
62
|
+
.map((l) => l.replace(/ · updated \S+$/, ""));
|
|
63
|
+
const count = Number(lines[0]?.match(/^(\d+) open incident/)?.[1] ?? rows.length);
|
|
64
|
+
if (count === 0 || rows.length === 0) return "";
|
|
65
|
+
const shown = rows.slice(0, 2).join("; ");
|
|
66
|
+
return (
|
|
67
|
+
`[onepatch] ${count} open incident${count === 1 ? "" : "s"} — newest: ${shown}. ` +
|
|
68
|
+
"Details: the onepatch MCP tools or `onepatch incidents read <num>`."
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function refreshHookCache(api: string): Promise<void> {
|
|
73
|
+
const client = new OnepatchClient({ api });
|
|
74
|
+
try {
|
|
75
|
+
const text = await client.incidents.list({ scope: "open", limit: 10 });
|
|
76
|
+
writeHookCache({ fetchedAt: Date.now(), line: buildContextLine(text) });
|
|
77
|
+
} finally {
|
|
78
|
+
await client.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function runHook(api: string, event: string | undefined, refresh: boolean) {
|
|
83
|
+
if (refresh) {
|
|
84
|
+
// Internal mode spawned below; errors just leave the stale cache in place.
|
|
85
|
+
try {
|
|
86
|
+
await refreshHookCache(api);
|
|
87
|
+
} catch {}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (event !== "user-prompt-submit") return; // unknown events inject nothing
|
|
91
|
+
// Hooks pipe the event payload on stdin; drain it so the agent never sees
|
|
92
|
+
// a broken pipe, but nothing in it changes what we inject.
|
|
93
|
+
try {
|
|
94
|
+
await Bun.stdin.text();
|
|
95
|
+
} catch {}
|
|
96
|
+
if (!loadCredentials(api)) return; // not logged in — stay silent
|
|
97
|
+
const action = decideHookAction(readHookCache(), Date.now());
|
|
98
|
+
if (action.refresh) {
|
|
99
|
+
writeHookCache({ ...readHookCache(), refreshStartedAt: Date.now() });
|
|
100
|
+
Bun.spawn({
|
|
101
|
+
cmd: selfCommand(["hook", "--refresh", "--api", api]),
|
|
102
|
+
stdin: "ignore",
|
|
103
|
+
stdout: "ignore",
|
|
104
|
+
stderr: "ignore",
|
|
105
|
+
}).unref();
|
|
106
|
+
}
|
|
107
|
+
if (action.emit !== "") console.log(action.emit);
|
|
108
|
+
}
|
package/src/install.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// `onepatch install [claude|codex|cursor]` — wire this machine's coding
|
|
2
|
+
// agents to OnePatch. The plugins themselves are thin (skills + MCP pointers,
|
|
3
|
+
// published from plugins/ in this repo); this command only does the local
|
|
4
|
+
// wiring each agent needs, and every step is idempotent so re-running is
|
|
5
|
+
// always repair, never damage.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fetchBootstrap } from "./config";
|
|
10
|
+
|
|
11
|
+
export const AGENTS = ["claude", "codex", "cursor"] as const;
|
|
12
|
+
export type AgentName = (typeof AGENTS)[number];
|
|
13
|
+
|
|
14
|
+
const CLAUDE_MARKETPLACE_REPO = "1patch/claude-code-plugin";
|
|
15
|
+
const CODEX_MARKETPLACE_REPO = "1patch/codex-plugin";
|
|
16
|
+
const CURSOR_PLUGIN_URL = "https://github.com/1patch/cursor-plugin";
|
|
17
|
+
|
|
18
|
+
// --- pure decision helpers (unit-tested) ---
|
|
19
|
+
|
|
20
|
+
// Append an `[mcp_servers.onepatch]` block to Codex's config.toml unless one
|
|
21
|
+
// already exists. A string scan, not a TOML parser: the only claim we make is
|
|
22
|
+
// "a block with this exact header is present", and rewriting a user's config
|
|
23
|
+
// through a parser risks clobbering formatting and comments.
|
|
24
|
+
export function ensureCodexMcpServer(
|
|
25
|
+
toml: string,
|
|
26
|
+
mcpUrl: string,
|
|
27
|
+
): { changed: boolean; text: string } {
|
|
28
|
+
if (/^\s*\[mcp_servers\.onepatch\]/m.test(toml)) return { changed: false, text: toml };
|
|
29
|
+
const block = `[mcp_servers.onepatch]\nurl = "${mcpUrl}"\n`;
|
|
30
|
+
const sep = toml === "" || toml.endsWith("\n\n") ? "" : toml.endsWith("\n") ? "\n" : "\n\n";
|
|
31
|
+
return { changed: true, text: `${toml}${sep}${block}` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Merge the onepatch server into Cursor's mcp.json, preserving everything
|
|
35
|
+
// else. `null` means the file doesn't exist yet.
|
|
36
|
+
export function ensureCursorMcpServer(
|
|
37
|
+
json: string | null,
|
|
38
|
+
mcpUrl: string,
|
|
39
|
+
): { changed: boolean; text: string } {
|
|
40
|
+
let doc: { mcpServers?: Record<string, unknown> };
|
|
41
|
+
try {
|
|
42
|
+
doc = json === null ? {} : (JSON.parse(json) as typeof doc);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error("~/.cursor/mcp.json exists but is not valid JSON; fix it and re-run.");
|
|
45
|
+
}
|
|
46
|
+
const servers = doc.mcpServers ?? {};
|
|
47
|
+
if (servers.onepatch !== undefined) return { changed: false, text: json ?? "" };
|
|
48
|
+
doc.mcpServers = { ...servers, onepatch: { url: mcpUrl } };
|
|
49
|
+
return { changed: true, text: `${JSON.stringify(doc, null, 2)}\n` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function detectAgents(home: string = homedir()): AgentName[] {
|
|
53
|
+
const found: AgentName[] = [];
|
|
54
|
+
if (Bun.which("claude")) found.push("claude");
|
|
55
|
+
if (Bun.which("codex") || existsSync(join(home, ".codex"))) found.push("codex");
|
|
56
|
+
if (Bun.which("cursor-agent") || existsSync(join(home, ".cursor"))) found.push("cursor");
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- IO ---
|
|
61
|
+
|
|
62
|
+
async function run(cmd: string[]): Promise<boolean> {
|
|
63
|
+
console.log(` $ ${cmd.join(" ")}`);
|
|
64
|
+
const proc = Bun.spawn({ cmd, stdin: "ignore", stdout: "inherit", stderr: "inherit" });
|
|
65
|
+
return (await proc.exited) === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function upsertFile(path: string, next: string): void {
|
|
69
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
70
|
+
writeFileSync(path, next);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function installClaude(): Promise<void> {
|
|
74
|
+
if (!Bun.which("claude")) {
|
|
75
|
+
console.log("claude: CLI not on PATH — install Claude Code first, then re-run.");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
console.log("claude: installing the OnePatch plugin (marketplace + plugin)…");
|
|
79
|
+
// The marketplace add fails harmlessly if it's already known; the install
|
|
80
|
+
// refreshes the marketplace itself, so together these double as upgrade.
|
|
81
|
+
await run(["claude", "plugin", "marketplace", "add", CLAUDE_MARKETPLACE_REPO]);
|
|
82
|
+
if (await run(["claude", "plugin", "install", "onepatch@onepatch"])) {
|
|
83
|
+
console.log("claude: done. Authenticate the onepatch MCP server via /mcp on first use.");
|
|
84
|
+
} else {
|
|
85
|
+
console.log(
|
|
86
|
+
`claude: plugin install failed — run \`claude plugin marketplace add ${CLAUDE_MARKETPLACE_REPO}\` and \`claude plugin install onepatch@onepatch\` by hand to see why.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function installCodex(mcpUrl: string, home: string): Promise<void> {
|
|
92
|
+
const configPath = join(home, ".codex", "config.toml");
|
|
93
|
+
const current = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
94
|
+
const result = ensureCodexMcpServer(current, mcpUrl);
|
|
95
|
+
if (result.changed) {
|
|
96
|
+
upsertFile(configPath, result.text);
|
|
97
|
+
console.log(`codex: added [mcp_servers.onepatch] to ${configPath}.`);
|
|
98
|
+
} else {
|
|
99
|
+
console.log("codex: MCP server already configured.");
|
|
100
|
+
}
|
|
101
|
+
if (Bun.which("codex")) {
|
|
102
|
+
console.log("codex: installing the OnePatch plugin (skills)…");
|
|
103
|
+
await run(["codex", "plugin", "marketplace", "add", CODEX_MARKETPLACE_REPO]);
|
|
104
|
+
await run(["codex", "plugin", "install", "onepatch/onepatch"]);
|
|
105
|
+
}
|
|
106
|
+
console.log("codex: sign in with `codex mcp login onepatch` on first use.");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function installCursor(mcpUrl: string, home: string): Promise<void> {
|
|
110
|
+
const mcpPath = join(home, ".cursor", "mcp.json");
|
|
111
|
+
const current = existsSync(mcpPath) ? readFileSync(mcpPath, "utf8") : null;
|
|
112
|
+
const result = ensureCursorMcpServer(current, mcpUrl);
|
|
113
|
+
if (result.changed) {
|
|
114
|
+
upsertFile(mcpPath, result.text);
|
|
115
|
+
console.log(`cursor: added the onepatch MCP server to ${mcpPath}.`);
|
|
116
|
+
} else {
|
|
117
|
+
console.log("cursor: MCP server already configured.");
|
|
118
|
+
}
|
|
119
|
+
console.log(
|
|
120
|
+
`cursor: for the skill + rules, run \`/add-plugin ${CURSOR_PLUGIN_URL}\` in Cursor's agent chat, and log in to onepatch under Settings → MCP.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function runInstall(api: string, target?: string): Promise<void> {
|
|
125
|
+
if (target !== undefined && !AGENTS.includes(target as AgentName)) {
|
|
126
|
+
throw new Error(`unknown agent "${target}" — expected one of: ${AGENTS.join(", ")}`);
|
|
127
|
+
}
|
|
128
|
+
// The MCP URL is discovered, not baked in, so self-hosted deployments get
|
|
129
|
+
// wired to their own endpoint by passing --api (or ONEPATCH_API_URL).
|
|
130
|
+
let mcpUrl: string;
|
|
131
|
+
try {
|
|
132
|
+
mcpUrl = (await fetchBootstrap(api)).mcpUrl;
|
|
133
|
+
} catch {
|
|
134
|
+
mcpUrl = `${api}/mcp`;
|
|
135
|
+
}
|
|
136
|
+
const targets = target !== undefined ? [target as AgentName] : detectAgents();
|
|
137
|
+
if (targets.length === 0) {
|
|
138
|
+
console.log(
|
|
139
|
+
"No coding agents found (looked for Claude Code, Codex, Cursor). " +
|
|
140
|
+
"Pass one explicitly: onepatch install <claude|codex|cursor>.",
|
|
141
|
+
);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const home = homedir();
|
|
145
|
+
for (const agent of targets) {
|
|
146
|
+
if (agent === "claude") await installClaude();
|
|
147
|
+
if (agent === "codex") await installCodex(mcpUrl, home);
|
|
148
|
+
if (agent === "cursor") await installCursor(mcpUrl, home);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Where this process's code lives and how to re-invoke it. The CLI ships two
|
|
2
|
+
// ways: as a compiled Bun executable (the binary users actually run) and as
|
|
3
|
+
// TypeScript sources under Bun (development, and the programmatic API). Every
|
|
4
|
+
// place that needs "this program again" or "this program's version" asks here
|
|
5
|
+
// instead of assuming a file layout.
|
|
6
|
+
import { realpathSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import pkg from "../package.json" with { type: "json" };
|
|
9
|
+
|
|
10
|
+
export const VERSION: string = pkg.version;
|
|
11
|
+
|
|
12
|
+
// Bun compiled executables mount their bundled sources on a virtual filesystem
|
|
13
|
+
// (`/$bunfs/` on POSIX, `B:\~BUN\` on Windows); nothing else ever lives there.
|
|
14
|
+
export function isCompiled(dir: string = import.meta.dir): boolean {
|
|
15
|
+
return dir.startsWith("/$bunfs/") || dir.includes("~BUN");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The argv that re-runs this program with `args`, detached or not.
|
|
19
|
+
export function selfCommand(args: string[], compiled: boolean = isCompiled()): string[] {
|
|
20
|
+
return compiled
|
|
21
|
+
? [process.execPath, ...args]
|
|
22
|
+
: [process.execPath, join(import.meta.dir, "cli.ts"), ...args];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The path that decides who owns this install: the binary itself when
|
|
26
|
+
// compiled, otherwise the package directory the sources live in. Symlinks
|
|
27
|
+
// (bin shims, ~/.local/bin links) are resolved so the answer is physical.
|
|
28
|
+
export function installRoot(): string {
|
|
29
|
+
const raw = isCompiled() ? process.execPath : join(import.meta.dir, "..");
|
|
30
|
+
try {
|
|
31
|
+
return realpathSync(raw);
|
|
32
|
+
} catch {
|
|
33
|
+
return raw;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/update.ts
CHANGED
|
@@ -2,35 +2,34 @@
|
|
|
2
2
|
// actually typed never waits on update machinery. Each invocation reads one
|
|
3
3
|
// small cached JSON file; a stale cache spawns a detached background process
|
|
4
4
|
// to refresh it from the npm registry, and a cache that already names a newer
|
|
5
|
-
// version spawns a detached
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
5
|
+
// version spawns a detached update. Either way the foreground command proceeds
|
|
6
|
+
// immediately, and the next invocation runs the new code.
|
|
7
|
+
//
|
|
8
|
+
// How the update lands depends on who owns this copy: a package manager (bun
|
|
9
|
+
// or npm) reinstalls the package, which fetches the matching binary; a
|
|
10
|
+
// standalone binary (curl installer, or a copied file) replaces itself in
|
|
11
|
+
// place from the release host, after checking the tarball's SHA-256.
|
|
12
|
+
//
|
|
13
|
+
// The release host (https://releases.onepatch.dev) is the one source of
|
|
14
|
+
// truth for "what is released": `latest` is written last by the publish job,
|
|
15
|
+
// after both the binaries and the npm package exist, so a version it names
|
|
16
|
+
// is always fully installable by every kind.
|
|
17
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
9
18
|
import { homedir } from "node:os";
|
|
10
|
-
import { join, sep } from "node:path";
|
|
19
|
+
import { dirname, join, sep } from "node:path";
|
|
11
20
|
import { configDir, ensureConfigDir } from "./credentials";
|
|
21
|
+
import { installRoot, isCompiled, selfCommand, VERSION } from "./runtime";
|
|
12
22
|
|
|
13
23
|
export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
14
24
|
// A spawned install normally lands in seconds; this only bounds how long a
|
|
15
25
|
// *failed* install suppresses retries (and dogpiles from parallel invocations).
|
|
16
26
|
export const INSTALL_RETRY_MS = 15 * 60 * 1000;
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// path we inspect is where the package physically lives.
|
|
21
|
-
const raw = join(import.meta.dir, "..");
|
|
22
|
-
try {
|
|
23
|
-
return realpathSync(raw);
|
|
24
|
-
} catch {
|
|
25
|
-
return raw;
|
|
26
|
-
}
|
|
27
|
-
})();
|
|
28
|
+
// Overridable so a release can be rehearsed end-to-end against a local server.
|
|
29
|
+
export const RELEASE_BASE = process.env.ONEPATCH_RELEASE_BASE ?? "https://releases.onepatch.dev";
|
|
28
30
|
|
|
29
31
|
export function currentVersion(): string {
|
|
30
|
-
|
|
31
|
-
version?: unknown;
|
|
32
|
-
};
|
|
33
|
-
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
32
|
+
return VERSION;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
// Plain x.y.z compare; a prerelease suffix sorts below its release.
|
|
@@ -53,24 +52,65 @@ export function compareVersions(a: string, b: string): number {
|
|
|
53
52
|
return pa.pre < pb.pre ? -1 : 1;
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
export type InstallKind = "bun" | "npm";
|
|
55
|
+
export type InstallKind = "bun" | "npm" | "binary";
|
|
57
56
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
57
|
+
// Who owns this copy — decided from where it physically lives, because that's
|
|
58
|
+
// also the only place the answer is honest. A package-manager path wins even
|
|
59
|
+
// for a compiled binary (the launcher's vendored copy), so the manager stays
|
|
60
|
+
// in charge of it. null means a source checkout (dev), where auto-update must
|
|
61
|
+
// stay away.
|
|
62
|
+
export function detectInstallKind(
|
|
63
|
+
root: string = installRoot(),
|
|
64
|
+
compiled: boolean = isCompiled(),
|
|
65
|
+
): InstallKind | null {
|
|
62
66
|
const bunInstall = process.env.BUN_INSTALL ?? join(homedir(), ".bun");
|
|
63
67
|
if (root.startsWith(bunInstall + sep) || root.split(sep).includes(".bun")) return "bun";
|
|
64
68
|
if (root.split(sep).includes("node_modules")) return "npm";
|
|
65
|
-
return null;
|
|
69
|
+
return compiled ? "binary" : null;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
export function installCommand(kind:
|
|
72
|
+
export function installCommand(kind: "bun" | "npm", version: string): string[] {
|
|
69
73
|
return kind === "bun"
|
|
70
74
|
? ["bun", "add", "-g", `onepatch@${version}`]
|
|
71
75
|
: ["npm", "install", "-g", `onepatch@${version}`];
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
// The `<os>-<arch>` suffix of the release asset built for this machine; null
|
|
79
|
+
// when no prebuilt binary exists for it.
|
|
80
|
+
export function releaseTarget(
|
|
81
|
+
platform: string = process.platform,
|
|
82
|
+
arch: string = process.arch,
|
|
83
|
+
): string | null {
|
|
84
|
+
const os =
|
|
85
|
+
platform === "darwin"
|
|
86
|
+
? "darwin"
|
|
87
|
+
: platform === "linux"
|
|
88
|
+
? "linux"
|
|
89
|
+
: platform === "win32"
|
|
90
|
+
? "windows"
|
|
91
|
+
: null;
|
|
92
|
+
if (os === null || (arch !== "x64" && arch !== "arm64")) return null;
|
|
93
|
+
if (os === "windows" && arch !== "x64") return null;
|
|
94
|
+
return `${os}-${arch}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function assetName(target: string): string {
|
|
98
|
+
return `onepatch-${target}.tar.gz`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function assetUrl(version: string, target: string, base: string = RELEASE_BASE): string {
|
|
102
|
+
return `${base}/v${version}/${assetName(target)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The hex digest recorded for `filename` in a `sha256sum`-style listing.
|
|
106
|
+
export function parseSha256Sums(listing: string, filename: string): string | null {
|
|
107
|
+
for (const line of listing.split("\n")) {
|
|
108
|
+
const m = /^([0-9a-f]{64})\s+\*?(.+)$/.exec(line.trim());
|
|
109
|
+
if (m && m[2] === filename) return m[1] ?? null;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
74
114
|
export type UpdateState = {
|
|
75
115
|
checkedAt?: number;
|
|
76
116
|
latest?: string;
|
|
@@ -113,18 +153,62 @@ export function decideUpdateAction(state: UpdateState, current: string, now: num
|
|
|
113
153
|
}
|
|
114
154
|
|
|
115
155
|
export async function fetchLatestVersion(fetchImpl: typeof fetch = fetch): Promise<string> {
|
|
116
|
-
const res = await fetchImpl(
|
|
156
|
+
const res = await fetchImpl(`${RELEASE_BASE}/latest`, {
|
|
117
157
|
headers: { accept: "application/json" },
|
|
118
158
|
signal: AbortSignal.timeout(10_000),
|
|
119
159
|
});
|
|
120
|
-
if (!res.ok) throw new Error(`
|
|
160
|
+
if (!res.ok) throw new Error(`release host answered ${res.status} for latest`);
|
|
121
161
|
const doc = (await res.json()) as { version?: unknown };
|
|
122
162
|
if (typeof doc.version !== "string") {
|
|
123
|
-
throw new Error("
|
|
163
|
+
throw new Error("release host returned a latest document without a version");
|
|
124
164
|
}
|
|
125
165
|
return doc.version;
|
|
126
166
|
}
|
|
127
167
|
|
|
168
|
+
async function fetchOk(url: string): Promise<Response> {
|
|
169
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
|
170
|
+
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
|
|
171
|
+
return res;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Replace the running standalone binary with the released `version`. The
|
|
175
|
+
// tarball is checked against the release's SHA256SUMS before anything on disk
|
|
176
|
+
// changes, staged next to the binary (same filesystem, so the final rename is
|
|
177
|
+
// atomic), and swapped in with one rename. A process already running keeps
|
|
178
|
+
// its old inode; the next invocation gets the new file.
|
|
179
|
+
export async function replaceBinary(version: string): Promise<void> {
|
|
180
|
+
const target = releaseTarget();
|
|
181
|
+
if (target === null) {
|
|
182
|
+
throw new Error(`no prebuilt onepatch binary for ${process.platform}/${process.arch}`);
|
|
183
|
+
}
|
|
184
|
+
if (target.startsWith("windows")) {
|
|
185
|
+
throw new Error("self-update isn't available on Windows yet; re-run the installer instead");
|
|
186
|
+
}
|
|
187
|
+
const sums = await (await fetchOk(`${RELEASE_BASE}/v${version}/SHA256SUMS`)).text();
|
|
188
|
+
const expected = parseSha256Sums(sums, assetName(target));
|
|
189
|
+
if (expected === null) throw new Error(`release v${version} has no checksum for ${target}`);
|
|
190
|
+
const tarball = new Uint8Array(await (await fetchOk(assetUrl(version, target))).arrayBuffer());
|
|
191
|
+
const actual = new Bun.CryptoHasher("sha256").update(tarball).digest("hex");
|
|
192
|
+
if (actual !== expected) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`checksum mismatch for ${assetName(target)}: expected ${expected}, got ${actual}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const real = installRoot();
|
|
198
|
+
const stage = join(dirname(real), `.onepatch-update-${process.pid}`);
|
|
199
|
+
mkdirSync(stage, { recursive: true });
|
|
200
|
+
try {
|
|
201
|
+
const archive = join(stage, assetName(target));
|
|
202
|
+
writeFileSync(archive, tarball);
|
|
203
|
+
const tar = Bun.spawn({ cmd: ["tar", "-xzf", archive, "-C", stage], stdout: "ignore" });
|
|
204
|
+
if ((await tar.exited) !== 0) throw new Error("tar failed to extract the release archive");
|
|
205
|
+
chmodSync(join(stage, "onepatch"), 0o755);
|
|
206
|
+
renameSync(join(stage, "onepatch"), real);
|
|
207
|
+
} finally {
|
|
208
|
+
rmSync(stage, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
128
212
|
function spawnDetached(cmd: string[]): void {
|
|
129
213
|
Bun.spawn({ cmd, stdin: "ignore", stdout: "ignore", stderr: "ignore" }).unref();
|
|
130
214
|
}
|
|
@@ -141,7 +225,9 @@ export function maybeAutoUpdate(): void {
|
|
|
141
225
|
if (action.kind === "install") {
|
|
142
226
|
// Stamp before spawning so parallel invocations don't dogpile.
|
|
143
227
|
writeUpdateState({ ...state, installStartedAt: Date.now() });
|
|
144
|
-
spawnDetached(
|
|
228
|
+
spawnDetached(
|
|
229
|
+
kind === "binary" ? selfCommand(["update"]) : installCommand(kind, action.latest),
|
|
230
|
+
);
|
|
145
231
|
console.error(
|
|
146
232
|
`onepatch ${currentVersion()} → ${action.latest} is installing in the background ` +
|
|
147
233
|
"(ONEPATCH_NO_UPDATE=1 disables this).",
|
|
@@ -149,7 +235,7 @@ export function maybeAutoUpdate(): void {
|
|
|
149
235
|
} else if (action.kind === "check") {
|
|
150
236
|
// Refresh the cache off-process: `onepatch update --check` fetches the
|
|
151
237
|
// registry and writes the state file, costing this invocation nothing.
|
|
152
|
-
spawnDetached([
|
|
238
|
+
spawnDetached(selfCommand(["update", "--check"]));
|
|
153
239
|
}
|
|
154
240
|
} catch {
|
|
155
241
|
// Auto-update is strictly best-effort; the user's command always wins.
|
|
@@ -177,11 +263,16 @@ export async function runUpdate(opts: { checkOnly: boolean }): Promise<void> {
|
|
|
177
263
|
);
|
|
178
264
|
return;
|
|
179
265
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
266
|
+
if (kind === "binary") {
|
|
267
|
+
console.log(`Updating onepatch ${current} → ${latest} (${installRoot()})…`);
|
|
268
|
+
await replaceBinary(latest);
|
|
269
|
+
} else {
|
|
270
|
+
const cmd = installCommand(kind, latest);
|
|
271
|
+
console.log(`Updating onepatch ${current} → ${latest} (${cmd.join(" ")})…`);
|
|
272
|
+
const proc = Bun.spawn({ cmd, stdin: "ignore", stdout: "inherit", stderr: "inherit" });
|
|
273
|
+
const code = await proc.exited;
|
|
274
|
+
if (code !== 0) throw new Error(`${cmd[0]} exited with code ${code}`);
|
|
275
|
+
}
|
|
185
276
|
writeUpdateState({ checkedAt: Date.now(), latest });
|
|
186
277
|
console.log(`onepatch ${latest} installed.`);
|
|
187
278
|
}
|
package/src/credentials.test.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
credentialsPath,
|
|
7
|
-
deleteCredentials,
|
|
8
|
-
loadCredentials,
|
|
9
|
-
saveCredentials,
|
|
10
|
-
} from "./credentials";
|
|
11
|
-
|
|
12
|
-
const CREDS = {
|
|
13
|
-
clientId: "client_01TEST",
|
|
14
|
-
accessToken: "at",
|
|
15
|
-
refreshToken: "rt",
|
|
16
|
-
email: "dev@example.com",
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
let dir: string;
|
|
20
|
-
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
dir = mkdtempSync(join(tmpdir(), "onepatch-cli-test-"));
|
|
23
|
-
process.env.ONEPATCH_CONFIG_DIR = dir;
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
afterEach(() => {
|
|
27
|
-
delete process.env.ONEPATCH_CONFIG_DIR;
|
|
28
|
-
rmSync(dir, { recursive: true, force: true });
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe("credential store", () => {
|
|
32
|
-
test("round-trips per API base and deletes cleanly", () => {
|
|
33
|
-
expect(loadCredentials("https://a.example")).toBeNull();
|
|
34
|
-
saveCredentials("https://a.example", CREDS);
|
|
35
|
-
saveCredentials("https://b.example", { ...CREDS, email: "other@example.com" });
|
|
36
|
-
expect(loadCredentials("https://a.example")?.email).toBe("dev@example.com");
|
|
37
|
-
expect(loadCredentials("https://b.example")?.email).toBe("other@example.com");
|
|
38
|
-
expect(deleteCredentials("https://a.example")).toBe(true);
|
|
39
|
-
expect(loadCredentials("https://a.example")).toBeNull();
|
|
40
|
-
expect(loadCredentials("https://b.example")?.email).toBe("other@example.com");
|
|
41
|
-
expect(deleteCredentials("https://a.example")).toBe(false);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("credential file is written 0600", () => {
|
|
45
|
-
saveCredentials("https://a.example", CREDS);
|
|
46
|
-
const mode = statSync(credentialsPath()).mode & 0o777;
|
|
47
|
-
expect(mode).toBe(0o600);
|
|
48
|
-
});
|
|
49
|
-
});
|
package/src/update.test.ts
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
CHECK_INTERVAL_MS,
|
|
6
|
-
compareVersions,
|
|
7
|
-
decideUpdateAction,
|
|
8
|
-
detectInstallKind,
|
|
9
|
-
fetchLatestVersion,
|
|
10
|
-
INSTALL_RETRY_MS,
|
|
11
|
-
installCommand,
|
|
12
|
-
} from "./update";
|
|
13
|
-
|
|
14
|
-
describe("compareVersions", () => {
|
|
15
|
-
test("orders plain versions", () => {
|
|
16
|
-
expect(compareVersions("0.2.0", "0.3.0")).toBeLessThan(0);
|
|
17
|
-
expect(compareVersions("0.3.0", "0.2.9")).toBeGreaterThan(0);
|
|
18
|
-
expect(compareVersions("1.0.0", "0.99.99")).toBeGreaterThan(0);
|
|
19
|
-
expect(compareVersions("0.2.0", "0.2.0")).toBe(0);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("compares numerically, not lexically", () => {
|
|
23
|
-
expect(compareVersions("0.10.0", "0.9.0")).toBeGreaterThan(0);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("prerelease sorts below its release", () => {
|
|
27
|
-
expect(compareVersions("1.0.0-beta.1", "1.0.0")).toBeLessThan(0);
|
|
28
|
-
expect(compareVersions("1.0.0", "1.0.0-rc.2")).toBeGreaterThan(0);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("tolerates short versions", () => {
|
|
32
|
-
expect(compareVersions("1.0", "1.0.0")).toBe(0);
|
|
33
|
-
expect(compareVersions("1", "1.0.1")).toBeLessThan(0);
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
describe("decideUpdateAction", () => {
|
|
38
|
-
const now = 1_700_000_000_000;
|
|
39
|
-
|
|
40
|
-
test("empty state asks for a background check", () => {
|
|
41
|
-
expect(decideUpdateAction({}, "0.2.0", now)).toEqual({ kind: "check" });
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("fresh state with no newer version does nothing", () => {
|
|
45
|
-
expect(decideUpdateAction({ checkedAt: now - 1000, latest: "0.2.0" }, "0.2.0", now)).toEqual({
|
|
46
|
-
kind: "none",
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("stale state asks for a background check", () => {
|
|
51
|
-
expect(
|
|
52
|
-
decideUpdateAction({ checkedAt: now - CHECK_INTERVAL_MS - 1, latest: "0.2.0" }, "0.2.0", now),
|
|
53
|
-
).toEqual({ kind: "check" });
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("known newer version installs", () => {
|
|
57
|
-
expect(decideUpdateAction({ checkedAt: now, latest: "0.3.0" }, "0.2.0", now)).toEqual({
|
|
58
|
-
kind: "install",
|
|
59
|
-
latest: "0.3.0",
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("an in-flight install suppresses re-spawning", () => {
|
|
64
|
-
expect(
|
|
65
|
-
decideUpdateAction(
|
|
66
|
-
{ checkedAt: now, latest: "0.3.0", installStartedAt: now - 1000 },
|
|
67
|
-
"0.2.0",
|
|
68
|
-
now,
|
|
69
|
-
),
|
|
70
|
-
).toEqual({ kind: "none" });
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("a failed install retries after the guard window", () => {
|
|
74
|
-
expect(
|
|
75
|
-
decideUpdateAction(
|
|
76
|
-
{ checkedAt: now, latest: "0.3.0", installStartedAt: now - INSTALL_RETRY_MS - 1 },
|
|
77
|
-
"0.2.0",
|
|
78
|
-
now,
|
|
79
|
-
),
|
|
80
|
-
).toEqual({ kind: "install", latest: "0.3.0" });
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("a cached latest older than current never installs", () => {
|
|
84
|
-
expect(decideUpdateAction({ checkedAt: now, latest: "0.1.0" }, "0.2.0", now)).toEqual({
|
|
85
|
-
kind: "none",
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("detectInstallKind", () => {
|
|
91
|
-
test("bun global install", () => {
|
|
92
|
-
expect(
|
|
93
|
-
detectInstallKind(join(homedir(), ".bun", "install", "global", "node_modules", "onepatch")),
|
|
94
|
-
).toBe("bun");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("npm global install", () => {
|
|
98
|
-
expect(detectInstallKind("/usr/local/lib/node_modules/onepatch")).toBe("npm");
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("source checkout is not updatable", () => {
|
|
102
|
-
expect(detectInstallKind("/Users/someone/dev/onepatch-cli")).toBeNull();
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
describe("installCommand", () => {
|
|
107
|
-
test("pins the discovered version, not the latest tag", () => {
|
|
108
|
-
expect(installCommand("bun", "0.3.0")).toEqual(["bun", "add", "-g", "onepatch@0.3.0"]);
|
|
109
|
-
expect(installCommand("npm", "0.3.0")).toEqual(["npm", "install", "-g", "onepatch@0.3.0"]);
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
describe("fetchLatestVersion", () => {
|
|
114
|
-
test("reads the version from the registry manifest", async () => {
|
|
115
|
-
const fake = (async () => Response.json({ version: "0.4.2" })) as unknown as typeof fetch;
|
|
116
|
-
expect(await fetchLatestVersion(fake)).toBe("0.4.2");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test("rejects a manifest without a version", async () => {
|
|
120
|
-
const fake = (async () => Response.json({})) as unknown as typeof fetch;
|
|
121
|
-
await expect(fetchLatestVersion(fake)).rejects.toThrow("without a version");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test("rejects a non-2xx answer", async () => {
|
|
125
|
-
const fake = (async () => new Response("nope", { status: 503 })) as unknown as typeof fetch;
|
|
126
|
-
await expect(fetchLatestVersion(fake)).rejects.toThrow("503");
|
|
127
|
-
});
|
|
128
|
-
});
|
package/src/workos.test.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { type DeviceAuthorization, pollForDeviceToken, refreshTokens } from "./workos";
|
|
3
|
-
|
|
4
|
-
const DEVICE: DeviceAuthorization = {
|
|
5
|
-
device_code: "dev_123",
|
|
6
|
-
user_code: "ABCD-1234",
|
|
7
|
-
verification_uri: "https://auth.example/device",
|
|
8
|
-
verification_uri_complete: "https://auth.example/device?code=ABCD-1234",
|
|
9
|
-
expires_in: 300,
|
|
10
|
-
interval: 5,
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
// A fetch stub that answers the authenticate endpoint from a scripted queue.
|
|
14
|
-
function scriptedFetch(responses: Array<{ status: number; body: unknown }>): {
|
|
15
|
-
fetchImpl: typeof fetch;
|
|
16
|
-
requests: URLSearchParams[];
|
|
17
|
-
} {
|
|
18
|
-
const requests: URLSearchParams[] = [];
|
|
19
|
-
const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => {
|
|
20
|
-
requests.push(new URLSearchParams(String(init?.body ?? "")));
|
|
21
|
-
const next = responses.shift();
|
|
22
|
-
if (!next) throw new Error("scripted fetch exhausted");
|
|
23
|
-
return Response.json(next.body, { status: next.status });
|
|
24
|
-
}) as typeof fetch;
|
|
25
|
-
return { fetchImpl, requests };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const TOKENS = {
|
|
29
|
-
access_token: "at_new",
|
|
30
|
-
refresh_token: "rt_new",
|
|
31
|
-
user: { id: "user_1", email: "dev@example.com" },
|
|
32
|
-
organization_id: "org_1",
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
describe("pollForDeviceToken", () => {
|
|
36
|
-
test("keeps polling through authorization_pending, widens on slow_down, then succeeds", async () => {
|
|
37
|
-
const sleeps: number[] = [];
|
|
38
|
-
const { fetchImpl, requests } = scriptedFetch([
|
|
39
|
-
{ status: 400, body: { error: "authorization_pending" } },
|
|
40
|
-
{ status: 400, body: { error: "slow_down" } },
|
|
41
|
-
{ status: 200, body: TOKENS },
|
|
42
|
-
]);
|
|
43
|
-
const result = await pollForDeviceToken("client_01TEST", DEVICE, {
|
|
44
|
-
fetchImpl,
|
|
45
|
-
sleep: async (ms) => {
|
|
46
|
-
sleeps.push(ms);
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
expect(result.accessToken).toBe("at_new");
|
|
50
|
-
expect(result.refreshToken).toBe("rt_new");
|
|
51
|
-
expect(result.user?.email).toBe("dev@example.com");
|
|
52
|
-
expect(result.organizationId).toBe("org_1");
|
|
53
|
-
// 5s while pending, then 5+5=10s after slow_down.
|
|
54
|
-
expect(sleeps).toEqual([5000, 10000]);
|
|
55
|
-
expect(requests[0]?.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code");
|
|
56
|
-
expect(requests[0]?.get("device_code")).toBe("dev_123");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("access_denied stops with a clear error", async () => {
|
|
60
|
-
const { fetchImpl } = scriptedFetch([{ status: 400, body: { error: "access_denied" } }]);
|
|
61
|
-
await expect(
|
|
62
|
-
pollForDeviceToken("client_01TEST", DEVICE, { fetchImpl, sleep: async () => {} }),
|
|
63
|
-
).rejects.toThrow(/denied/);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("expired_token tells the user to log in again", async () => {
|
|
67
|
-
const { fetchImpl } = scriptedFetch([{ status: 400, body: { error: "expired_token" } }]);
|
|
68
|
-
await expect(
|
|
69
|
-
pollForDeviceToken("client_01TEST", DEVICE, { fetchImpl, sleep: async () => {} }),
|
|
70
|
-
).rejects.toThrow(/login/);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe("refreshTokens", () => {
|
|
75
|
-
test("sends the refresh grant and parses rotated tokens", async () => {
|
|
76
|
-
const { fetchImpl, requests } = scriptedFetch([{ status: 200, body: TOKENS }]);
|
|
77
|
-
const result = await refreshTokens("client_01TEST", "rt_old", fetchImpl);
|
|
78
|
-
expect(result.refreshToken).toBe("rt_new");
|
|
79
|
-
expect(requests[0]?.get("grant_type")).toBe("refresh_token");
|
|
80
|
-
expect(requests[0]?.get("refresh_token")).toBe("rt_old");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("a rejected refresh surfaces as a re-login prompt", async () => {
|
|
84
|
-
const { fetchImpl } = scriptedFetch([{ status: 400, body: { error: "invalid_grant" } }]);
|
|
85
|
-
await expect(refreshTokens("client_01TEST", "rt_old", fetchImpl)).rejects.toThrow(/login/);
|
|
86
|
-
});
|
|
87
|
-
});
|