@infimind/video-mcp-cli 1.0.11 → 1.0.13
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 +83 -28
- package/dist/auth/credentialWriter.js +85 -0
- package/dist/auth/exchange.js +67 -0
- package/dist/auth/installationId.js +36 -0
- package/dist/auth/login.js +77 -0
- package/dist/auth/logout.js +34 -0
- package/dist/auth/loopback.js +155 -0
- package/dist/auth/paths.js +30 -0
- package/dist/auth/pkce.js +11 -0
- package/dist/auth/profile.js +15 -0
- package/dist/auth/redact.js +63 -0
- package/dist/auth/status.js +35 -0
- package/dist/creativeTools.js +585 -0
- package/dist/index.js +139 -39
- package/dist/server.js +900 -211
- package/dist/videoRules.js +102 -20
- package/package.json +35 -8
- package/dist/index.d.ts +0 -2
- package/dist/server.d.ts +0 -11
- package/dist/videoRules.d.ts +0 -49
package/README.md
CHANGED
|
@@ -78,6 +78,7 @@ After saving the configuration, restart Claude Desktop for the changes to take e
|
|
|
78
78
|
Get information about the authenticated user.
|
|
79
79
|
|
|
80
80
|
**Example usage in Claude:**
|
|
81
|
+
|
|
81
82
|
```
|
|
82
83
|
Get my user information for the video generator
|
|
83
84
|
```
|
|
@@ -86,28 +87,32 @@ Get my user information for the video generator
|
|
|
86
87
|
|
|
87
88
|
Create a video generation task.
|
|
88
89
|
|
|
89
|
-
Supported
|
|
90
|
+
Supported models (pass `model`; `route` is a deprecated legacy alias):
|
|
91
|
+
|
|
92
|
+
- `WA`: 极睿WA专业版 (default; available to non-VIP users)
|
|
93
|
+
- `SD`: 极睿SD专业版 (VIP only)
|
|
94
|
+
- `SD25`: 极睿SD2.5专业版 (VIP only)
|
|
95
|
+
- `HA`: 极睿HA专业版 (VIP only)
|
|
96
|
+
- `H3`: 极睿H3专业版 (VIP only)
|
|
90
97
|
|
|
91
|
-
|
|
92
|
-
- `S5`: 极睿Sd专业版 (VIP only)
|
|
93
|
-
- `S6`: 极睿Ha专业版 (VIP only)
|
|
98
|
+
`SA` (Sora 2 / route `S2`/`S10`) is retired. Requests that still send it are rejected with `MODEL_RETIRED`; use `WA` instead.
|
|
94
99
|
|
|
95
100
|
Parameter applicability:
|
|
96
101
|
|
|
97
|
-
| Parameter
|
|
98
|
-
|
|
|
99
|
-
| `sourceImage`
|
|
100
|
-
| `sourceImages`
|
|
101
|
-
| `sourceVideos`
|
|
102
|
-
| `sourceAudios`
|
|
103
|
-
| `sourceVideoDurationSeconds` |
|
|
104
|
-
| `sourceAudioDurationSeconds` |
|
|
105
|
-
| `taskMode`
|
|
106
|
-
| `resolution`
|
|
107
|
-
| `ratio`
|
|
108
|
-
| `generateAudio`
|
|
109
|
-
| `webSearchEnabled`
|
|
110
|
-
| `dryRun`
|
|
102
|
+
| Parameter | WA 极睿WA专业版 | SD 极睿SD专业版 | HA 极睿HA专业版 |
|
|
103
|
+
| ---------------------------- | ---------------------------------- | --------------------------- | ---------------------------------------- |
|
|
104
|
+
| `sourceImage` | Not supported | Not supported | Standard mode single image |
|
|
105
|
+
| `sourceImages` | allinone / firstlast / edit images | allinone / firstlast images | reference / edit reference images |
|
|
106
|
+
| `sourceVideos` | allinone / edit reference videos | allinone reference videos | edit input videos |
|
|
107
|
+
| `sourceAudios` | allinone / edit reference audio | allinone reference audio | Not supported |
|
|
108
|
+
| `sourceVideoDurationSeconds` | dryRun estimate for videos | dryRun estimate for videos | dryRun estimate for edit video |
|
|
109
|
+
| `sourceAudioDurationSeconds` | dryRun estimate for audio | dryRun estimate for audio | Not supported |
|
|
110
|
+
| `taskMode` | `allinone` / `firstlast` / `edit` | `allinone` / `firstlast` | `standard` / `reference` / `edit` |
|
|
111
|
+
| `resolution` | `480p` / `720p` / `1080p` | `720p` / `1080p` | `720p` / `1080p` |
|
|
112
|
+
| `ratio` | `adaptive` / `16:9` / `9:16` / ... | Not supported | reference or text-to-video standard mode |
|
|
113
|
+
| `generateAudio` | Optional | Optional | Not supported |
|
|
114
|
+
| `webSearchEnabled` | Not supported | Optional | Not supported |
|
|
115
|
+
| `dryRun` | Optional preflight | Optional preflight | Optional preflight |
|
|
111
116
|
|
|
112
117
|
Local file paths and public URLs are both supported.
|
|
113
118
|
|
|
@@ -117,10 +122,12 @@ Media limits:
|
|
|
117
122
|
|
|
118
123
|
- Local video files: MP4/MOV, up to 50MB each, 2-15 seconds.
|
|
119
124
|
- Local audio files: MP3/WAV, up to 15MB each, 2-15 seconds.
|
|
120
|
-
-
|
|
121
|
-
-
|
|
122
|
-
-
|
|
123
|
-
-
|
|
125
|
+
- WA: up to 10 images, 5 videos, and 5 audio files; fixed 5/10/15/20/25/30-second output (Edit uses smart duration only).
|
|
126
|
+
- SD allinone: up to 9 images, 3 videos, and 3 audio files.
|
|
127
|
+
- SD firstlast: up to 2 images; the first frame is required.
|
|
128
|
+
- SD25 allinone/edit: up to 30 images, 10 videos, and 10 audio files.
|
|
129
|
+
- HA reference: up to 9 images.
|
|
130
|
+
- HA edit: up to 1 video and 5 images.
|
|
124
131
|
|
|
125
132
|
URL safety rules:
|
|
126
133
|
|
|
@@ -146,17 +153,20 @@ Use `dryRun: true` to validate parameters, VIP access, media rules, and estimate
|
|
|
146
153
|
}
|
|
147
154
|
```
|
|
148
155
|
|
|
149
|
-
#### 极睿
|
|
156
|
+
#### 极睿WA专业版 - 全能参考(默认模型)
|
|
150
157
|
|
|
151
|
-
|
|
158
|
+
WA is the default model when `model` is omitted. Do not pass `sourceImage` or `webSearchEnabled`.
|
|
152
159
|
|
|
153
160
|
```json
|
|
154
161
|
{
|
|
155
162
|
"prompt": "一个高质感的产品展示视频,柔和布光,镜头缓慢推进",
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
163
|
+
"model": "WA",
|
|
164
|
+
"taskMode": "allinone",
|
|
165
|
+
"sourceImages": ["/path/to/product.jpg"],
|
|
166
|
+
"seconds": 5,
|
|
159
167
|
"size": "720x1280",
|
|
168
|
+
"ratio": "9:16",
|
|
169
|
+
"resolution": "720p",
|
|
160
170
|
"language": "zh-CN",
|
|
161
171
|
"count": 1,
|
|
162
172
|
"dryRun": true
|
|
@@ -264,6 +274,49 @@ Image-to-video:
|
|
|
264
274
|
|
|
265
275
|
After dry run succeeds and the estimated credits look correct, remove `dryRun` or set it to `false` to create the real task. After creating a task, use `get_video_status` with the returned `taskId` to query progress and video links.
|
|
266
276
|
|
|
277
|
+
### `create_motion_video_remake_task` / `get_motion_video_remake_status`
|
|
278
|
+
|
|
279
|
+
Create and poll a motion video remake task using a character image and an MP4 motion reference. Local files and public URLs are supported.
|
|
280
|
+
|
|
281
|
+
```json
|
|
282
|
+
{
|
|
283
|
+
"sourceImage": "/path/to/person.jpg",
|
|
284
|
+
"referenceVideo": "/path/to/motion.mp4",
|
|
285
|
+
"mode": "move",
|
|
286
|
+
"quality": "std",
|
|
287
|
+
"count": 1,
|
|
288
|
+
"referenceVideoDurationSeconds": 12,
|
|
289
|
+
"dryRun": true
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
For `dryRun`, `referenceVideoDurationSeconds` is an optional estimate hint. If it is omitted, media validation still runs but credit fields are returned as `null`; the hint is never treated as detected media metadata. During formal creation, the backend ignores the hint, uploads the MP4 to OSS, reads the authoritative duration and dimensions through OSS `video/info`, and bills from the unrounded duration. Images are limited to 5 MB; MP4 references are limited to 100 MB and 30 seconds. After preflight succeeds, set `dryRun` to `false`, then poll the returned `taskId` with `get_motion_video_remake_status`.
|
|
294
|
+
|
|
295
|
+
### `create_ecommerce_storyboard_task` / `get_ecommerce_storyboard_status`
|
|
296
|
+
|
|
297
|
+
Create and poll an ecommerce storyboard with a complete story, per-scene descriptions, video prompts, and sketch images.
|
|
298
|
+
|
|
299
|
+
```json
|
|
300
|
+
{
|
|
301
|
+
"productName": "Smart Watch",
|
|
302
|
+
"productDescription": "Lightweight watch with long battery life and fitness tracking",
|
|
303
|
+
"targetAudience": "Young professionals",
|
|
304
|
+
"videoStyle": "Energetic product demo",
|
|
305
|
+
"productImage": "/path/to/product.png",
|
|
306
|
+
"targetDuration": 60,
|
|
307
|
+
"language": "en",
|
|
308
|
+
"orientation": "vertical",
|
|
309
|
+
"dryRun": true
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Storyboard preflight estimates a fixed cost of 2 credits. Set `dryRun` to `false` to create the task, then call `get_ecommerce_storyboard_status`. Storyboard scenes are not automatically submitted as final video tasks; pass a selected scene prompt and image to `create_video_task` when needed.
|
|
314
|
+
|
|
315
|
+
### Backend deployment requirements for 1.1.0
|
|
316
|
+
|
|
317
|
+
- Apply `drizzle/migrations/0070_add_creative_billing_state.sql` after `0069_add_output_upscale_attempt.sql` and before deploying the application and motion poller. The additive columns default historical creative rows to already charged.
|
|
318
|
+
- Verify that the configured OSS bucket supports `video/info` and that the application identity can read media metadata before enabling the new tools. The application can be rolled back without dropping the additive columns.
|
|
319
|
+
|
|
267
320
|
## Development
|
|
268
321
|
|
|
269
322
|
```bash
|
|
@@ -303,7 +356,9 @@ Set `SORA_API_URL=http://localhost:3000/api` when testing against a local app se
|
|
|
303
356
|
"mcpServers": {
|
|
304
357
|
"sora-video-generator-local": {
|
|
305
358
|
"command": "node",
|
|
306
|
-
"args": [
|
|
359
|
+
"args": [
|
|
360
|
+
"/absolute/path/to/sora_video_generator/mcp-server/dist/index.js"
|
|
361
|
+
],
|
|
307
362
|
"env": {
|
|
308
363
|
"SORA_MCP_TOKEN": "YOUR_TOKEN",
|
|
309
364
|
"SORA_API_URL": "http://localhost:3000/api"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic credential file writer.
|
|
3
|
+
*
|
|
4
|
+
* Writes to a temp file in the same directory, then renames over the
|
|
5
|
+
* target. Sets directory mode 0700 and file mode 0600 after the rename.
|
|
6
|
+
*/
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, openSync, closeSync, } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { credentialsDir, credentialsFilePath } from "./paths.js";
|
|
10
|
+
function assertPermissions() {
|
|
11
|
+
const dir = credentialsDir();
|
|
12
|
+
if (!existsSync(dir)) {
|
|
13
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
chmodSync(dir, 0o700);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// best-effort
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const st = statSync(dir);
|
|
23
|
+
if (process.platform !== "win32") {
|
|
24
|
+
const mode = st.mode & 0o777;
|
|
25
|
+
if (mode & 0o077) {
|
|
26
|
+
throw new Error(`credentials directory is world/group accessible (mode=${mode.toString(8)})`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err instanceof Error &&
|
|
32
|
+
err.message.startsWith("credentials directory")) {
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function writeCredentials(payload) {
|
|
38
|
+
assertPermissions();
|
|
39
|
+
const target = credentialsFilePath();
|
|
40
|
+
const tmp = join(dirname(target), `.${Date.now()}.${process.pid}.tmp`);
|
|
41
|
+
const json = JSON.stringify(payload, null, 2);
|
|
42
|
+
const fd = openSync(tmp, "wx", 0o600);
|
|
43
|
+
try {
|
|
44
|
+
writeFileSync(fd, json, { encoding: "utf8" });
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
renameSync(tmp, target);
|
|
50
|
+
try {
|
|
51
|
+
chmodSync(target, 0o600);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// best-effort
|
|
55
|
+
}
|
|
56
|
+
return target;
|
|
57
|
+
}
|
|
58
|
+
export function readCredentials() {
|
|
59
|
+
const target = credentialsFilePath();
|
|
60
|
+
if (!existsSync(target))
|
|
61
|
+
return null;
|
|
62
|
+
try {
|
|
63
|
+
const st = statSync(target);
|
|
64
|
+
if (process.platform !== "win32") {
|
|
65
|
+
const mode = st.mode & 0o777;
|
|
66
|
+
if (mode & 0o077) {
|
|
67
|
+
throw new Error(`credentials file is world/group accessible (mode=${mode.toString(8)})`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const text = readFileSync(target, "utf8");
|
|
71
|
+
return JSON.parse(text);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err instanceof SyntaxError)
|
|
75
|
+
return null;
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function deleteCredentials() {
|
|
80
|
+
const target = credentialsFilePath();
|
|
81
|
+
if (!existsSync(target))
|
|
82
|
+
return false;
|
|
83
|
+
unlinkSync(target);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token exchange helpers. The CLI never logs the raw token, code, or
|
|
3
|
+
* verifier; pass them through the redacting writers in `redact.ts`.
|
|
4
|
+
*/
|
|
5
|
+
const DEFAULT_API_URL = "https://aigc-next.iclip.cn/api";
|
|
6
|
+
export async function exchangeAuthorizationCode(input) {
|
|
7
|
+
const url = new URL("/api/oauth/workbuddy/token", input.apiBaseUrl).toString();
|
|
8
|
+
const body = {
|
|
9
|
+
grant_type: "authorization_code",
|
|
10
|
+
code: input.code,
|
|
11
|
+
redirect_uri: input.redirectUri,
|
|
12
|
+
code_verifier: input.codeVerifier,
|
|
13
|
+
installation_id: input.installationId,
|
|
14
|
+
client_id: "infimind-video-workbuddy-cli",
|
|
15
|
+
};
|
|
16
|
+
let resp;
|
|
17
|
+
try {
|
|
18
|
+
resp = await fetch(url, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: {
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
Accept: "application/json",
|
|
23
|
+
},
|
|
24
|
+
body: JSON.stringify(body),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
return { ok: false, reason: "network_error" };
|
|
29
|
+
}
|
|
30
|
+
if (!resp.ok) {
|
|
31
|
+
return { ok: false, reason: `http_${resp.status}` };
|
|
32
|
+
}
|
|
33
|
+
let json;
|
|
34
|
+
try {
|
|
35
|
+
json = (await resp.json());
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return { ok: false, reason: "invalid_response" };
|
|
39
|
+
}
|
|
40
|
+
if (!json.access_token || !json.token_type) {
|
|
41
|
+
return { ok: false, reason: "invalid_response" };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, token: json };
|
|
44
|
+
}
|
|
45
|
+
export async function revokeWorkbuddyToken(input) {
|
|
46
|
+
const url = new URL("/api/oauth/workbuddy/revoke", input.apiBaseUrl).toString();
|
|
47
|
+
try {
|
|
48
|
+
const resp = await fetch(url, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
Accept: "application/json",
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
token: input.accessToken,
|
|
56
|
+
installation_id: input.installationId,
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
return { ok: resp.ok, status: resp.status };
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
return { ok: false, status: 0, reason: "network_error" };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function defaultApiUrl() {
|
|
66
|
+
return process.env.SORA_API_URL || DEFAULT_API_URL;
|
|
67
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable per-installation identifier. Generated once and persisted under
|
|
3
|
+
* ~/.workbuddy/connectors/infimind-video/auth/installation_id
|
|
4
|
+
* so the same machine always produces the same id. We do not transmit the
|
|
5
|
+
* hostname to the server; only the hash of this id is sent.
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { installationIdFilePath } from "./paths.js";
|
|
11
|
+
const INSTALLATION_ID_LENGTH = 32; // 32 bytes base64url = 43 chars
|
|
12
|
+
export function readOrCreateInstallationId() {
|
|
13
|
+
const path = installationIdFilePath();
|
|
14
|
+
if (existsSync(path)) {
|
|
15
|
+
try {
|
|
16
|
+
const value = readFileSync(path, "utf8").trim();
|
|
17
|
+
if (/^[A-Za-z0-9_-]{16,128}$/.test(value)) {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// fall through to regeneration
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const generated = randomBytes(INSTALLATION_ID_LENGTH).toString("base64url");
|
|
26
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
27
|
+
writeFileSync(path, generated, { encoding: "utf8", mode: 0o600 });
|
|
28
|
+
try {
|
|
29
|
+
chmodSync(dirname(path), 0o700);
|
|
30
|
+
chmodSync(path, 0o600);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// best-effort; CI / Windows may not support chmod
|
|
34
|
+
}
|
|
35
|
+
return generated;
|
|
36
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sora-mcp auth login` implementation.
|
|
3
|
+
*
|
|
4
|
+
* Sequence:
|
|
5
|
+
* 1. Read or generate the installationId (persisted on disk).
|
|
6
|
+
* 2. Bind 127.0.0.1 to a random port and prepare state + PKCE challenge.
|
|
7
|
+
* 3. Print the HTTPS authorization URL within 10 s of invocation.
|
|
8
|
+
* 4. Wait for the loopback callback or 5-minute timeout.
|
|
9
|
+
* 5. Exchange the code with PKCE verifier + installation id.
|
|
10
|
+
* 6. Persist the credential atomically.
|
|
11
|
+
*/
|
|
12
|
+
import { bindLoopback } from "./loopback.js";
|
|
13
|
+
import { readOrCreateInstallationId } from "./installationId.js";
|
|
14
|
+
import { exchangeAuthorizationCode, defaultApiUrl } from "./exchange.js";
|
|
15
|
+
import { writeCredentials } from "./credentialWriter.js";
|
|
16
|
+
const TIMEOUT_MS = 5 * 60 * 1000;
|
|
17
|
+
const PRINT_URL_DEADLINE_MS = 10_000;
|
|
18
|
+
const CLI_VERSION = "1.0.13";
|
|
19
|
+
function detectPlatform() {
|
|
20
|
+
return process.platform;
|
|
21
|
+
}
|
|
22
|
+
export async function runLogin() {
|
|
23
|
+
const installationId = readOrCreateInstallationId();
|
|
24
|
+
const apiBaseUrl = defaultApiUrl();
|
|
25
|
+
const platform = detectPlatform();
|
|
26
|
+
const binding = await bindLoopback();
|
|
27
|
+
const params = new URLSearchParams({
|
|
28
|
+
response_type: "code",
|
|
29
|
+
client_id: "infimind-video-workbuddy-cli",
|
|
30
|
+
redirect_uri: binding.url,
|
|
31
|
+
state: binding.state,
|
|
32
|
+
code_challenge: binding.codeChallenge,
|
|
33
|
+
code_challenge_method: "S256",
|
|
34
|
+
installation_id: installationId,
|
|
35
|
+
platform,
|
|
36
|
+
cli_version: CLI_VERSION,
|
|
37
|
+
});
|
|
38
|
+
const authUrl = `${apiBaseUrl.replace(/\/+$/, "")}/oauth/workbuddy/authorize?${params.toString()}`;
|
|
39
|
+
// Print URL within 10s.
|
|
40
|
+
const printTimer = setTimeout(() => {
|
|
41
|
+
process.stdout.write(`\n[workbuddy-auth] login URL not printed within 10s; binding kept\n`);
|
|
42
|
+
}, PRINT_URL_DEADLINE_MS);
|
|
43
|
+
process.stdout.write(`\n[workbuddy-auth] open this URL in your browser:\n${authUrl}\n`);
|
|
44
|
+
clearTimeout(printTimer);
|
|
45
|
+
let callback;
|
|
46
|
+
try {
|
|
47
|
+
callback = await binding.awaitCallback(TIMEOUT_MS);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
await binding.close();
|
|
51
|
+
return { ok: false, reason: err.message || "callback_failed" };
|
|
52
|
+
}
|
|
53
|
+
await binding.close();
|
|
54
|
+
const exchanged = await exchangeAuthorizationCode({
|
|
55
|
+
apiBaseUrl,
|
|
56
|
+
redirectUri: binding.url,
|
|
57
|
+
code: callback.code,
|
|
58
|
+
codeVerifier: binding.codeVerifier,
|
|
59
|
+
installationId,
|
|
60
|
+
platform,
|
|
61
|
+
cliVersion: CLI_VERSION,
|
|
62
|
+
});
|
|
63
|
+
if (!exchanged.ok || !exchanged.token) {
|
|
64
|
+
return { ok: false, reason: exchanged.reason ?? "exchange_failed" };
|
|
65
|
+
}
|
|
66
|
+
const issuedAt = new Date().toISOString();
|
|
67
|
+
writeCredentials({
|
|
68
|
+
installationId,
|
|
69
|
+
accessToken: exchanged.token.access_token,
|
|
70
|
+
tokenType: "Bearer",
|
|
71
|
+
scope: exchanged.token.scope,
|
|
72
|
+
issuedAt,
|
|
73
|
+
platform,
|
|
74
|
+
cliVersion: CLI_VERSION,
|
|
75
|
+
});
|
|
76
|
+
return { ok: true, authUrl };
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sora-mcp auth logout` implementation.
|
|
3
|
+
*
|
|
4
|
+
* Order:
|
|
5
|
+
* 1. Read the credential file.
|
|
6
|
+
* 2. Call /api/oauth/workbuddy/revoke.
|
|
7
|
+
* 3. Only delete the local file if the revoke succeeded.
|
|
8
|
+
* 4. If the network is unreachable, keep the local file and exit non-zero
|
|
9
|
+
* so the user can retry revocation.
|
|
10
|
+
*/
|
|
11
|
+
import { readCredentials, deleteCredentials } from "./credentialWriter.js";
|
|
12
|
+
import { revokeWorkbuddyToken, defaultApiUrl } from "./exchange.js";
|
|
13
|
+
export async function runLogout() {
|
|
14
|
+
const creds = readCredentials();
|
|
15
|
+
if (!creds) {
|
|
16
|
+
return { ok: true, revoked: false, localDeleted: false };
|
|
17
|
+
}
|
|
18
|
+
const apiBaseUrl = defaultApiUrl();
|
|
19
|
+
const result = await revokeWorkbuddyToken({
|
|
20
|
+
apiBaseUrl,
|
|
21
|
+
accessToken: creds.accessToken,
|
|
22
|
+
installationId: creds.installationId,
|
|
23
|
+
});
|
|
24
|
+
if (!result.ok) {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
reason: result.reason ?? `http_${result.status}`,
|
|
28
|
+
revoked: false,
|
|
29
|
+
localDeleted: false,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const localDeleted = deleteCredentials();
|
|
33
|
+
return { ok: true, revoked: true, localDeleted };
|
|
34
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback HTTP listener that captures the WorkBuddy OAuth callback.
|
|
3
|
+
*
|
|
4
|
+
* Per RFC 8252 §7.3 the CLI binds 127.0.0.1 to a random port, registers
|
|
5
|
+
* `/oauth/callback` and waits for a single GET with `?code=...&state=...`.
|
|
6
|
+
* Anything else is rejected with 400.
|
|
7
|
+
*/
|
|
8
|
+
import { createServer, } from "node:http";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { generateCodeVerifier, s256Challenge } from "./pkce.js";
|
|
11
|
+
async function pickRandomPort(hostname) {
|
|
12
|
+
return await new Promise((resolve, reject) => {
|
|
13
|
+
const probe = createServer();
|
|
14
|
+
probe.on("error", reject);
|
|
15
|
+
probe.listen(0, hostname, () => {
|
|
16
|
+
const addr = probe.address();
|
|
17
|
+
if (addr && typeof addr === "object") {
|
|
18
|
+
const port = addr.port;
|
|
19
|
+
probe.close(() => resolve(port));
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
probe.close(() => reject(new Error("no_port")));
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function htmlResponse(res, status, body) {
|
|
28
|
+
res.statusCode = status;
|
|
29
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
30
|
+
res.setHeader("Cache-Control", "no-store");
|
|
31
|
+
res.end(body);
|
|
32
|
+
}
|
|
33
|
+
const SUCCESS_PAGE = `<!doctype html>
|
|
34
|
+
<html lang="zh-CN">
|
|
35
|
+
<head>
|
|
36
|
+
<meta charset="utf-8" />
|
|
37
|
+
<title>WorkBuddy 授权完成</title>
|
|
38
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
39
|
+
<style>
|
|
40
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif; background: #0f1115; color: #f1f1f1; margin: 0; padding: 48px; }
|
|
41
|
+
.card { max-width: 480px; margin: 0 auto; background: #1a1d23; border: 1px solid #2a2d33; border-radius: 12px; padding: 32px; text-align: center; }
|
|
42
|
+
h1 { font-size: 20px; margin: 0 0 16px; }
|
|
43
|
+
p { color: #b8b8b8; font-size: 14px; line-height: 1.6; }
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<div class="card">
|
|
48
|
+
<h1>WorkBuddy 授权完成</h1>
|
|
49
|
+
<p>您已成功将极睿视频账户连接到 WorkBuddy。</p>
|
|
50
|
+
<p>请返回 WorkBuddy 继续;可以关闭此页面。</p>
|
|
51
|
+
</div>
|
|
52
|
+
</body>
|
|
53
|
+
</html>`;
|
|
54
|
+
const ERROR_PAGE = (msg) => `<!doctype html>
|
|
55
|
+
<html lang="zh-CN">
|
|
56
|
+
<head>
|
|
57
|
+
<meta charset="utf-8" />
|
|
58
|
+
<title>WorkBuddy 授权失败</title>
|
|
59
|
+
</head>
|
|
60
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; padding: 48px;">
|
|
61
|
+
<h1>WorkBuddy 授权失败</h1>
|
|
62
|
+
<p>${msg}</p>
|
|
63
|
+
<p>请返回 WorkBuddy 重试。</p>
|
|
64
|
+
</body>
|
|
65
|
+
</html>`;
|
|
66
|
+
export async function bindLoopback(opts = {}) {
|
|
67
|
+
const hostname = opts.hostname ?? "127.0.0.1";
|
|
68
|
+
const port = opts.preferredPort ?? (await pickRandomPort(hostname));
|
|
69
|
+
const stateBytes = createHash("sha256")
|
|
70
|
+
.update(`${Date.now()}.${Math.random()}`)
|
|
71
|
+
.digest();
|
|
72
|
+
const state = stateBytes.toString("base64url").slice(0, 32);
|
|
73
|
+
const codeVerifier = generateCodeVerifier();
|
|
74
|
+
const codeChallenge = s256Challenge(codeVerifier);
|
|
75
|
+
let resolveCb = null;
|
|
76
|
+
let rejectCb = null;
|
|
77
|
+
let settled = false;
|
|
78
|
+
const callbackPromise = new Promise((resolve, reject) => {
|
|
79
|
+
resolveCb = resolve;
|
|
80
|
+
rejectCb = reject;
|
|
81
|
+
});
|
|
82
|
+
const handle = (req, res) => {
|
|
83
|
+
if (settled) {
|
|
84
|
+
htmlResponse(res, 410, ERROR_PAGE("回调已过期"));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const url = req.url ?? "/";
|
|
88
|
+
if (!url.startsWith("/oauth/callback")) {
|
|
89
|
+
htmlResponse(res, 404, ERROR_PAGE("Not Found"));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (req.method !== "GET") {
|
|
93
|
+
htmlResponse(res, 405, ERROR_PAGE("Method Not Allowed"));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const parsed = new URL(url, `http://${hostname}:${port}`);
|
|
97
|
+
const code = parsed.searchParams.get("code");
|
|
98
|
+
const stateParam = parsed.searchParams.get("state");
|
|
99
|
+
const error = parsed.searchParams.get("error");
|
|
100
|
+
if (error) {
|
|
101
|
+
settled = true;
|
|
102
|
+
htmlResponse(res, 400, ERROR_PAGE(`授权服务器返回 ${error}`));
|
|
103
|
+
rejectCb?.(new Error(`oauth_error:${error}`));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!code || !stateParam) {
|
|
107
|
+
htmlResponse(res, 400, ERROR_PAGE("缺少 code 或 state"));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (stateParam !== state) {
|
|
111
|
+
settled = true;
|
|
112
|
+
htmlResponse(res, 400, ERROR_PAGE("state 不匹配"));
|
|
113
|
+
rejectCb?.(new Error("state_mismatch"));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
settled = true;
|
|
117
|
+
htmlResponse(res, 200, SUCCESS_PAGE);
|
|
118
|
+
resolveCb?.({ code, state: stateParam });
|
|
119
|
+
};
|
|
120
|
+
const server = createServer(handle);
|
|
121
|
+
await new Promise((resolve, reject) => {
|
|
122
|
+
server.once("error", reject);
|
|
123
|
+
server.listen(port, hostname, () => resolve());
|
|
124
|
+
});
|
|
125
|
+
const boundPort = server.address()?.port ?? port;
|
|
126
|
+
const close = () => new Promise(resolve => {
|
|
127
|
+
server.close(() => resolve());
|
|
128
|
+
});
|
|
129
|
+
const awaitCallback = (timeoutMs) => new Promise((resolve, reject) => {
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
if (!settled) {
|
|
132
|
+
settled = true;
|
|
133
|
+
reject(new Error("loopback_timeout"));
|
|
134
|
+
}
|
|
135
|
+
}, timeoutMs);
|
|
136
|
+
callbackPromise
|
|
137
|
+
.then(v => {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
resolve(v);
|
|
140
|
+
})
|
|
141
|
+
.catch((err) => {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
reject(err);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
port: boundPort,
|
|
148
|
+
url: `http://${hostname}:${boundPort}/oauth/callback`,
|
|
149
|
+
state,
|
|
150
|
+
codeVerifier,
|
|
151
|
+
codeChallenge,
|
|
152
|
+
close,
|
|
153
|
+
awaitCallback,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem layout for WorkBuddy-managed credentials.
|
|
3
|
+
*
|
|
4
|
+
* Per plan §4: the CLI persists the credential file at
|
|
5
|
+
* ~/.workbuddy/connectors/infimind-video/auth/credentials.json
|
|
6
|
+
* with directory mode 0700 and file mode 0600.
|
|
7
|
+
*
|
|
8
|
+
* `~` is resolved to $HOME (or to a process.env override WORKBUDDY_HOME for
|
|
9
|
+
* testing). Tests can monkey-patch the resolver by setting WORKBUDDY_HOME.
|
|
10
|
+
*/
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
export const WORKBUDDY_HOME_ENV = "WORKBUDDY_HOME";
|
|
14
|
+
export const CONNECTOR_ID = "infimind-video";
|
|
15
|
+
export const CREDENTIAL_FILENAME = "credentials.json";
|
|
16
|
+
export function workbuddyHome() {
|
|
17
|
+
const env = process.env[WORKBUDDY_HOME_ENV];
|
|
18
|
+
if (env && env.length > 0)
|
|
19
|
+
return resolve(env);
|
|
20
|
+
return join(homedir(), ".workbuddy");
|
|
21
|
+
}
|
|
22
|
+
export function credentialsDir() {
|
|
23
|
+
return join(workbuddyHome(), "connectors", CONNECTOR_ID, "auth");
|
|
24
|
+
}
|
|
25
|
+
export function credentialsFilePath() {
|
|
26
|
+
return join(credentialsDir(), CREDENTIAL_FILENAME);
|
|
27
|
+
}
|
|
28
|
+
export function installationIdFilePath() {
|
|
29
|
+
return join(credentialsDir(), "installation_id");
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE S256 helpers (CLI side). Mirrors the server's checks exactly so the
|
|
3
|
+
* verifier we generate always produces a challenge the server accepts.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
export function generateCodeVerifier() {
|
|
7
|
+
return randomBytes(32).toString("base64url");
|
|
8
|
+
}
|
|
9
|
+
export function s256Challenge(verifier) {
|
|
10
|
+
return createHash("sha256").update(verifier, "utf8").digest("base64url");
|
|
11
|
+
}
|