onepatch 0.2.0 → 0.4.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 +10 -0
- package/package.json +1 -1
- package/src/cli.ts +34 -1
- package/src/credentials.ts +15 -6
- package/src/hook.test.ts +67 -0
- package/src/hook.ts +107 -0
- package/src/install.test.ts +67 -0
- package/src/install.ts +150 -0
- package/src/update.test.ts +128 -0
- package/src/update.ts +187 -0
package/README.md
CHANGED
|
@@ -27,10 +27,20 @@ onepatch incidents read <num> [--raw]
|
|
|
27
27
|
|
|
28
28
|
onepatch tools # list the server's MCP tools
|
|
29
29
|
onepatch whoami
|
|
30
|
+
|
|
31
|
+
onepatch install # wire this machine's coding agents (Claude Code, Codex, Cursor)
|
|
30
32
|
```
|
|
31
33
|
|
|
32
34
|
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
35
|
|
|
36
|
+
## Coding agents
|
|
37
|
+
|
|
38
|
+
`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).
|
|
39
|
+
|
|
40
|
+
## Updates
|
|
41
|
+
|
|
42
|
+
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 whichever package manager owns the copy (bun or npm) and 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.
|
|
43
|
+
|
|
34
44
|
## Programmatic use
|
|
35
45
|
|
|
36
46
|
```ts
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,9 @@ 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";
|
|
11
|
+
import { currentVersion, maybeAutoUpdate, runUpdate } from "./update";
|
|
9
12
|
import { pollForDeviceToken, startDeviceAuthorization } from "./workos";
|
|
10
13
|
|
|
11
14
|
const USAGE = `onepatch — OnePatch from the terminal
|
|
@@ -29,13 +32,21 @@ Usage:
|
|
|
29
32
|
onepatch incidents read <num> [--raw] Read one incident by number
|
|
30
33
|
|
|
31
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)
|
|
40
|
+
onepatch update Update the CLI to the latest version now
|
|
32
41
|
|
|
33
42
|
Global flags:
|
|
34
43
|
--api <url> Deployment base URL (default: $ONEPATCH_API_URL or https://app.onepatch.dev)
|
|
35
44
|
--json Print raw MCP content blocks as JSON
|
|
45
|
+
--version Print the CLI version
|
|
36
46
|
-h, --help Show this help
|
|
37
47
|
|
|
38
|
-
Pass "-" for <sql> or <text> to read it from stdin
|
|
48
|
+
Pass "-" for <sql> or <text> to read it from stdin.
|
|
49
|
+
The CLI keeps itself current in the background; ONEPATCH_NO_UPDATE=1 disables that.`;
|
|
39
50
|
|
|
40
51
|
function fail(message: string): never {
|
|
41
52
|
console.error(message);
|
|
@@ -128,18 +139,40 @@ async function main(): Promise<void> {
|
|
|
128
139
|
"waiting-on": { type: "string" },
|
|
129
140
|
limit: { type: "string" },
|
|
130
141
|
raw: { type: "boolean", default: false },
|
|
142
|
+
version: { type: "boolean", default: false },
|
|
143
|
+
// Internal: `onepatch update --check` only refreshes the cached
|
|
144
|
+
// latest-version state; the background updater spawns it.
|
|
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" },
|
|
131
150
|
},
|
|
132
151
|
});
|
|
133
152
|
|
|
153
|
+
if (flags.version) {
|
|
154
|
+
console.log(currentVersion());
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
134
158
|
const [noun, verb, ...rest] = positionals;
|
|
135
159
|
if (flags.help || !noun) {
|
|
136
160
|
console.log(USAGE);
|
|
137
161
|
return;
|
|
138
162
|
}
|
|
139
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
|
+
|
|
168
|
+
if (noun === "update") return await runUpdate({ checkOnly: flags.check });
|
|
169
|
+
// Every other command triggers the zero-cost background update pass.
|
|
170
|
+
maybeAutoUpdate();
|
|
171
|
+
|
|
140
172
|
const api = resolveApiUrl(flags.api);
|
|
141
173
|
|
|
142
174
|
if (noun === "login") return await login(api);
|
|
175
|
+
if (noun === "install") return await runInstall(api, verb);
|
|
143
176
|
if (noun === "logout") {
|
|
144
177
|
console.log(deleteCredentials(api) ? `Logged out of ${api}.` : `No credentials for ${api}.`);
|
|
145
178
|
return;
|
package/src/credentials.ts
CHANGED
|
@@ -16,11 +16,21 @@ export type Credentials = {
|
|
|
16
16
|
|
|
17
17
|
type CredentialsFile = Record<string, Credentials>;
|
|
18
18
|
|
|
19
|
-
export function
|
|
20
|
-
|
|
19
|
+
export function configDir(): string {
|
|
20
|
+
return (
|
|
21
21
|
process.env.ONEPATCH_CONFIG_DIR ??
|
|
22
|
-
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "onepatch")
|
|
23
|
-
|
|
22
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "onepatch")
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ensureConfigDir(): string {
|
|
27
|
+
const dir = configDir();
|
|
28
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function credentialsPath(): string {
|
|
33
|
+
return join(configDir(), "credentials.json");
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
function readFile(): CredentialsFile {
|
|
@@ -37,8 +47,7 @@ export function loadCredentials(api: string): Credentials | null {
|
|
|
37
47
|
|
|
38
48
|
export function saveCredentials(api: string, creds: Credentials): void {
|
|
39
49
|
const path = credentialsPath();
|
|
40
|
-
|
|
41
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
ensureConfigDir();
|
|
42
51
|
const all = readFile();
|
|
43
52
|
all[api] = creds;
|
|
44
53
|
writeFileSync(path, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
package/src/hook.test.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildContextLine, decideHookAction, HOOK_CACHE_TTL_MS } from "./hook";
|
|
3
|
+
|
|
4
|
+
// Verbatim shape of the CLI's list_incidents digest.
|
|
5
|
+
const DIGEST = `86 open incidents · newest activity first
|
|
6
|
+
115 closed/folded incidents hidden — scope: "all" lists them
|
|
7
|
+
showing the newest 5 — raise limit for older
|
|
8
|
+
|
|
9
|
+
INC-193 · unjudged · waiting on time · false alarm · A stale staging deploy failure is still flagged · updated 2026-09-01T15:04Z
|
|
10
|
+
INC-183 · unjudged · waiting on human · Search intermittently degraded · updated 2026-09-01T14:58Z
|
|
11
|
+
INC-192 · P3 · waiting on human · false alarm · Stale boot-time probe · updated 2026-09-01T12:51Z
|
|
12
|
+
incident-3de5a4e4 · no incident document yet (read_chat incident-3de5a4e4) · updated 2026-08-30T05:26Z`;
|
|
13
|
+
|
|
14
|
+
describe("buildContextLine", () => {
|
|
15
|
+
test("keeps the count and the two newest INC rows, drops timestamps", () => {
|
|
16
|
+
const line = buildContextLine(DIGEST);
|
|
17
|
+
expect(line).toContain("86 open incidents");
|
|
18
|
+
expect(line).toContain(
|
|
19
|
+
"INC-193 · unjudged · waiting on time · false alarm · A stale staging deploy failure is still flagged",
|
|
20
|
+
);
|
|
21
|
+
expect(line).toContain("INC-183");
|
|
22
|
+
expect(line).not.toContain("INC-192");
|
|
23
|
+
expect(line).not.toContain("updated 2026");
|
|
24
|
+
expect(line).not.toContain("incident-3de5a4e4");
|
|
25
|
+
expect(line.split("\n")).toHaveLength(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("injects nothing when no incidents are open", () => {
|
|
29
|
+
expect(buildContextLine("0 open incidents · newest activity first\n")).toBe("");
|
|
30
|
+
expect(buildContextLine("")).toBe("");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("singularizes a lone incident", () => {
|
|
34
|
+
const line = buildContextLine(
|
|
35
|
+
"1 open incident · newest\n\nINC-7 · P1 · waiting on agent · x · updated now",
|
|
36
|
+
);
|
|
37
|
+
expect(line).toContain("1 open incident —");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("decideHookAction", () => {
|
|
42
|
+
const now = 1_000_000_000_000;
|
|
43
|
+
|
|
44
|
+
test("fresh cache: emit, no refresh", () => {
|
|
45
|
+
const a = decideHookAction({ fetchedAt: now - 1000, line: "[onepatch] hi" }, now);
|
|
46
|
+
expect(a).toEqual({ emit: "[onepatch] hi", refresh: false });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("stale cache: emit the stale line AND refresh", () => {
|
|
50
|
+
const a = decideHookAction({ fetchedAt: now - HOOK_CACHE_TTL_MS - 1, line: "old" }, now);
|
|
51
|
+
expect(a).toEqual({ emit: "old", refresh: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("empty cache: emit nothing, refresh", () => {
|
|
55
|
+
expect(decideHookAction({}, now)).toEqual({ emit: "", refresh: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("refresh already in flight is not re-spawned", () => {
|
|
59
|
+
const a = decideHookAction({ refreshStartedAt: now - 1000 }, now);
|
|
60
|
+
expect(a.refresh).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a stuck refresh stops suppressing after the retry window", () => {
|
|
64
|
+
const a = decideHookAction({ refreshStartedAt: now - 10 * 60 * 1000 }, now);
|
|
65
|
+
expect(a.refresh).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/hook.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
|
|
12
|
+
export const HOOK_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
13
|
+
// A refresh normally lands in seconds; this only bounds how long a failed one
|
|
14
|
+
// suppresses retries.
|
|
15
|
+
const REFRESH_RETRY_MS = 2 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export type HookCache = {
|
|
18
|
+
fetchedAt?: number;
|
|
19
|
+
refreshStartedAt?: number;
|
|
20
|
+
// The context line to inject; "" means "healthy, nothing worth injecting".
|
|
21
|
+
line?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function hookCachePath(): string {
|
|
25
|
+
return join(configDir(), "hook-status.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readHookCache(): HookCache {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(hookCachePath(), "utf8")) as HookCache;
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function writeHookCache(cache: HookCache): void {
|
|
37
|
+
ensureConfigDir();
|
|
38
|
+
writeFileSync(hookCachePath(), `${JSON.stringify(cache)}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type HookAction = { emit: string; refresh: boolean };
|
|
42
|
+
|
|
43
|
+
// Pure decision: what to print and whether to kick a background refresh.
|
|
44
|
+
// A stale line still gets emitted — slightly old incident context beats none,
|
|
45
|
+
// and the refresh makes the next prompt current.
|
|
46
|
+
export function decideHookAction(cache: HookCache, now: number): HookAction {
|
|
47
|
+
const fresh = cache.fetchedAt !== undefined && now - cache.fetchedAt < HOOK_CACHE_TTL_MS;
|
|
48
|
+
const refreshInFlight =
|
|
49
|
+
cache.refreshStartedAt !== undefined && now - cache.refreshStartedAt < REFRESH_RETRY_MS;
|
|
50
|
+
return { emit: cache.line ?? "", refresh: !fresh && !refreshInFlight };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Compress the list_incidents digest into one context line. The digest leads
|
|
54
|
+
// with "<N> open incidents · …" and lists one incident per line
|
|
55
|
+
// ("INC-193 · P2 · waiting on human · <title> · updated <ts>"); we keep the
|
|
56
|
+
// count and the two newest rows, minus the updated-at tail.
|
|
57
|
+
export function buildContextLine(incidentsText: string): string {
|
|
58
|
+
const lines = incidentsText.split("\n").map((l) => l.trim());
|
|
59
|
+
const rows = lines
|
|
60
|
+
.filter((l) => /^INC-\d+ · /.test(l))
|
|
61
|
+
.map((l) => l.replace(/ · updated \S+$/, ""));
|
|
62
|
+
const count = Number(lines[0]?.match(/^(\d+) open incident/)?.[1] ?? rows.length);
|
|
63
|
+
if (count === 0 || rows.length === 0) return "";
|
|
64
|
+
const shown = rows.slice(0, 2).join("; ");
|
|
65
|
+
return (
|
|
66
|
+
`[onepatch] ${count} open incident${count === 1 ? "" : "s"} — newest: ${shown}. ` +
|
|
67
|
+
"Details: the onepatch MCP tools or `onepatch incidents read <num>`."
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function refreshHookCache(api: string): Promise<void> {
|
|
72
|
+
const client = new OnepatchClient({ api });
|
|
73
|
+
try {
|
|
74
|
+
const text = await client.incidents.list({ scope: "open", limit: 10 });
|
|
75
|
+
writeHookCache({ fetchedAt: Date.now(), line: buildContextLine(text) });
|
|
76
|
+
} finally {
|
|
77
|
+
await client.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runHook(api: string, event: string | undefined, refresh: boolean) {
|
|
82
|
+
if (refresh) {
|
|
83
|
+
// Internal mode spawned below; errors just leave the stale cache in place.
|
|
84
|
+
try {
|
|
85
|
+
await refreshHookCache(api);
|
|
86
|
+
} catch {}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (event !== "user-prompt-submit") return; // unknown events inject nothing
|
|
90
|
+
// Hooks pipe the event payload on stdin; drain it so the agent never sees
|
|
91
|
+
// a broken pipe, but nothing in it changes what we inject.
|
|
92
|
+
try {
|
|
93
|
+
await Bun.stdin.text();
|
|
94
|
+
} catch {}
|
|
95
|
+
if (!loadCredentials(api)) return; // not logged in — stay silent
|
|
96
|
+
const action = decideHookAction(readHookCache(), Date.now());
|
|
97
|
+
if (action.refresh) {
|
|
98
|
+
writeHookCache({ ...readHookCache(), refreshStartedAt: Date.now() });
|
|
99
|
+
Bun.spawn({
|
|
100
|
+
cmd: [process.execPath, join(import.meta.dir, "cli.ts"), "hook", "--refresh", "--api", api],
|
|
101
|
+
stdin: "ignore",
|
|
102
|
+
stdout: "ignore",
|
|
103
|
+
stderr: "ignore",
|
|
104
|
+
}).unref();
|
|
105
|
+
}
|
|
106
|
+
if (action.emit !== "") console.log(action.emit);
|
|
107
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ensureCodexMcpServer, ensureCursorMcpServer } from "./install";
|
|
3
|
+
|
|
4
|
+
const URL = "https://app.onepatch.dev/mcp";
|
|
5
|
+
|
|
6
|
+
describe("ensureCodexMcpServer", () => {
|
|
7
|
+
test("appends a block to an empty config", () => {
|
|
8
|
+
const r = ensureCodexMcpServer("", URL);
|
|
9
|
+
expect(r.changed).toBe(true);
|
|
10
|
+
expect(r.text).toBe(`[mcp_servers.onepatch]\nurl = "${URL}"\n`);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("separates the block from existing content with a blank line", () => {
|
|
14
|
+
const r = ensureCodexMcpServer('model = "gpt-5"\n', URL);
|
|
15
|
+
expect(r.changed).toBe(true);
|
|
16
|
+
expect(r.text).toBe(`model = "gpt-5"\n\n[mcp_servers.onepatch]\nurl = "${URL}"\n`);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("adds a newline when the file lacks a trailing one", () => {
|
|
20
|
+
const r = ensureCodexMcpServer('model = "gpt-5"', URL);
|
|
21
|
+
expect(r.text.startsWith('model = "gpt-5"\n\n[')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("is idempotent", () => {
|
|
25
|
+
const once = ensureCodexMcpServer("", URL);
|
|
26
|
+
const twice = ensureCodexMcpServer(once.text, URL);
|
|
27
|
+
expect(twice.changed).toBe(false);
|
|
28
|
+
expect(twice.text).toBe(once.text);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("respects an existing block even with different settings", () => {
|
|
32
|
+
const existing = '[mcp_servers.onepatch]\nurl = "https://elsewhere.example/mcp"\n';
|
|
33
|
+
expect(ensureCodexMcpServer(existing, URL).changed).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("does not match other servers or commented blocks", () => {
|
|
37
|
+
const other = '[mcp_servers.other]\nurl = "x"\n# [mcp_servers.onepatch] disabled\n';
|
|
38
|
+
expect(ensureCodexMcpServer(other, URL).changed).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("ensureCursorMcpServer", () => {
|
|
43
|
+
test("creates the document when the file is missing", () => {
|
|
44
|
+
const r = ensureCursorMcpServer(null, URL);
|
|
45
|
+
expect(r.changed).toBe(true);
|
|
46
|
+
expect(JSON.parse(r.text)).toEqual({ mcpServers: { onepatch: { url: URL } } });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("preserves existing servers and unknown top-level keys", () => {
|
|
50
|
+
const existing = JSON.stringify({ mcpServers: { foo: { command: "foo" } }, other: 1 });
|
|
51
|
+
const r = ensureCursorMcpServer(existing, URL);
|
|
52
|
+
expect(r.changed).toBe(true);
|
|
53
|
+
expect(JSON.parse(r.text)).toEqual({
|
|
54
|
+
mcpServers: { foo: { command: "foo" }, onepatch: { url: URL } },
|
|
55
|
+
other: 1,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("is idempotent, even against a user-customized entry", () => {
|
|
60
|
+
const existing = JSON.stringify({ mcpServers: { onepatch: { url: "https://custom/mcp" } } });
|
|
61
|
+
expect(ensureCursorMcpServer(existing, URL).changed).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("refuses to clobber an unparseable file", () => {
|
|
65
|
+
expect(() => ensureCursorMcpServer("{not json", URL)).toThrow(/not valid JSON/);
|
|
66
|
+
});
|
|
67
|
+
});
|
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
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
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/update.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Self-update. The rule that shapes everything here: the command the user
|
|
2
|
+
// actually typed never waits on update machinery. Each invocation reads one
|
|
3
|
+
// small cached JSON file; a stale cache spawns a detached background process
|
|
4
|
+
// to refresh it from the npm registry, and a cache that already names a newer
|
|
5
|
+
// version spawns a detached reinstall via whichever package manager owns this
|
|
6
|
+
// copy. Either way the foreground command proceeds immediately, and the next
|
|
7
|
+
// invocation runs the new code.
|
|
8
|
+
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join, sep } from "node:path";
|
|
11
|
+
import { configDir, ensureConfigDir } from "./credentials";
|
|
12
|
+
|
|
13
|
+
export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
// A spawned install normally lands in seconds; this only bounds how long a
|
|
15
|
+
// *failed* install suppresses retries (and dogpiles from parallel invocations).
|
|
16
|
+
export const INSTALL_RETRY_MS = 15 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
const packageRoot = (() => {
|
|
19
|
+
// The bin shim is a symlink into the installed package; resolve it so the
|
|
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
|
+
|
|
29
|
+
export function currentVersion(): string {
|
|
30
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as {
|
|
31
|
+
version?: unknown;
|
|
32
|
+
};
|
|
33
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Plain x.y.z compare; a prerelease suffix sorts below its release.
|
|
37
|
+
export function compareVersions(a: string, b: string): number {
|
|
38
|
+
const parse = (v: string) => {
|
|
39
|
+
const [main = "", pre] = v.split("-", 2);
|
|
40
|
+
const nums = main.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
41
|
+
while (nums.length < 3) nums.push(0);
|
|
42
|
+
return { nums, pre };
|
|
43
|
+
};
|
|
44
|
+
const pa = parse(a);
|
|
45
|
+
const pb = parse(b);
|
|
46
|
+
for (let i = 0; i < 3; i++) {
|
|
47
|
+
const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
|
|
48
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
49
|
+
}
|
|
50
|
+
if (pa.pre === pb.pre) return 0;
|
|
51
|
+
if (pa.pre === undefined) return 1;
|
|
52
|
+
if (pb.pre === undefined) return -1;
|
|
53
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type InstallKind = "bun" | "npm";
|
|
57
|
+
|
|
58
|
+
// Which package manager owns this copy — decided from where the package
|
|
59
|
+
// physically lives, because that's also the only place the answer is honest.
|
|
60
|
+
// null means a source checkout (dev), where auto-update must stay away.
|
|
61
|
+
export function detectInstallKind(root: string = packageRoot): InstallKind | null {
|
|
62
|
+
const bunInstall = process.env.BUN_INSTALL ?? join(homedir(), ".bun");
|
|
63
|
+
if (root.startsWith(bunInstall + sep) || root.split(sep).includes(".bun")) return "bun";
|
|
64
|
+
if (root.split(sep).includes("node_modules")) return "npm";
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function installCommand(kind: InstallKind, version: string): string[] {
|
|
69
|
+
return kind === "bun"
|
|
70
|
+
? ["bun", "add", "-g", `onepatch@${version}`]
|
|
71
|
+
: ["npm", "install", "-g", `onepatch@${version}`];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type UpdateState = {
|
|
75
|
+
checkedAt?: number;
|
|
76
|
+
latest?: string;
|
|
77
|
+
installStartedAt?: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function updateStatePath(): string {
|
|
81
|
+
return join(configDir(), "update-check.json");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function readUpdateState(): UpdateState {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(readFileSync(updateStatePath(), "utf8")) as UpdateState;
|
|
87
|
+
} catch {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function writeUpdateState(state: UpdateState): void {
|
|
93
|
+
ensureConfigDir();
|
|
94
|
+
writeFileSync(updateStatePath(), `${JSON.stringify(state)}\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type UpdateAction =
|
|
98
|
+
| { kind: "install"; latest: string }
|
|
99
|
+
| { kind: "check" }
|
|
100
|
+
| { kind: "none" };
|
|
101
|
+
|
|
102
|
+
export function decideUpdateAction(state: UpdateState, current: string, now: number): UpdateAction {
|
|
103
|
+
if (state.latest && compareVersions(state.latest, current) > 0) {
|
|
104
|
+
if (state.installStartedAt !== undefined && now - state.installStartedAt < INSTALL_RETRY_MS) {
|
|
105
|
+
return { kind: "none" };
|
|
106
|
+
}
|
|
107
|
+
return { kind: "install", latest: state.latest };
|
|
108
|
+
}
|
|
109
|
+
if (state.checkedAt === undefined || now - state.checkedAt > CHECK_INTERVAL_MS) {
|
|
110
|
+
return { kind: "check" };
|
|
111
|
+
}
|
|
112
|
+
return { kind: "none" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function fetchLatestVersion(fetchImpl: typeof fetch = fetch): Promise<string> {
|
|
116
|
+
const res = await fetchImpl("https://registry.npmjs.org/onepatch/latest", {
|
|
117
|
+
headers: { accept: "application/json" },
|
|
118
|
+
signal: AbortSignal.timeout(10_000),
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) throw new Error(`npm registry answered ${res.status} for onepatch@latest`);
|
|
121
|
+
const doc = (await res.json()) as { version?: unknown };
|
|
122
|
+
if (typeof doc.version !== "string") {
|
|
123
|
+
throw new Error("npm registry returned a manifest without a version");
|
|
124
|
+
}
|
|
125
|
+
return doc.version;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function spawnDetached(cmd: string[]): void {
|
|
129
|
+
Bun.spawn({ cmd, stdin: "ignore", stdout: "ignore", stderr: "ignore" }).unref();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Called on every ordinary invocation. Must never throw, never block, never
|
|
133
|
+
// touch the network in-process.
|
|
134
|
+
export function maybeAutoUpdate(): void {
|
|
135
|
+
try {
|
|
136
|
+
if (process.env.ONEPATCH_NO_UPDATE) return;
|
|
137
|
+
const kind = detectInstallKind();
|
|
138
|
+
if (kind === null) return;
|
|
139
|
+
const state = readUpdateState();
|
|
140
|
+
const action = decideUpdateAction(state, currentVersion(), Date.now());
|
|
141
|
+
if (action.kind === "install") {
|
|
142
|
+
// Stamp before spawning so parallel invocations don't dogpile.
|
|
143
|
+
writeUpdateState({ ...state, installStartedAt: Date.now() });
|
|
144
|
+
spawnDetached(installCommand(kind, action.latest));
|
|
145
|
+
console.error(
|
|
146
|
+
`onepatch ${currentVersion()} → ${action.latest} is installing in the background ` +
|
|
147
|
+
"(ONEPATCH_NO_UPDATE=1 disables this).",
|
|
148
|
+
);
|
|
149
|
+
} else if (action.kind === "check") {
|
|
150
|
+
// Refresh the cache off-process: `onepatch update --check` fetches the
|
|
151
|
+
// registry and writes the state file, costing this invocation nothing.
|
|
152
|
+
spawnDetached([process.execPath, join(packageRoot, "src", "cli.ts"), "update", "--check"]);
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// Auto-update is strictly best-effort; the user's command always wins.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The explicit `onepatch update` command (and its hidden `--check` cache-refresh
|
|
160
|
+
// mode used by the background spawn above).
|
|
161
|
+
export async function runUpdate(opts: { checkOnly: boolean }): Promise<void> {
|
|
162
|
+
const latest = await fetchLatestVersion();
|
|
163
|
+
const prior = readUpdateState();
|
|
164
|
+
writeUpdateState({ ...prior, checkedAt: Date.now(), latest });
|
|
165
|
+
if (opts.checkOnly) return;
|
|
166
|
+
|
|
167
|
+
const current = currentVersion();
|
|
168
|
+
if (compareVersions(latest, current) <= 0) {
|
|
169
|
+
console.log(`onepatch ${current} is up to date.`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const kind = detectInstallKind();
|
|
173
|
+
if (kind === null) {
|
|
174
|
+
console.log(
|
|
175
|
+
`onepatch ${latest} is available (you have ${current}), but this copy isn't a ` +
|
|
176
|
+
"global install — update your checkout with git instead.",
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const cmd = installCommand(kind, latest);
|
|
181
|
+
console.log(`Updating onepatch ${current} → ${latest} (${cmd.join(" ")})…`);
|
|
182
|
+
const proc = Bun.spawn({ cmd, stdin: "ignore", stdout: "inherit", stderr: "inherit" });
|
|
183
|
+
const code = await proc.exited;
|
|
184
|
+
if (code !== 0) throw new Error(`${cmd[0]} exited with code ${code}`);
|
|
185
|
+
writeUpdateState({ checkedAt: Date.now(), latest });
|
|
186
|
+
console.log(`onepatch ${latest} installed.`);
|
|
187
|
+
}
|