offrouter-cli 0.2.1 → 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/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +59 -3
- package/dist/commands/login.js.map +1 -1
- package/package.json +13 -8
- package/src/catalog-cache.test.ts +0 -258
- package/src/catalog-cache.ts +0 -169
- package/src/catalog-sample.json +0 -30
- package/src/cli.test.ts +0 -1705
- package/src/commands/config-error.ts +0 -31
- package/src/commands/detection.ts +0 -209
- package/src/commands/doctor.ts +0 -555
- package/src/commands/hooks.ts +0 -125
- package/src/commands/init.ts +0 -59
- package/src/commands/install.ts +0 -438
- package/src/commands/login.test.ts +0 -247
- package/src/commands/login.ts +0 -413
- package/src/commands/mcp.ts +0 -35
- package/src/commands/models.ts +0 -168
- package/src/commands/output.ts +0 -11
- package/src/commands/profiles.ts +0 -58
- package/src/commands/proxy.ts +0 -87
- package/src/commands/route.ts +0 -190
- package/src/commands/status.ts +0 -139
- package/src/commands/update.ts +0 -62
- package/src/index.ts +0 -174
- package/tsconfig.json +0 -17
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the `offrouter login` command.
|
|
3
|
-
*
|
|
4
|
-
* Mocks `offrouter-core` functions so the login command runs without real OAuth.
|
|
5
|
-
* FileSecretStore is NOT mocked; it runs against a real temp directory.
|
|
6
|
-
*/
|
|
7
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
-
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
// Mock offrouter-core for login tests
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
const mocks = vi.hoisted(() => ({
|
|
16
|
-
mockRunOAuthLogin: vi.fn(),
|
|
17
|
-
mockGetOAuthTokens: vi.fn(),
|
|
18
|
-
mockIsOAuthTokenExpired: vi.fn(),
|
|
19
|
-
mockRefreshTokens: vi.fn(),
|
|
20
|
-
mockResolveClientId: vi.fn(),
|
|
21
|
-
mockResolveOffRouterHome: vi.fn(),
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
// Keep FileSecretStore real but mock the core functions.
|
|
25
|
-
vi.mock("offrouter-core", async () => {
|
|
26
|
-
const actual = await vi.importActual<typeof import("offrouter-core")>("offrouter-core");
|
|
27
|
-
return {
|
|
28
|
-
...actual,
|
|
29
|
-
runOAuthLogin: mocks.mockRunOAuthLogin,
|
|
30
|
-
getOAuthTokens: mocks.mockGetOAuthTokens,
|
|
31
|
-
isOAuthTokenExpired: mocks.mockIsOAuthTokenExpired,
|
|
32
|
-
refreshTokens: mocks.mockRefreshTokens,
|
|
33
|
-
resolveClientId: mocks.mockResolveClientId,
|
|
34
|
-
resolveOffRouterHome: mocks.mockResolveOffRouterHome,
|
|
35
|
-
};
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
import { createProgram } from "../index.js";
|
|
39
|
-
|
|
40
|
-
function capture(
|
|
41
|
-
argv: string[],
|
|
42
|
-
env: NodeJS.ProcessEnv = { ...process.env },
|
|
43
|
-
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
44
|
-
return new Promise((resolve) => {
|
|
45
|
-
let stdout = "";
|
|
46
|
-
let stderr = "";
|
|
47
|
-
const program = createProgram({
|
|
48
|
-
env,
|
|
49
|
-
stdout: {
|
|
50
|
-
write(chunk: string) {
|
|
51
|
-
stdout += chunk;
|
|
52
|
-
return true;
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
stderr: {
|
|
56
|
-
write(chunk: string) {
|
|
57
|
-
stderr += chunk;
|
|
58
|
-
return true;
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
program
|
|
64
|
-
.parseAsync(argv, { from: "user" })
|
|
65
|
-
.then(() => {
|
|
66
|
-
resolve({ code: 0, stdout, stderr });
|
|
67
|
-
})
|
|
68
|
-
.catch((err) => {
|
|
69
|
-
const code = err?.exitCode ?? 1;
|
|
70
|
-
resolve({ code, stdout, stderr });
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
describe("offrouter login command", () => {
|
|
76
|
-
let tmpHome: string;
|
|
77
|
-
|
|
78
|
-
beforeEach(() => {
|
|
79
|
-
vi.resetAllMocks();
|
|
80
|
-
tmpHome = mkdtempSync(join(tmpdir(), "offrouter-login-test-"));
|
|
81
|
-
mocks.mockResolveOffRouterHome.mockReturnValue(tmpHome);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
afterEach(() => {
|
|
85
|
-
if (tmpHome && existsSync(tmpHome)) {
|
|
86
|
-
rmSync(tmpHome, { recursive: true, force: true });
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("help", () => {
|
|
91
|
-
it("offrouter login --help shows login command", async () => {
|
|
92
|
-
const { code, stdout, stderr } = await capture(["login", "--help"]);
|
|
93
|
-
expect(code).toBe(0);
|
|
94
|
-
const output = stdout + stderr;
|
|
95
|
-
expect(output).toMatch(/login/i);
|
|
96
|
-
expect(output).toMatch(/provider/i);
|
|
97
|
-
expect(output).toMatch(/anthropic/i);
|
|
98
|
-
expect(output).toMatch(/openai/i);
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
describe("login <provider>", () => {
|
|
103
|
-
it("calls runOAuthLogin with the provider and stores tokens", async () => {
|
|
104
|
-
mocks.mockResolveClientId.mockReturnValue("test-client-id");
|
|
105
|
-
mocks.mockRunOAuthLogin.mockResolvedValue({
|
|
106
|
-
provider: "anthropic",
|
|
107
|
-
redirectUri: "http://127.0.0.1:1456/callback",
|
|
108
|
-
expiresAt: Date.now() + 3600_000,
|
|
109
|
-
scope: "api offline_access",
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
const { code, stdout, stderr } = await capture(
|
|
113
|
-
["login", "anthropic"],
|
|
114
|
-
{
|
|
115
|
-
...process.env,
|
|
116
|
-
OFFROUTER_HOME: tmpHome,
|
|
117
|
-
},
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
expect(code).toBe(0);
|
|
121
|
-
expect(stderr).toBe("");
|
|
122
|
-
|
|
123
|
-
expect(mocks.mockRunOAuthLogin).toHaveBeenCalledWith(
|
|
124
|
-
expect.objectContaining({
|
|
125
|
-
provider: "anthropic",
|
|
126
|
-
clientId: "test-client-id",
|
|
127
|
-
}),
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
expect(stdout).toMatch(/successful|Login/);
|
|
131
|
-
expect(stdout).toContain("anthropic");
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("prints error for unsupported provider", async () => {
|
|
135
|
-
const { code, stdout } = await capture(["login", "gemini"]);
|
|
136
|
-
expect(code).toBe(0);
|
|
137
|
-
expect(stdout).toMatch(/not supported/i);
|
|
138
|
-
expect(mocks.mockRunOAuthLogin).not.toHaveBeenCalled();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("prints error when no client id is configured", async () => {
|
|
142
|
-
const { code, stdout } = await capture(
|
|
143
|
-
["login", "anthropic"],
|
|
144
|
-
{ ...process.env, OFFROUTER_HOME: tmpHome },
|
|
145
|
-
);
|
|
146
|
-
expect(code).toBe(0);
|
|
147
|
-
expect(stdout).toMatch(/client id required|client.*id/i);
|
|
148
|
-
expect(mocks.mockRunOAuthLogin).not.toHaveBeenCalled();
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
describe("login --list", () => {
|
|
153
|
-
it("shows providers with no tokens when none stored", async () => {
|
|
154
|
-
mocks.mockGetOAuthTokens.mockResolvedValue(undefined);
|
|
155
|
-
|
|
156
|
-
const { code, stdout } = await capture(
|
|
157
|
-
["login", "--list"],
|
|
158
|
-
{ ...process.env, OFFROUTER_HOME: tmpHome },
|
|
159
|
-
);
|
|
160
|
-
expect(code).toBe(0);
|
|
161
|
-
expect(stdout).toMatch(/OAuth providers/i);
|
|
162
|
-
expect(stdout).toContain("anthropic");
|
|
163
|
-
expect(stdout).toContain("openai");
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("shows active token status", async () => {
|
|
167
|
-
mocks.mockGetOAuthTokens.mockResolvedValue({
|
|
168
|
-
accessToken: "tok",
|
|
169
|
-
refreshToken: "refresh",
|
|
170
|
-
expiresAt: Date.now() + 3600_000,
|
|
171
|
-
obtainedAt: Date.now(),
|
|
172
|
-
});
|
|
173
|
-
mocks.mockIsOAuthTokenExpired.mockReturnValue(false);
|
|
174
|
-
|
|
175
|
-
const { code, stdout } = await capture(
|
|
176
|
-
["login", "--list"],
|
|
177
|
-
{ ...process.env, OFFROUTER_HOME: tmpHome },
|
|
178
|
-
);
|
|
179
|
-
expect(code).toBe(0);
|
|
180
|
-
expect(stdout).toContain("active");
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("shows expired token status", async () => {
|
|
184
|
-
mocks.mockGetOAuthTokens.mockResolvedValue({
|
|
185
|
-
accessToken: "tok",
|
|
186
|
-
refreshToken: "refresh",
|
|
187
|
-
expiresAt: Date.now() - 1000,
|
|
188
|
-
obtainedAt: Date.now() - 7200_000,
|
|
189
|
-
});
|
|
190
|
-
mocks.mockIsOAuthTokenExpired.mockReturnValue(true);
|
|
191
|
-
|
|
192
|
-
const { code, stdout } = await capture(
|
|
193
|
-
["login", "--list"],
|
|
194
|
-
{ ...process.env, OFFROUTER_HOME: tmpHome },
|
|
195
|
-
);
|
|
196
|
-
expect(code).toBe(0);
|
|
197
|
-
expect(stdout).toContain("expired");
|
|
198
|
-
});
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
describe("login --status <provider>", () => {
|
|
202
|
-
it("shows status for unsupported provider", async () => {
|
|
203
|
-
const { code, stdout } = await capture(
|
|
204
|
-
["login", "--status", "gemini"],
|
|
205
|
-
);
|
|
206
|
-
expect(code).toBe(0);
|
|
207
|
-
expect(stdout).toMatch(/not supported/i);
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
describe("login --json", () => {
|
|
212
|
-
it("returns JSON for successful login", async () => {
|
|
213
|
-
mocks.mockResolveClientId.mockReturnValue("test-oa-id");
|
|
214
|
-
mocks.mockRunOAuthLogin.mockResolvedValue({
|
|
215
|
-
provider: "openai",
|
|
216
|
-
redirectUri: "http://127.0.0.1:1455/callback",
|
|
217
|
-
expiresAt: 9999999999000,
|
|
218
|
-
scope: "offline_access",
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
const { code, stdout } = await capture(
|
|
222
|
-
["login", "openai", "--json"],
|
|
223
|
-
{
|
|
224
|
-
...process.env,
|
|
225
|
-
OFFROUTER_HOME: tmpHome,
|
|
226
|
-
},
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
expect(code).toBe(0);
|
|
230
|
-
const json = JSON.parse(stdout) as Record<string, unknown>;
|
|
231
|
-
expect(json.ok).toBe(true);
|
|
232
|
-
expect(json.provider).toBe("openai");
|
|
233
|
-
expect(json.redirectUri).toContain("127.0.0.1");
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it("returns JSON error for unsupported provider", async () => {
|
|
237
|
-
const { code, stdout } = await capture(
|
|
238
|
-
["login", "gemini", "--json"],
|
|
239
|
-
{ ...process.env, OFFROUTER_HOME: tmpHome },
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
expect(code).toBe(0);
|
|
243
|
-
const json = JSON.parse(stdout) as Record<string, unknown>;
|
|
244
|
-
expect(json.error).toMatch(/not supported/i);
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
});
|
package/src/commands/login.ts
DELETED
|
@@ -1,413 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `offrouter login` command: real OAuth PKCE login with browser open.
|
|
3
|
-
*
|
|
4
|
-
* Supports anthropic and openai (bring-your-own-client-id, documented as fragile).
|
|
5
|
-
* Tokens are stored in the OffRouter secret store (keychain or file fallback).
|
|
6
|
-
* Never scrapes or copies credentials from other CLIs.
|
|
7
|
-
*/
|
|
8
|
-
import type { Command } from "commander";
|
|
9
|
-
import { execSync } from "node:child_process";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import {
|
|
12
|
-
FileSecretStore,
|
|
13
|
-
PKCE_CONFIGS,
|
|
14
|
-
getOAuthTokens,
|
|
15
|
-
isOAuthProvider,
|
|
16
|
-
isOAuthTokenExpired,
|
|
17
|
-
refreshTokens,
|
|
18
|
-
resolveClientId,
|
|
19
|
-
resolveOffRouterHome,
|
|
20
|
-
runOAuthLogin,
|
|
21
|
-
} from "offrouter-core";
|
|
22
|
-
import type { OAuthStoredTokens } from "offrouter-core";
|
|
23
|
-
import { writeJson, writeText } from "./output.js";
|
|
24
|
-
import type { CommandContext } from "./init.js";
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Browser open (macOS, best-effort)
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
function openBrowser(url: string): boolean {
|
|
31
|
-
try {
|
|
32
|
-
execSync(`open "${url}"`, { timeout: 5000, stdio: "ignore" });
|
|
33
|
-
return true;
|
|
34
|
-
} catch {
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
// Secret store helpers
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
|
|
43
|
-
function createStore(env: NodeJS.ProcessEnv): FileSecretStore {
|
|
44
|
-
const home = resolveOffRouterHome({ env });
|
|
45
|
-
return new FileSecretStore({ filePath: join(home, "secrets.json") });
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
// Provider display helpers
|
|
50
|
-
// ---------------------------------------------------------------------------
|
|
51
|
-
|
|
52
|
-
interface OAuthProviderInfo {
|
|
53
|
-
id: string;
|
|
54
|
-
clientId: string | null;
|
|
55
|
-
tokensStored: boolean;
|
|
56
|
-
hasRefreshToken: boolean;
|
|
57
|
-
expired: boolean | null;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function listOAuthProviders(
|
|
61
|
-
env: NodeJS.ProcessEnv,
|
|
62
|
-
): Promise<OAuthProviderInfo[]> {
|
|
63
|
-
const providers = Object.keys(PKCE_CONFIGS);
|
|
64
|
-
const store = createStore(env);
|
|
65
|
-
const results: OAuthProviderInfo[] = [];
|
|
66
|
-
|
|
67
|
-
for (const id of providers) {
|
|
68
|
-
const clientId = resolveClientId(id, env) ?? null;
|
|
69
|
-
let tokens: OAuthStoredTokens | undefined;
|
|
70
|
-
try {
|
|
71
|
-
tokens = await getOAuthTokens(id, store);
|
|
72
|
-
} catch {
|
|
73
|
-
tokens = undefined;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
results.push({
|
|
77
|
-
id,
|
|
78
|
-
clientId,
|
|
79
|
-
tokensStored: !!tokens,
|
|
80
|
-
hasRefreshToken: !!tokens?.refreshToken,
|
|
81
|
-
expired: tokens ? isOAuthTokenExpired(tokens) : null,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return results;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function statusForProvider(
|
|
89
|
-
providerId: string,
|
|
90
|
-
env: NodeJS.ProcessEnv,
|
|
91
|
-
): Promise<{
|
|
92
|
-
provider: string;
|
|
93
|
-
valid: boolean;
|
|
94
|
-
expired: boolean;
|
|
95
|
-
hasRefreshToken: boolean;
|
|
96
|
-
refreshed: boolean;
|
|
97
|
-
error?: string;
|
|
98
|
-
}> {
|
|
99
|
-
const store = createStore(env);
|
|
100
|
-
const tokens = await getOAuthTokens(providerId, store);
|
|
101
|
-
|
|
102
|
-
if (!tokens) {
|
|
103
|
-
return {
|
|
104
|
-
provider: providerId,
|
|
105
|
-
valid: false,
|
|
106
|
-
expired: false,
|
|
107
|
-
hasRefreshToken: false,
|
|
108
|
-
refreshed: false,
|
|
109
|
-
error: "No OAuth tokens stored. Run `offrouter login <provider>` first.",
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const expired = isOAuthTokenExpired(tokens);
|
|
114
|
-
|
|
115
|
-
if (!expired) {
|
|
116
|
-
return {
|
|
117
|
-
provider: providerId,
|
|
118
|
-
valid: true,
|
|
119
|
-
expired: false,
|
|
120
|
-
hasRefreshToken: !!tokens.refreshToken,
|
|
121
|
-
refreshed: false,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Expired: try refresh if we have a refresh token.
|
|
126
|
-
if (!tokens.refreshToken) {
|
|
127
|
-
return {
|
|
128
|
-
provider: providerId,
|
|
129
|
-
valid: false,
|
|
130
|
-
expired: true,
|
|
131
|
-
hasRefreshToken: false,
|
|
132
|
-
refreshed: false,
|
|
133
|
-
error:
|
|
134
|
-
"Token is expired and no refresh token is available. Run `offrouter login` again.",
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
await refreshTokens(providerId, tokens.refreshToken, store);
|
|
140
|
-
return {
|
|
141
|
-
provider: providerId,
|
|
142
|
-
valid: true,
|
|
143
|
-
expired: false,
|
|
144
|
-
hasRefreshToken: true,
|
|
145
|
-
refreshed: true,
|
|
146
|
-
};
|
|
147
|
-
} catch (err) {
|
|
148
|
-
return {
|
|
149
|
-
provider: providerId,
|
|
150
|
-
valid: false,
|
|
151
|
-
expired: true,
|
|
152
|
-
hasRefreshToken: true,
|
|
153
|
-
refreshed: false,
|
|
154
|
-
error: err instanceof Error ? err.message : "Token refresh failed",
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ---------------------------------------------------------------------------
|
|
160
|
-
// Command registration
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
|
-
|
|
163
|
-
export function registerLoginCommand(
|
|
164
|
-
program: Command,
|
|
165
|
-
ctx: () => CommandContext,
|
|
166
|
-
): void {
|
|
167
|
-
const cmd = program
|
|
168
|
-
.command("login")
|
|
169
|
-
.description(
|
|
170
|
-
"Authenticate with an OAuth provider (Anthropic or OpenAI). Tokens stored in the OffRouter secret store.",
|
|
171
|
-
)
|
|
172
|
-
.argument("[provider]", "Provider id (anthropic, openai)")
|
|
173
|
-
.option("--list", "List providers with stored tokens")
|
|
174
|
-
.option("--status <provider>", "Check token status for a provider")
|
|
175
|
-
.option("--json", "Emit JSON", false)
|
|
176
|
-
.option("--timeout <ms>", "OAuth callback timeout in milliseconds")
|
|
177
|
-
.option(
|
|
178
|
-
"--client-id <id>",
|
|
179
|
-
"Override client ID (default from OFFROUTER_*_CLIENT_ID env)",
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
cmd.action(
|
|
183
|
-
async (
|
|
184
|
-
provider: string | undefined,
|
|
185
|
-
opts: {
|
|
186
|
-
json?: boolean;
|
|
187
|
-
list?: boolean;
|
|
188
|
-
status?: string;
|
|
189
|
-
timeout?: string;
|
|
190
|
-
clientId?: string;
|
|
191
|
-
},
|
|
192
|
-
) => {
|
|
193
|
-
const { env, stdout } = ctx();
|
|
194
|
-
|
|
195
|
-
// --list
|
|
196
|
-
if (opts.list) {
|
|
197
|
-
await handleList(stdout, env, !!opts.json);
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// --status <provider>
|
|
202
|
-
if (opts.status) {
|
|
203
|
-
await handleStatus(stdout, env, opts.status, !!opts.json);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// login <provider> (full flow)
|
|
208
|
-
if (!provider) {
|
|
209
|
-
if (opts.json) {
|
|
210
|
-
writeJson(stdout, {
|
|
211
|
-
error: "Missing provider argument. Run `offrouter login --help` for usage.",
|
|
212
|
-
});
|
|
213
|
-
} else {
|
|
214
|
-
writeText(
|
|
215
|
-
stdout,
|
|
216
|
-
"Missing provider. Usage: offrouter login <provider> [options]",
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
await handleLogin(stdout, env, provider, opts);
|
|
223
|
-
},
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// ---------------------------------------------------------------------------
|
|
228
|
-
// Handlers
|
|
229
|
-
// ---------------------------------------------------------------------------
|
|
230
|
-
|
|
231
|
-
async function handleList(
|
|
232
|
-
stdout: { write(chunk: string): boolean | void },
|
|
233
|
-
env: NodeJS.ProcessEnv,
|
|
234
|
-
json: boolean,
|
|
235
|
-
): Promise<void> {
|
|
236
|
-
const providers = await listOAuthProviders(env);
|
|
237
|
-
|
|
238
|
-
if (json) {
|
|
239
|
-
writeJson(stdout, { providers });
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (providers.length === 0) {
|
|
244
|
-
writeText(stdout, "No OAuth providers configured.");
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
writeText(stdout, "OAuth providers:");
|
|
249
|
-
for (const p of providers) {
|
|
250
|
-
const tokenStatus = p.tokensStored
|
|
251
|
-
? p.expired
|
|
252
|
-
? "expired"
|
|
253
|
-
: "active"
|
|
254
|
-
: "no tokens";
|
|
255
|
-
const refreshStatus = p.hasRefreshToken ? " (refresh token)" : "";
|
|
256
|
-
const clientStatus = p.clientId ? `client-id: ${p.clientId}` : "no client-id configured";
|
|
257
|
-
writeText(stdout, ` ${p.id}: ${tokenStatus}${refreshStatus}, ${clientStatus}`);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
async function handleStatus(
|
|
262
|
-
stdout: { write(chunk: string): boolean | void },
|
|
263
|
-
env: NodeJS.ProcessEnv,
|
|
264
|
-
providerId: string,
|
|
265
|
-
json: boolean,
|
|
266
|
-
): Promise<void> {
|
|
267
|
-
if (!isOAuthProvider(providerId)) {
|
|
268
|
-
if (json) {
|
|
269
|
-
writeJson(stdout, {
|
|
270
|
-
error: `OAuth not supported for provider: ${providerId}`,
|
|
271
|
-
});
|
|
272
|
-
} else {
|
|
273
|
-
writeText(
|
|
274
|
-
stdout,
|
|
275
|
-
`OAuth not supported for provider "${providerId}". Supported providers: ${Object.keys(PKCE_CONFIGS).join(", ")}`,
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
const status = await statusForProvider(providerId, env);
|
|
283
|
-
|
|
284
|
-
if (json) {
|
|
285
|
-
writeJson(stdout, status);
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (status.error) {
|
|
290
|
-
writeText(stdout, ` ${providerId}: ${status.error}`);
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
if (status.refreshed) {
|
|
295
|
-
writeText(stdout, ` ${providerId}: token was expired, refreshed successfully`);
|
|
296
|
-
} else {
|
|
297
|
-
writeText(stdout, ` ${providerId}: token valid`);
|
|
298
|
-
}
|
|
299
|
-
} catch (err) {
|
|
300
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
301
|
-
if (json) {
|
|
302
|
-
writeJson(stdout, { error: msg, provider: providerId });
|
|
303
|
-
} else {
|
|
304
|
-
writeText(stdout, ` ${providerId}: error checking status: ${msg}`);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
async function handleLogin(
|
|
310
|
-
stdout: { write(chunk: string): boolean | void },
|
|
311
|
-
env: NodeJS.ProcessEnv,
|
|
312
|
-
provider: string,
|
|
313
|
-
opts: {
|
|
314
|
-
json?: boolean;
|
|
315
|
-
timeout?: string;
|
|
316
|
-
clientId?: string;
|
|
317
|
-
},
|
|
318
|
-
): Promise<void> {
|
|
319
|
-
if (!isOAuthProvider(provider)) {
|
|
320
|
-
if (opts.json) {
|
|
321
|
-
writeJson(stdout, {
|
|
322
|
-
error: `OAuth not supported for provider: ${provider}`,
|
|
323
|
-
});
|
|
324
|
-
} else {
|
|
325
|
-
writeText(
|
|
326
|
-
stdout,
|
|
327
|
-
`OAuth not supported for "${provider}". Use one of: ${Object.keys(PKCE_CONFIGS).join(", ")}`,
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const config = PKCE_CONFIGS[provider];
|
|
334
|
-
if (!config) return; // unreachable
|
|
335
|
-
|
|
336
|
-
// Resolve client ID
|
|
337
|
-
const clientId = opts.clientId ?? resolveClientId(provider, env);
|
|
338
|
-
|
|
339
|
-
if (!clientId) {
|
|
340
|
-
const hint = config.clientIdEnvVar
|
|
341
|
-
? `Set the ${config.clientIdEnvVar} environment variable.`
|
|
342
|
-
: "A client ID is required. Register an OAuth app with the provider.";
|
|
343
|
-
if (opts.json) {
|
|
344
|
-
writeJson(stdout, {
|
|
345
|
-
error: `Client ID required for ${provider} OAuth. ${hint}`,
|
|
346
|
-
});
|
|
347
|
-
} else {
|
|
348
|
-
writeText(stdout, `Error: Client ID required for ${provider} OAuth.`);
|
|
349
|
-
writeText(stdout, hint);
|
|
350
|
-
}
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const store = createStore(env);
|
|
355
|
-
const timeoutMs = opts.timeout ? parseInt(opts.timeout, 10) : 5 * 60 * 1000;
|
|
356
|
-
|
|
357
|
-
try {
|
|
358
|
-
const result = await runOAuthLogin({
|
|
359
|
-
provider,
|
|
360
|
-
store,
|
|
361
|
-
clientId,
|
|
362
|
-
timeoutMs,
|
|
363
|
-
openBrowser,
|
|
364
|
-
onAuthorizeUrl(url) {
|
|
365
|
-
writeText(stdout, "");
|
|
366
|
-
writeText(stdout, `Opening browser to authorize OffRouter for ${provider}...`);
|
|
367
|
-
writeText(stdout, "");
|
|
368
|
-
writeText(stdout, `If the browser does not open, visit:`);
|
|
369
|
-
writeText(stdout, ` ${url}`);
|
|
370
|
-
writeText(stdout, "");
|
|
371
|
-
|
|
372
|
-
const opened = openBrowser(url);
|
|
373
|
-
if (!opened) {
|
|
374
|
-
writeText(stdout, "(Could not open browser automatically. Copy and paste the URL above.)");
|
|
375
|
-
}
|
|
376
|
-
writeText(stdout, "Waiting for authorization...");
|
|
377
|
-
},
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
if (opts.json) {
|
|
381
|
-
writeJson(stdout, {
|
|
382
|
-
provider: result.provider,
|
|
383
|
-
ok: true,
|
|
384
|
-
redirectUri: result.redirectUri,
|
|
385
|
-
expiresAt: result.expiresAt,
|
|
386
|
-
scope: result.scope,
|
|
387
|
-
});
|
|
388
|
-
} else {
|
|
389
|
-
writeText(stdout, "");
|
|
390
|
-
writeText(stdout, `✓ Login successful for ${provider}.`);
|
|
391
|
-
writeText(stdout, ` Tokens stored in OffRouter secret store.`);
|
|
392
|
-
if (result.expiresAt) {
|
|
393
|
-
const expiresIn = Math.round((result.expiresAt - Date.now()) / 1000 / 60);
|
|
394
|
-
writeText(stdout, ` Token expires in ${expiresIn} minutes.`);
|
|
395
|
-
}
|
|
396
|
-
writeText(stdout, "");
|
|
397
|
-
}
|
|
398
|
-
} catch (err) {
|
|
399
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
400
|
-
|
|
401
|
-
if (opts.json) {
|
|
402
|
-
writeJson(stdout, {
|
|
403
|
-
error: msg,
|
|
404
|
-
provider,
|
|
405
|
-
ok: false,
|
|
406
|
-
});
|
|
407
|
-
} else {
|
|
408
|
-
writeText(stdout, "");
|
|
409
|
-
writeText(stdout, `✗ Login failed for ${provider}: ${msg}`);
|
|
410
|
-
writeText(stdout, "");
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
package/src/commands/mcp.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import type { Command } from "commander";
|
|
2
|
-
import { runMcpCli } from "offrouter-mcp";
|
|
3
|
-
import type { CommandContext } from "./init.js";
|
|
4
|
-
|
|
5
|
-
export function registerMcpCommand(
|
|
6
|
-
program: Command,
|
|
7
|
-
ctx: () => CommandContext,
|
|
8
|
-
): void {
|
|
9
|
-
const mcp = program
|
|
10
|
-
.command("mcp")
|
|
11
|
-
.description("OffRouter MCP stdio server bridges");
|
|
12
|
-
|
|
13
|
-
mcp
|
|
14
|
-
.command("serve")
|
|
15
|
-
.description(
|
|
16
|
-
"Start the OffRouter MCP stdio server (route/delegate/status tools)",
|
|
17
|
-
)
|
|
18
|
-
.action(async (_opts: unknown, command: Command) => {
|
|
19
|
-
const { env, cwd, stdout, stderr } = ctx();
|
|
20
|
-
// Forward only unknown/passthrough args after `serve`; Commander already
|
|
21
|
-
// handled --help before invoking this action.
|
|
22
|
-
const forwarded = command.args ?? [];
|
|
23
|
-
const code = await runMcpCli(forwarded, {
|
|
24
|
-
env,
|
|
25
|
-
cwd,
|
|
26
|
-
stdout,
|
|
27
|
-
stderr,
|
|
28
|
-
});
|
|
29
|
-
if (code !== 0) {
|
|
30
|
-
const err = new Error(`MCP server exited with code ${code}`);
|
|
31
|
-
(err as { exitCode?: number }).exitCode = code;
|
|
32
|
-
throw err;
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
}
|