onepatch 0.2.0 → 0.3.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 CHANGED
@@ -31,6 +31,10 @@ onepatch whoami
31
31
 
32
32
  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
33
 
34
+ ## Updates
35
+
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 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.
37
+
34
38
  ## Programmatic use
35
39
 
36
40
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onepatch",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@ 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 { currentVersion, maybeAutoUpdate, runUpdate } from "./update";
9
10
  import { pollForDeviceToken, startDeviceAuthorization } from "./workos";
10
11
 
11
12
  const USAGE = `onepatch — OnePatch from the terminal
@@ -29,13 +30,16 @@ Usage:
29
30
  onepatch incidents read <num> [--raw] Read one incident by number
30
31
 
31
32
  onepatch tools List the server's MCP tools
33
+ onepatch update Update the CLI to the latest version now
32
34
 
33
35
  Global flags:
34
36
  --api <url> Deployment base URL (default: $ONEPATCH_API_URL or https://app.onepatch.dev)
35
37
  --json Print raw MCP content blocks as JSON
38
+ --version Print the CLI version
36
39
  -h, --help Show this help
37
40
 
38
- Pass "-" for <sql> or <text> to read it from stdin.`;
41
+ Pass "-" for <sql> or <text> to read it from stdin.
42
+ The CLI keeps itself current in the background; ONEPATCH_NO_UPDATE=1 disables that.`;
39
43
 
40
44
  function fail(message: string): never {
41
45
  console.error(message);
@@ -128,15 +132,28 @@ async function main(): Promise<void> {
128
132
  "waiting-on": { type: "string" },
129
133
  limit: { type: "string" },
130
134
  raw: { type: "boolean", default: false },
135
+ version: { type: "boolean", default: false },
136
+ // Internal: `onepatch update --check` only refreshes the cached
137
+ // latest-version state; the background updater spawns it.
138
+ check: { type: "boolean", default: false },
131
139
  },
132
140
  });
133
141
 
142
+ if (flags.version) {
143
+ console.log(currentVersion());
144
+ return;
145
+ }
146
+
134
147
  const [noun, verb, ...rest] = positionals;
135
148
  if (flags.help || !noun) {
136
149
  console.log(USAGE);
137
150
  return;
138
151
  }
139
152
 
153
+ if (noun === "update") return await runUpdate({ checkOnly: flags.check });
154
+ // Every other command triggers the zero-cost background update pass.
155
+ maybeAutoUpdate();
156
+
140
157
  const api = resolveApiUrl(flags.api);
141
158
 
142
159
  if (noun === "login") return await login(api);
@@ -16,11 +16,21 @@ export type Credentials = {
16
16
 
17
17
  type CredentialsFile = Record<string, Credentials>;
18
18
 
19
- export function credentialsPath(): string {
20
- const dir =
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
- return join(dir, "credentials.json");
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
- const dir = join(path, "..");
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 });
@@ -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
+ }