onepatch 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/package.json +44 -0
- package/src/auth.ts +63 -0
- package/src/cli.ts +243 -0
- package/src/client.ts +154 -0
- package/src/config.ts +31 -0
- package/src/credentials.test.ts +49 -0
- package/src/credentials.ts +59 -0
- package/src/index.ts +10 -0
- package/src/workos.test.ts +87 -0
- package/src/workos.ts +150 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Damasqas, Corp.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# onepatch
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
Requires [Bun](https://bun.sh).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun install -g onepatch # or: npm install -g onepatch
|
|
9
|
+
onepatch login
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`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
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
onepatch otel query "SELECT count() FROM otel.logs WHERE ts > now() - INTERVAL 1 HOUR"
|
|
18
|
+
onepatch otel ingest-config
|
|
19
|
+
|
|
20
|
+
onepatch chats list
|
|
21
|
+
onepatch chats read <id> [--from-turn N] [--turns N] [--tool-io]
|
|
22
|
+
onepatch chats start "why is checkout slow?"
|
|
23
|
+
onepatch chats reply <id> "focus on the EU region"
|
|
24
|
+
|
|
25
|
+
onepatch incidents list [--scope open|all] [--severity P0|P1|P2|P3] [--limit N]
|
|
26
|
+
onepatch incidents read <num> [--raw]
|
|
27
|
+
|
|
28
|
+
onepatch tools # list the server's MCP tools
|
|
29
|
+
onepatch whoami
|
|
30
|
+
```
|
|
31
|
+
|
|
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
|
+
|
|
34
|
+
## Programmatic use
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { onepatch } from "onepatch";
|
|
38
|
+
|
|
39
|
+
const op = onepatch(); // credentials from `onepatch login`
|
|
40
|
+
const logs = await op.otel.query("SELECT count() FROM otel.logs");
|
|
41
|
+
const incidents = await op.incidents.list({ severity: "P1" });
|
|
42
|
+
const digest = await op.chats.start("investigate the p95 spike on /checkout");
|
|
43
|
+
await op.close();
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
All methods return the same plain-text digests the MCP tools produce. `op.call(name, args)` reaches any tool directly.
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "onepatch",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "OnePatch CLI: query your telemetry, chats, and incidents from a terminal or a coding agent",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://onepatch.dev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/1patch/cli.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/1patch/cli/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["onepatch", "observability", "sre", "cli", "mcp", "opentelemetry"],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"onepatch": "./src/cli.ts"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"files": ["src", "README.md", "LICENSE"],
|
|
23
|
+
"engines": {
|
|
24
|
+
"bun": ">=1.1.0"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
|
31
|
+
"test": "bun test",
|
|
32
|
+
"fmt": "biome check --write .",
|
|
33
|
+
"check": "biome ci . && bun run typecheck && bun test"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@biomejs/biome": "^2.5.8",
|
|
40
|
+
"@types/bun": "^1.3.14",
|
|
41
|
+
"@typescript/native-preview": "^7.0.0-dev.20260506.1",
|
|
42
|
+
"typescript": "^5.5.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Token lifecycle: read the stored credentials for an API base, refresh when
|
|
2
|
+
// the access token is close to expiry (they live 1 hour), and persist the
|
|
3
|
+
// rotated refresh token every time.
|
|
4
|
+
import { type Credentials, loadCredentials, saveCredentials } from "./credentials";
|
|
5
|
+
import { refreshTokens } from "./workos";
|
|
6
|
+
|
|
7
|
+
export class NotLoggedInError extends Error {
|
|
8
|
+
constructor(api: string) {
|
|
9
|
+
super(`Not logged in to ${api}. Run \`onepatch login\` first.`);
|
|
10
|
+
this.name = "NotLoggedInError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Payload-only JWT decode; no signature check. The CLI never trusts these
|
|
15
|
+
// claims for anything security-relevant — the server re-verifies every call —
|
|
16
|
+
// it only reads `exp` for proactive refresh and identity fields for display.
|
|
17
|
+
export function decodeJwtClaims(token: string): Record<string, unknown> {
|
|
18
|
+
const payload = token.split(".")[1];
|
|
19
|
+
if (!payload) return {};
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<
|
|
22
|
+
string,
|
|
23
|
+
unknown
|
|
24
|
+
>;
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function tokenExpiresSoon(token: string, skewSeconds = 60): boolean {
|
|
31
|
+
const exp = decodeJwtClaims(token).exp;
|
|
32
|
+
if (typeof exp !== "number") return true;
|
|
33
|
+
return exp * 1000 - Date.now() < skewSeconds * 1000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function refreshAndSave(api: string, creds: Credentials): Promise<Credentials> {
|
|
37
|
+
const tokens = await refreshTokens(creds.clientId, creds.refreshToken);
|
|
38
|
+
const next: Credentials = {
|
|
39
|
+
...creds,
|
|
40
|
+
accessToken: tokens.accessToken,
|
|
41
|
+
refreshToken: tokens.refreshToken,
|
|
42
|
+
orgId: tokens.organizationId ?? creds.orgId,
|
|
43
|
+
};
|
|
44
|
+
saveCredentials(api, next);
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function getValidAccessToken(api: string): Promise<string> {
|
|
49
|
+
let creds = loadCredentials(api);
|
|
50
|
+
if (!creds) throw new NotLoggedInError(api);
|
|
51
|
+
if (tokenExpiresSoon(creds.accessToken)) {
|
|
52
|
+
creds = await refreshAndSave(api, creds);
|
|
53
|
+
}
|
|
54
|
+
return creds.accessToken;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Unconditional refresh — the retry path after a server-side 401 on a token
|
|
58
|
+
// that still looked fresh locally (clock skew, revoked session rotated keys).
|
|
59
|
+
export async function forceRefreshAccessToken(api: string): Promise<string> {
|
|
60
|
+
const creds = loadCredentials(api);
|
|
61
|
+
if (!creds) throw new NotLoggedInError(api);
|
|
62
|
+
return (await refreshAndSave(api, creds)).accessToken;
|
|
63
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// The `onepatch` command. Thin noun-verb shell over OnepatchClient (src/client.ts);
|
|
3
|
+
// all product behavior lives server-side behind /mcp.
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { decodeJwtClaims, getValidAccessToken } from "./auth";
|
|
6
|
+
import { OnepatchClient } from "./client";
|
|
7
|
+
import { fetchBootstrap, resolveApiUrl } from "./config";
|
|
8
|
+
import { deleteCredentials, loadCredentials, saveCredentials } from "./credentials";
|
|
9
|
+
import { pollForDeviceToken, startDeviceAuthorization } from "./workos";
|
|
10
|
+
|
|
11
|
+
const USAGE = `onepatch — OnePatch from the terminal
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
onepatch login Sign in (device flow, opens a browser code)
|
|
15
|
+
onepatch logout Forget stored credentials for this API
|
|
16
|
+
onepatch whoami Show the signed-in identity
|
|
17
|
+
|
|
18
|
+
onepatch otel query <sql|-> Run SQL against your telemetry (otel.spans/logs/metrics/histograms)
|
|
19
|
+
onepatch otel ingest-config Ingest endpoint + token for sending telemetry
|
|
20
|
+
|
|
21
|
+
onepatch chats list List agent chats
|
|
22
|
+
onepatch chats read <id> Read a chat transcript digest
|
|
23
|
+
[--from-turn N] [--turns N] [--tool-io] [--block-chars N]
|
|
24
|
+
onepatch chats start <text|-> Start a new agent chat
|
|
25
|
+
onepatch chats reply <id> <text|-> Reply in an existing chat
|
|
26
|
+
|
|
27
|
+
onepatch incidents list List incidents
|
|
28
|
+
[--scope open|all] [--severity P0|P1|P2|P3] [--waiting-on X] [--limit N]
|
|
29
|
+
onepatch incidents read <num> [--raw] Read one incident by number
|
|
30
|
+
|
|
31
|
+
onepatch tools List the server's MCP tools
|
|
32
|
+
|
|
33
|
+
Global flags:
|
|
34
|
+
--api <url> Deployment base URL (default: $ONEPATCH_API_URL or https://app.onepatch.dev)
|
|
35
|
+
--json Print raw MCP content blocks as JSON
|
|
36
|
+
-h, --help Show this help
|
|
37
|
+
|
|
38
|
+
Pass "-" for <sql> or <text> to read it from stdin.`;
|
|
39
|
+
|
|
40
|
+
function fail(message: string): never {
|
|
41
|
+
console.error(message);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function resolveText(arg: string | undefined, what: string): Promise<string> {
|
|
46
|
+
if (arg === undefined) fail(`missing ${what}\n\n${USAGE}`);
|
|
47
|
+
if (arg !== "-") return arg;
|
|
48
|
+
const text = (await Bun.stdin.text()).trim();
|
|
49
|
+
if (!text) fail(`stdin was empty; pipe the ${what} in or pass it as an argument`);
|
|
50
|
+
return text;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function intFlag(value: string | undefined, name: string): number | undefined {
|
|
54
|
+
if (value === undefined) return undefined;
|
|
55
|
+
const n = Number.parseInt(value, 10);
|
|
56
|
+
if (!Number.isFinite(n)) fail(`--${name} must be an integer`);
|
|
57
|
+
return n;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function login(api: string): Promise<void> {
|
|
61
|
+
const { clientId } = await fetchBootstrap(api);
|
|
62
|
+
const device = await startDeviceAuthorization(clientId);
|
|
63
|
+
console.log(`To sign in, open:\n\n ${device.verification_uri_complete}\n`);
|
|
64
|
+
console.log(`or go to ${device.verification_uri} and enter code ${device.user_code}.\n`);
|
|
65
|
+
console.log("Waiting for approval…");
|
|
66
|
+
const tokens = await pollForDeviceToken(clientId, device);
|
|
67
|
+
const claims = decodeJwtClaims(tokens.accessToken);
|
|
68
|
+
const orgId =
|
|
69
|
+
tokens.organizationId ?? (typeof claims.org_id === "string" ? claims.org_id : undefined);
|
|
70
|
+
saveCredentials(api, {
|
|
71
|
+
clientId,
|
|
72
|
+
accessToken: tokens.accessToken,
|
|
73
|
+
refreshToken: tokens.refreshToken,
|
|
74
|
+
userId: tokens.user?.id,
|
|
75
|
+
email: tokens.user?.email,
|
|
76
|
+
orgId,
|
|
77
|
+
});
|
|
78
|
+
console.log(`\nLogged in to ${api} as ${tokens.user?.email ?? "unknown user"}.`);
|
|
79
|
+
if (orgId) {
|
|
80
|
+
console.log(`Organization: ${orgId}`);
|
|
81
|
+
} else {
|
|
82
|
+
console.log(
|
|
83
|
+
"Warning: this session is not scoped to an organization; API calls will be " +
|
|
84
|
+
"rejected. Sign in to the web app, make sure you belong to an organization, " +
|
|
85
|
+
"and run `onepatch login` again.",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function whoami(api: string, json: boolean): Promise<void> {
|
|
91
|
+
const stored = loadCredentials(api);
|
|
92
|
+
if (!stored) fail(`Not logged in to ${api}. Run \`onepatch login\` first.`);
|
|
93
|
+
// Refreshes if expired, so whoami doubles as a session health check.
|
|
94
|
+
const token = await getValidAccessToken(api);
|
|
95
|
+
const claims = decodeJwtClaims(token);
|
|
96
|
+
const info = {
|
|
97
|
+
api,
|
|
98
|
+
email: stored.email ?? null,
|
|
99
|
+
userId: typeof claims.sub === "string" ? claims.sub : (stored.userId ?? null),
|
|
100
|
+
orgId: typeof claims.org_id === "string" ? claims.org_id : (stored.orgId ?? null),
|
|
101
|
+
tokenExpires: typeof claims.exp === "number" ? new Date(claims.exp * 1000).toISOString() : null,
|
|
102
|
+
};
|
|
103
|
+
if (json) {
|
|
104
|
+
console.log(JSON.stringify(info, null, 2));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
console.log(`api: ${info.api}`);
|
|
108
|
+
console.log(`email: ${info.email ?? "unknown"}`);
|
|
109
|
+
console.log(`user: ${info.userId ?? "unknown"}`);
|
|
110
|
+
console.log(`org: ${info.orgId ?? "none"}`);
|
|
111
|
+
console.log(`expires: ${info.tokenExpires ?? "unknown"}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function main(): Promise<void> {
|
|
115
|
+
const { values: flags, positionals } = parseArgs({
|
|
116
|
+
args: process.argv.slice(2),
|
|
117
|
+
allowPositionals: true,
|
|
118
|
+
options: {
|
|
119
|
+
api: { type: "string" },
|
|
120
|
+
json: { type: "boolean", default: false },
|
|
121
|
+
help: { type: "boolean", short: "h", default: false },
|
|
122
|
+
"from-turn": { type: "string" },
|
|
123
|
+
turns: { type: "string" },
|
|
124
|
+
"tool-io": { type: "boolean", default: false },
|
|
125
|
+
"block-chars": { type: "string" },
|
|
126
|
+
scope: { type: "string" },
|
|
127
|
+
severity: { type: "string" },
|
|
128
|
+
"waiting-on": { type: "string" },
|
|
129
|
+
limit: { type: "string" },
|
|
130
|
+
raw: { type: "boolean", default: false },
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const [noun, verb, ...rest] = positionals;
|
|
135
|
+
if (flags.help || !noun) {
|
|
136
|
+
console.log(USAGE);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const api = resolveApiUrl(flags.api);
|
|
141
|
+
|
|
142
|
+
if (noun === "login") return await login(api);
|
|
143
|
+
if (noun === "logout") {
|
|
144
|
+
console.log(deleteCredentials(api) ? `Logged out of ${api}.` : `No credentials for ${api}.`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (noun === "whoami") return await whoami(api, flags.json);
|
|
148
|
+
|
|
149
|
+
const client = new OnepatchClient({ api });
|
|
150
|
+
const emit = async (name: string, args: Record<string, unknown>) => {
|
|
151
|
+
if (flags.json) {
|
|
152
|
+
console.log(JSON.stringify(await client.call(name, args), null, 2));
|
|
153
|
+
} else {
|
|
154
|
+
const blocks = await client.call(name, args);
|
|
155
|
+
console.log(
|
|
156
|
+
blocks
|
|
157
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
158
|
+
.map((b) => b.text)
|
|
159
|
+
.join("\n"),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
switch (noun) {
|
|
166
|
+
case "tools": {
|
|
167
|
+
const tools = await client.tools();
|
|
168
|
+
if (flags.json) {
|
|
169
|
+
console.log(JSON.stringify(tools, null, 2));
|
|
170
|
+
} else {
|
|
171
|
+
for (const t of tools) console.log(`${t.name}\n ${t.description.split("\n")[0]}`);
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
case "otel": {
|
|
176
|
+
if (verb === "query") {
|
|
177
|
+
return await emit("query_otel", { sql: await resolveText(rest[0], "SQL") });
|
|
178
|
+
}
|
|
179
|
+
if (verb === "ingest-config") return await emit("get_ingest_config", {});
|
|
180
|
+
fail(`unknown otel command: ${verb ?? "(none)"}\n\n${USAGE}`);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case "chats": {
|
|
184
|
+
if (verb === "list") return await emit("list_chats", {});
|
|
185
|
+
if (verb === "read") {
|
|
186
|
+
const chatId = rest[0] ?? fail(`missing chat id\n\n${USAGE}`);
|
|
187
|
+
return await emit("read_chat", {
|
|
188
|
+
chatId,
|
|
189
|
+
...(intFlag(flags["from-turn"], "from-turn") !== undefined
|
|
190
|
+
? { fromTurn: intFlag(flags["from-turn"], "from-turn") }
|
|
191
|
+
: {}),
|
|
192
|
+
...(intFlag(flags.turns, "turns") !== undefined
|
|
193
|
+
? { turnLimit: intFlag(flags.turns, "turns") }
|
|
194
|
+
: {}),
|
|
195
|
+
...(flags["tool-io"] ? { includeToolIo: true } : {}),
|
|
196
|
+
...(intFlag(flags["block-chars"], "block-chars") !== undefined
|
|
197
|
+
? { blockChars: intFlag(flags["block-chars"], "block-chars") }
|
|
198
|
+
: {}),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (verb === "start") {
|
|
202
|
+
return await emit("start_chat", { text: await resolveText(rest[0], "message") });
|
|
203
|
+
}
|
|
204
|
+
if (verb === "reply") {
|
|
205
|
+
const chatId = rest[0] ?? fail(`missing chat id\n\n${USAGE}`);
|
|
206
|
+
return await emit("send_chat_reply", {
|
|
207
|
+
chatId,
|
|
208
|
+
text: await resolveText(rest[1], "message"),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
fail(`unknown chats command: ${verb ?? "(none)"}\n\n${USAGE}`);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case "incidents": {
|
|
215
|
+
if (verb === "list") {
|
|
216
|
+
return await emit("list_incidents", {
|
|
217
|
+
...(flags.scope ? { scope: flags.scope } : {}),
|
|
218
|
+
...(flags.severity ? { severity: flags.severity } : {}),
|
|
219
|
+
...(flags["waiting-on"] ? { waiting_on: flags["waiting-on"] } : {}),
|
|
220
|
+
...(intFlag(flags.limit, "limit") !== undefined
|
|
221
|
+
? { limit: intFlag(flags.limit, "limit") }
|
|
222
|
+
: {}),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (verb === "read") {
|
|
226
|
+
const num = intFlag(rest[0], "incident number");
|
|
227
|
+
if (num === undefined) fail(`missing incident number\n\n${USAGE}`);
|
|
228
|
+
return await emit("read_incident", { num, ...(flags.raw ? { raw: true } : {}) });
|
|
229
|
+
}
|
|
230
|
+
fail(`unknown incidents command: ${verb ?? "(none)"}\n\n${USAGE}`);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
default:
|
|
234
|
+
fail(`unknown command: ${noun}\n\n${USAGE}`);
|
|
235
|
+
}
|
|
236
|
+
} finally {
|
|
237
|
+
await client.close();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
main().catch((err: unknown) => {
|
|
242
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
243
|
+
});
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The programmatic surface: a fluent, promise-returning client over the
|
|
2
|
+
// OnePatch MCP endpoint. Exact functional parity with the served MCP tools —
|
|
3
|
+
// the CLI is an MCP client, so no server behavior is duplicated here.
|
|
4
|
+
//
|
|
5
|
+
// const op = onepatch(); // creds from `onepatch login`
|
|
6
|
+
// await op.otel.query("SELECT count() FROM otel.logs");
|
|
7
|
+
// await op.incidents.list({ severity: "P1" });
|
|
8
|
+
// const { chatId } = await op.chats.start("why is checkout slow?");
|
|
9
|
+
// await op.chats.reply(chatId, "focus on the EU region");
|
|
10
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
11
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
12
|
+
import { forceRefreshAccessToken, getValidAccessToken } from "./auth";
|
|
13
|
+
import { resolveApiUrl } from "./config";
|
|
14
|
+
|
|
15
|
+
export type OnepatchClientOptions = {
|
|
16
|
+
// Base URL of the deployment, e.g. https://app.onepatch.dev. Defaults to
|
|
17
|
+
// ONEPATCH_API_URL, then the hosted production URL.
|
|
18
|
+
api?: string;
|
|
19
|
+
// Bypass the credential store with an explicit bearer token (headless use).
|
|
20
|
+
accessToken?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ReadChatOptions = {
|
|
24
|
+
fromTurn?: number;
|
|
25
|
+
turnLimit?: number;
|
|
26
|
+
includeToolIo?: boolean;
|
|
27
|
+
blockChars?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ListIncidentsOptions = {
|
|
31
|
+
scope?: "open" | "all";
|
|
32
|
+
severity?: "P0" | "P1" | "P2" | "P3";
|
|
33
|
+
waitingOn?: string;
|
|
34
|
+
limit?: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ToolContentBlock = { type: string; text?: string; [key: string]: unknown };
|
|
38
|
+
|
|
39
|
+
function textOf(blocks: ToolContentBlock[]): string {
|
|
40
|
+
return blocks
|
|
41
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
42
|
+
.map((b) => b.text as string)
|
|
43
|
+
.join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class OnepatchClient {
|
|
47
|
+
readonly api: string;
|
|
48
|
+
private readonly accessTokenOverride: string | null;
|
|
49
|
+
private clientPromise: Promise<Client> | null = null;
|
|
50
|
+
|
|
51
|
+
constructor(options: OnepatchClientOptions = {}) {
|
|
52
|
+
this.api = resolveApiUrl(options.api);
|
|
53
|
+
this.accessTokenOverride = options.accessToken ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
readonly otel = {
|
|
57
|
+
query: (sql: string) => this.callText("query_otel", { sql }),
|
|
58
|
+
ingestConfig: () => this.callText("get_ingest_config", {}),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
readonly chats = {
|
|
62
|
+
list: () => this.callText("list_chats", {}),
|
|
63
|
+
read: (chatId: string, opts: ReadChatOptions = {}) =>
|
|
64
|
+
this.callText("read_chat", { chatId, ...opts }),
|
|
65
|
+
// Returns the digest text, which leads with the new chat id.
|
|
66
|
+
start: (text: string) => this.callText("start_chat", { text }),
|
|
67
|
+
reply: (chatId: string, text: string) => this.callText("send_chat_reply", { chatId, text }),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
readonly incidents = {
|
|
71
|
+
list: (opts: ListIncidentsOptions = {}) =>
|
|
72
|
+
this.callText("list_incidents", {
|
|
73
|
+
...(opts.scope ? { scope: opts.scope } : {}),
|
|
74
|
+
...(opts.severity ? { severity: opts.severity } : {}),
|
|
75
|
+
...(opts.waitingOn ? { waiting_on: opts.waitingOn } : {}),
|
|
76
|
+
...(opts.limit !== undefined ? { limit: opts.limit } : {}),
|
|
77
|
+
}),
|
|
78
|
+
read: (num: number, opts: { raw?: boolean } = {}) =>
|
|
79
|
+
this.callText("read_incident", { num, ...opts }),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
async tools(): Promise<{ name: string; description: string }[]> {
|
|
83
|
+
const client = await this.connect();
|
|
84
|
+
const res = await client.listTools();
|
|
85
|
+
return res.tools.map((t) => ({ name: t.name, description: t.description ?? "" }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Raw tool call — returns the MCP content blocks (for --json and new tools
|
|
89
|
+
// this client has no sugar for yet). Throws on tool-level errors.
|
|
90
|
+
async call(name: string, args: Record<string, unknown>): Promise<ToolContentBlock[]> {
|
|
91
|
+
const blocks = await this.callWithAuthRetry(name, args);
|
|
92
|
+
return blocks;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async close(): Promise<void> {
|
|
96
|
+
if (!this.clientPromise) return;
|
|
97
|
+
const client = await this.clientPromise.catch(() => null);
|
|
98
|
+
this.clientPromise = null;
|
|
99
|
+
await client?.close().catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async callText(name: string, args: Record<string, unknown>): Promise<string> {
|
|
103
|
+
return textOf(await this.callWithAuthRetry(name, args));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async callWithAuthRetry(
|
|
107
|
+
name: string,
|
|
108
|
+
args: Record<string, unknown>,
|
|
109
|
+
): Promise<ToolContentBlock[]> {
|
|
110
|
+
try {
|
|
111
|
+
return await this.callOnce(name, args);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// A 401 mid-session means the token aged out server-side; refresh once
|
|
114
|
+
// and retry on a fresh connection. Explicit-token clients can't refresh.
|
|
115
|
+
if (this.accessTokenOverride || !/\b401\b/.test(String(err))) throw err;
|
|
116
|
+
this.clientPromise = null;
|
|
117
|
+
await forceRefreshAccessToken(this.api);
|
|
118
|
+
return await this.callOnce(name, args);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async callOnce(name: string, args: Record<string, unknown>): Promise<ToolContentBlock[]> {
|
|
123
|
+
const client = await this.connect();
|
|
124
|
+
const result = await client.callTool({ name, arguments: args });
|
|
125
|
+
const blocks = (result.content ?? []) as ToolContentBlock[];
|
|
126
|
+
if (result.isError) {
|
|
127
|
+
throw new Error(textOf(blocks) || `tool ${name} failed`);
|
|
128
|
+
}
|
|
129
|
+
return blocks;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private connect(): Promise<Client> {
|
|
133
|
+
if (!this.clientPromise) {
|
|
134
|
+
this.clientPromise = (async () => {
|
|
135
|
+
const token = this.accessTokenOverride ?? (await getValidAccessToken(this.api));
|
|
136
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${this.api}/mcp`), {
|
|
137
|
+
requestInit: { headers: { authorization: `Bearer ${token}` } },
|
|
138
|
+
});
|
|
139
|
+
const client = new Client({ name: "onepatch-cli", version: "0.1.0" });
|
|
140
|
+
await client.connect(transport);
|
|
141
|
+
return client;
|
|
142
|
+
})().catch((err) => {
|
|
143
|
+
// Don't cache a failed connection — the next call retries fresh.
|
|
144
|
+
this.clientPromise = null;
|
|
145
|
+
throw err;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return this.clientPromise;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function onepatch(options: OnepatchClientOptions = {}): OnepatchClient {
|
|
153
|
+
return new OnepatchClient(options);
|
|
154
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Which OnePatch deployment the CLI talks to. Everything else — the WorkOS
|
|
2
|
+
// client id for login, the MCP endpoint — is discovered from this one URL via
|
|
3
|
+
// `GET /.well-known/onepatch-cli`, so the CLI ships with zero baked-in config.
|
|
4
|
+
export const DEFAULT_API_URL = "https://app.onepatch.dev";
|
|
5
|
+
|
|
6
|
+
export function resolveApiUrl(flag?: string): string {
|
|
7
|
+
const raw = flag ?? process.env.ONEPATCH_API_URL ?? DEFAULT_API_URL;
|
|
8
|
+
return raw.replace(/\/+$/, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type CliBootstrap = { clientId: string; mcpUrl: string };
|
|
12
|
+
|
|
13
|
+
// Fetch the server's CLI bootstrap document. The client id is public by
|
|
14
|
+
// design (it already ships in the web app's browser bundle).
|
|
15
|
+
export async function fetchBootstrap(
|
|
16
|
+
api: string,
|
|
17
|
+
fetchImpl: typeof fetch = fetch,
|
|
18
|
+
): Promise<CliBootstrap> {
|
|
19
|
+
const res = await fetchImpl(`${api}/.well-known/onepatch-cli`);
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`${api} did not answer the CLI bootstrap probe (${res.status}). ` +
|
|
23
|
+
"Check the URL, or pass --api <url> if this deployment lives elsewhere.",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const doc = (await res.json()) as { client_id?: unknown; mcp_url?: unknown };
|
|
27
|
+
if (typeof doc.client_id !== "string" || typeof doc.mcp_url !== "string") {
|
|
28
|
+
throw new Error(`${api} returned a malformed CLI bootstrap document.`);
|
|
29
|
+
}
|
|
30
|
+
return { clientId: doc.client_id, mcpUrl: doc.mcp_url };
|
|
31
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// On-disk credential store: one JSON file keyed by API base URL, so a login
|
|
2
|
+
// against staging and one against prod coexist. The file holds real bearer
|
|
3
|
+
// material (access + refresh tokens), so it is written 0600 inside a 0700 dir.
|
|
4
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
export type Credentials = {
|
|
9
|
+
clientId: string;
|
|
10
|
+
accessToken: string;
|
|
11
|
+
refreshToken: string;
|
|
12
|
+
userId?: string;
|
|
13
|
+
email?: string;
|
|
14
|
+
orgId?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type CredentialsFile = Record<string, Credentials>;
|
|
18
|
+
|
|
19
|
+
export function credentialsPath(): string {
|
|
20
|
+
const dir =
|
|
21
|
+
process.env.ONEPATCH_CONFIG_DIR ??
|
|
22
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "onepatch");
|
|
23
|
+
return join(dir, "credentials.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readFile(): CredentialsFile {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(credentialsPath(), "utf8")) as CredentialsFile;
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadCredentials(api: string): Credentials | null {
|
|
35
|
+
return readFile()[api] ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function saveCredentials(api: string, creds: Credentials): void {
|
|
39
|
+
const path = credentialsPath();
|
|
40
|
+
const dir = join(path, "..");
|
|
41
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
42
|
+
const all = readFile();
|
|
43
|
+
all[api] = creds;
|
|
44
|
+
writeFileSync(path, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
|
45
|
+
// mkdir/write modes only apply on creation; converge pre-existing files too.
|
|
46
|
+
chmodSync(path, 0o600);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function deleteCredentials(api: string): boolean {
|
|
50
|
+
const all = readFile();
|
|
51
|
+
if (!(api in all)) return false;
|
|
52
|
+
delete all[api];
|
|
53
|
+
if (Object.keys(all).length === 0) {
|
|
54
|
+
rmSync(credentialsPath(), { force: true });
|
|
55
|
+
} else {
|
|
56
|
+
writeFileSync(credentialsPath(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Programmatic entry point: import { onepatch } from "onepatch";
|
|
2
|
+
export {
|
|
3
|
+
type ListIncidentsOptions,
|
|
4
|
+
OnepatchClient,
|
|
5
|
+
type OnepatchClientOptions,
|
|
6
|
+
onepatch,
|
|
7
|
+
type ReadChatOptions,
|
|
8
|
+
type ToolContentBlock,
|
|
9
|
+
} from "./client";
|
|
10
|
+
export { DEFAULT_API_URL, resolveApiUrl } from "./config";
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
});
|
package/src/workos.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// WorkOS user-management device-authorization grant (RFC 8628), spoken as a
|
|
2
|
+
// public client: client_id only, no secret anywhere. The server accepts the
|
|
3
|
+
// resulting access token on /mcp via its first-party issuer branch.
|
|
4
|
+
//
|
|
5
|
+
// Empirically verified against WorkOS staging 2026-08-31:
|
|
6
|
+
// - POST /user_management/authorize/device is form-encoded, client_id only.
|
|
7
|
+
// - Polling POST /user_management/authenticate answers `authorization_pending`
|
|
8
|
+
// until approval, `slow_down` to widen the interval, and
|
|
9
|
+
// `expired_token` / `access_denied` / `invalid_grant` terminally.
|
|
10
|
+
// - Tokens refresh with grant_type=refresh_token + client_id (rotating
|
|
11
|
+
// refresh token: every refresh answer carries a NEW one — always persist it).
|
|
12
|
+
const WORKOS_API = "https://api.workos.com";
|
|
13
|
+
|
|
14
|
+
export type DeviceAuthorization = {
|
|
15
|
+
device_code: string;
|
|
16
|
+
user_code: string;
|
|
17
|
+
verification_uri: string;
|
|
18
|
+
verification_uri_complete: string;
|
|
19
|
+
expires_in: number;
|
|
20
|
+
interval: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AuthUser = { id: string; email: string };
|
|
24
|
+
|
|
25
|
+
export type AuthTokens = {
|
|
26
|
+
accessToken: string;
|
|
27
|
+
refreshToken: string;
|
|
28
|
+
user: AuthUser | null;
|
|
29
|
+
organizationId: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type FetchLike = typeof fetch;
|
|
33
|
+
|
|
34
|
+
async function postForm(
|
|
35
|
+
path: string,
|
|
36
|
+
params: Record<string, string>,
|
|
37
|
+
fetchImpl: FetchLike,
|
|
38
|
+
): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
39
|
+
const res = await fetchImpl(`${WORKOS_API}${path}`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
42
|
+
body: new URLSearchParams(params).toString(),
|
|
43
|
+
});
|
|
44
|
+
let body: Record<string, unknown> = {};
|
|
45
|
+
try {
|
|
46
|
+
body = (await res.json()) as Record<string, unknown>;
|
|
47
|
+
} catch {
|
|
48
|
+
// Non-JSON error bodies fall through with an empty object.
|
|
49
|
+
}
|
|
50
|
+
return { status: res.status, body };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseTokens(body: Record<string, unknown>): AuthTokens {
|
|
54
|
+
const accessToken = body.access_token;
|
|
55
|
+
const refreshToken = body.refresh_token;
|
|
56
|
+
if (typeof accessToken !== "string" || typeof refreshToken !== "string") {
|
|
57
|
+
throw new Error("WorkOS returned a token response without access/refresh tokens.");
|
|
58
|
+
}
|
|
59
|
+
const rawUser = body.user as { id?: unknown; email?: unknown } | undefined;
|
|
60
|
+
const user =
|
|
61
|
+
rawUser && typeof rawUser.id === "string" && typeof rawUser.email === "string"
|
|
62
|
+
? { id: rawUser.id, email: rawUser.email }
|
|
63
|
+
: null;
|
|
64
|
+
return {
|
|
65
|
+
accessToken,
|
|
66
|
+
refreshToken,
|
|
67
|
+
user,
|
|
68
|
+
organizationId: typeof body.organization_id === "string" ? body.organization_id : null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function startDeviceAuthorization(
|
|
73
|
+
clientId: string,
|
|
74
|
+
fetchImpl: FetchLike = fetch,
|
|
75
|
+
): Promise<DeviceAuthorization> {
|
|
76
|
+
const { status, body } = await postForm(
|
|
77
|
+
"/user_management/authorize/device",
|
|
78
|
+
{ client_id: clientId },
|
|
79
|
+
fetchImpl,
|
|
80
|
+
);
|
|
81
|
+
if (status !== 200 || typeof body.device_code !== "string") {
|
|
82
|
+
throw new Error(`WorkOS refused device authorization (${status}): ${JSON.stringify(body)}`);
|
|
83
|
+
}
|
|
84
|
+
return body as unknown as DeviceAuthorization;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type PollOptions = {
|
|
88
|
+
fetchImpl?: FetchLike;
|
|
89
|
+
sleep?: (ms: number) => Promise<void>;
|
|
90
|
+
onPending?: () => void;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export async function pollForDeviceToken(
|
|
94
|
+
clientId: string,
|
|
95
|
+
device: DeviceAuthorization,
|
|
96
|
+
opts: PollOptions = {},
|
|
97
|
+
): Promise<AuthTokens> {
|
|
98
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
99
|
+
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
100
|
+
let intervalSeconds = device.interval > 0 ? device.interval : 5;
|
|
101
|
+
const deadline = Date.now() + device.expires_in * 1000;
|
|
102
|
+
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
const { status, body } = await postForm(
|
|
105
|
+
"/user_management/authenticate",
|
|
106
|
+
{
|
|
107
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
108
|
+
device_code: device.device_code,
|
|
109
|
+
client_id: clientId,
|
|
110
|
+
},
|
|
111
|
+
fetchImpl,
|
|
112
|
+
);
|
|
113
|
+
if (status === 200) return parseTokens(body);
|
|
114
|
+
const error = typeof body.error === "string" ? body.error : "unknown_error";
|
|
115
|
+
switch (error) {
|
|
116
|
+
case "authorization_pending":
|
|
117
|
+
opts.onPending?.();
|
|
118
|
+
break;
|
|
119
|
+
case "slow_down":
|
|
120
|
+
intervalSeconds += 5;
|
|
121
|
+
break;
|
|
122
|
+
case "access_denied":
|
|
123
|
+
throw new Error("Sign-in was denied in the browser.");
|
|
124
|
+
case "expired_token":
|
|
125
|
+
case "invalid_grant":
|
|
126
|
+
throw new Error("The sign-in request expired. Run `onepatch login` again.");
|
|
127
|
+
default:
|
|
128
|
+
throw new Error(`WorkOS authentication failed (${status}): ${error}`);
|
|
129
|
+
}
|
|
130
|
+
await sleep(intervalSeconds * 1000);
|
|
131
|
+
}
|
|
132
|
+
throw new Error("The sign-in request expired. Run `onepatch login` again.");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function refreshTokens(
|
|
136
|
+
clientId: string,
|
|
137
|
+
refreshToken: string,
|
|
138
|
+
fetchImpl: FetchLike = fetch,
|
|
139
|
+
): Promise<AuthTokens> {
|
|
140
|
+
const { status, body } = await postForm(
|
|
141
|
+
"/user_management/authenticate",
|
|
142
|
+
{ grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId },
|
|
143
|
+
fetchImpl,
|
|
144
|
+
);
|
|
145
|
+
if (status !== 200) {
|
|
146
|
+
const error = typeof body.error === "string" ? body.error : `http_${status}`;
|
|
147
|
+
throw new Error(`Session expired (${error}). Run \`onepatch login\` again.`);
|
|
148
|
+
}
|
|
149
|
+
return parseTokens(body);
|
|
150
|
+
}
|