@remixmate/cli 0.9.13 → 0.9.15
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 +54 -1
- package/README.zh-CN.md +37 -1
- package/dist/auth/auto-login.js +10 -1
- package/dist/auth/commands.js +181 -21
- package/dist/auth/credential-store.d.ts +16 -0
- package/dist/auth/credential-store.js +17 -0
- package/dist/auth/device-flow-runner.d.ts +15 -0
- package/dist/auth/device-flow-runner.js +60 -12
- package/dist/auth/device-flow.js +24 -7
- package/dist/auth/ensure.d.ts +55 -0
- package/dist/auth/ensure.js +62 -0
- package/dist/auth/environment.d.ts +13 -0
- package/dist/auth/environment.js +21 -0
- package/dist/auth/pending-store.d.ts +33 -0
- package/dist/auth/pending-store.js +45 -0
- package/dist/billing.d.ts +44 -0
- package/dist/billing.js +76 -0
- package/dist/cli.js +75 -8
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +142 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +33 -0
- package/dist/exec.d.ts +18 -0
- package/dist/exec.js +47 -0
- package/dist/handlers/gen-digital-human.js +1 -0
- package/dist/handlers/gen-image.js +1 -0
- package/dist/handlers/gen-video.js +1 -0
- package/dist/handlers/gen-voice.js +2 -0
- package/dist/handlers/index.d.ts +7 -0
- package/dist/http.d.ts +20 -6
- package/dist/http.js +43 -15
- package/dist/manifest.json +2 -2
- package/dist/registry.d.ts +4 -2
- package/dist/registry.js +2 -1
- package/dist/runner.d.ts +4 -0
- package/dist/runner.js +36 -12
- package/dist/skill-schema.d.ts +19 -0
- package/dist/skill-schema.js +18 -0
- package/dist/text.d.ts +8 -0
- package/dist/text.js +15 -0
- package/package.json +2 -2
- package/skills/export-jianying/skill.json +1 -0
- package/skills/gen-digital-human/SKILL.md +12 -0
- package/skills/gen-digital-human/skill.json +1 -0
- package/skills/gen-image/SKILL.md +12 -0
- package/skills/gen-image/skill.json +1 -0
- package/skills/gen-script/skill.json +1 -0
- package/skills/gen-video/SKILL.md +12 -0
- package/skills/gen-video/skill.json +1 -0
- package/skills/gen-voice/SKILL.md +12 -0
- package/skills/gen-voice/skill.json +1 -0
- package/skills/prepare-video-assets/SKILL.md +12 -0
- package/skills/prepare-video-assets/skill.json +1 -0
- package/skills/render-video/SKILL.md +12 -0
- package/skills/render-video/scripts/render_video.py +63 -0
- package/skills/render-video/skill.json +1 -0
- package/skills/template-registry/scripts/list_templates.py +16 -1
- package/skills/template-registry/scripts/registry_loader.py +63 -63
- package/skills/template-registry/scripts/render_job_client.py +27 -0
- package/skills/template-registry/skill.json +1 -0
- package/skills/video-parser/skill.json +1 -0
- package/skills/web-record/skill.json +1 -0
- package/skills/web-screenshot/skill.json +1 -0
package/README.md
CHANGED
|
@@ -52,7 +52,13 @@ remixmate logout # remove stored credentials from this machine
|
|
|
52
52
|
`login` uses the OAuth 2.0 Device Authorization Grant (RFC 8628): the CLI shows a
|
|
53
53
|
device code, you approve it in a browser that is already signed in to the web app, and
|
|
54
54
|
the CLI receives and stores your credential in the OS keychain (or a `0600` file at
|
|
55
|
-
`~/.config/remixmate/credentials.json`).
|
|
55
|
+
`~/.config/remixmate/credentials.json`).
|
|
56
|
+
|
|
57
|
+
No TTY is required — the device flow reads no keyboard input, so an agent host
|
|
58
|
+
(Claude Code / Codex) can run it and relay the authorization link into the chat.
|
|
59
|
+
The CLI prints a link with the device code pre-filled, so approving is one click.
|
|
60
|
+
It is refused only where a browser could never be reached: CI, a headless Linux
|
|
61
|
+
session, or `REMIXMATE_NO_BROWSER_AUTH=1`; use `PRIV_TOKEN` there instead.
|
|
56
62
|
|
|
57
63
|
For CI and agent-embedded hosts (ab-agent / Claude Code), keep using the `PRIV_TOKEN`
|
|
58
64
|
environment variable. Credential resolution precedence is:
|
|
@@ -63,6 +69,53 @@ environment variable. Credential resolution precedence is:
|
|
|
63
69
|
|
|
64
70
|
When `PRIV_TOKEN` is set, `login` is skipped and the device flow is never auto-triggered.
|
|
65
71
|
|
|
72
|
+
The credential is resolved once, on the Node side (`src/auth/ensure.ts`), and injected
|
|
73
|
+
into spawned Python skills through their environment — skill scripts never read the
|
|
74
|
+
credential store or the keychain themselves. To give any other command the same
|
|
75
|
+
credential, use `exec`:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
remixmate exec -- python3 scripts/test-template-pipeline.py
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Each skill declares its requirement as `auth` in `skill.json`: `required` (authorize
|
|
82
|
+
when no token is present), `optional` (use a token if there is one, otherwise carry on
|
|
83
|
+
in a degraded mode — `web-record` keeps the file locally), or `none` (purely local, e.g.
|
|
84
|
+
`web-screenshot`).
|
|
85
|
+
|
|
86
|
+
Exit codes on failure: `4` = authorization needed, `5` = backend unreachable,
|
|
87
|
+
`2` = usage error.
|
|
88
|
+
|
|
89
|
+
### Non-blocking authorization (agent hosts)
|
|
90
|
+
|
|
91
|
+
A blocking `login` waits for the device code's full lifetime, which outlives an
|
|
92
|
+
agent's time-bounded tool call. Split it instead:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
remixmate login --start --json # returns immediately with the approval link
|
|
96
|
+
remixmate login --wait --timeout 60 --json # bounded poll; call again while pending
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`--start` persists the device code to a `0600` `~/.config/remixmate/pending.json`;
|
|
100
|
+
`--wait` redeems it and clears the file. While the user has not yet approved,
|
|
101
|
+
`--wait` reports `{"status":"pending"}` with exit code 4 and leaves the pending
|
|
102
|
+
state in place, so it is safe to call repeatedly.
|
|
103
|
+
|
|
104
|
+
### Diagnosing
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
remixmate doctor # node / python3 / Playwright / credential / backend
|
|
108
|
+
remixmate doctor --offline # skip the reachability probe
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`doctor` exits `4` when the only problem is a missing credential.
|
|
112
|
+
|
|
113
|
+
### Listing skills
|
|
114
|
+
|
|
115
|
+
`remixmate --list` prints a human-readable table (name + whether it needs a login
|
|
116
|
+
+ summary) and appends the current auth status on stderr, so piping stdout is
|
|
117
|
+
unaffected. For tooling, use `remixmate --list --json`.
|
|
118
|
+
|
|
66
119
|
### Automatic browser authorization (local hosts)
|
|
67
120
|
|
|
68
121
|
When a skill that needs a credential is invoked from a **local third-party host**
|
package/README.zh-CN.md
CHANGED
|
@@ -48,7 +48,9 @@ remixmate whoami # 查看当前身份(绝不打印 token)
|
|
|
48
48
|
remixmate logout # 从本机移除已存储的凭证
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
`login` 使用 OAuth 2.0 设备授权流程(RFC 8628):CLI
|
|
51
|
+
`login` 使用 OAuth 2.0 设备授权流程(RFC 8628):CLI 给出一条已预填设备码的授权链接,你在已登录 Web 的浏览器中点一下确认,CLI 随后取回凭证并保存到系统钥匙串(或 `~/.config/remixmate/credentials.json`,权限 `0600`)。
|
|
52
|
+
|
|
53
|
+
不需要交互式终端 —— 设备流不读键盘输入,agent 宿主(Claude Code / Codex)可以直接执行并把链接转述到聊天里。只有在浏览器根本不可达时才会拒绝:CI、无桌面会话的 Linux、或设置了 `REMIXMATE_NO_BROWSER_AUTH=1`;这些场景请改用 `PRIV_TOKEN`。
|
|
52
54
|
|
|
53
55
|
CI 与 agent 宿主(ab-agent / Claude Code)继续使用 `PRIV_TOKEN` 环境变量。凭证解析优先级:
|
|
54
56
|
|
|
@@ -58,6 +60,40 @@ CI 与 agent 宿主(ab-agent / Claude Code)继续使用 `PRIV_TOKEN` 环境
|
|
|
58
60
|
|
|
59
61
|
设置了 `PRIV_TOKEN` 时,`login` 会跳过,且任何命令都不会自动触发设备登录流程。
|
|
60
62
|
|
|
63
|
+
凭证只在 CLI 的 Node 侧解析一次(`src/auth/ensure.ts`),再通过环境变量注入给被调起的 Python 技能 —— 技能脚本自己不读凭证库、不读钥匙串。需要让其它命令(如维护脚本)拿到同一份凭证时,用 `exec`:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
remixmate exec -- python3 scripts/test-template-pipeline.py
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
每个技能在 `skill.json` 里用 `auth` 声明鉴权需求:`required`(缺 token 就发起授权)、`optional`(有就用,没有则降级继续,如 `web-record` 只保留本地文件)、`none`(纯本地,如 `web-screenshot`)。
|
|
70
|
+
|
|
71
|
+
失败时的退出码:`4` = 需要授权,`5` = 后端不可达,`2` = 用法错误。
|
|
72
|
+
|
|
73
|
+
### 非阻塞授权(agent 宿主)
|
|
74
|
+
|
|
75
|
+
阻塞式 `login` 会一直等到设备码过期(10 分钟),超过 agent 单次工具调用的时长。改用两段式:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
remixmate login --start --json # 立刻返回授权链接,不等待
|
|
79
|
+
remixmate login --wait --timeout 60 --json # 有界轮询;还没授权就再调一次
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`--start` 把设备码写入 `0600` 的 `~/.config/remixmate/pending.json`;`--wait` 兑换后清除它。用户尚未确认时 `--wait` 返回 `{"status":"pending"}` 与退出码 4,并保留 pending 状态,可以反复调用。
|
|
83
|
+
|
|
84
|
+
### 排障
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
remixmate doctor # 检查 node / python3 / Playwright / 凭证 / 后端
|
|
88
|
+
remixmate doctor --offline # 跳过联网探测
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
只有凭证一项有问题时,`doctor` 退出码为 `4`。
|
|
92
|
+
|
|
93
|
+
### 列出技能
|
|
94
|
+
|
|
95
|
+
`remixmate --list` 默认输出人类可读表格(技能名 + 是否需登录 + 摘要),末尾附一行当前登录状态(写到 stderr,不干扰管道)。给工具消费请用 `remixmate --list --json`。
|
|
96
|
+
|
|
61
97
|
> 安全提示:若你此前曾把 `PRIV_TOKEN` 粘贴到聊天框或写进 shell 历史,切换到 `remixmate login` 后建议轮换该 token。
|
|
62
98
|
|
|
63
99
|
## 环境
|
package/dist/auth/auto-login.js
CHANGED
|
@@ -15,7 +15,16 @@ import { resolvePrivToken } from './resolve.js';
|
|
|
15
15
|
import { canAutoAuth } from './environment.js';
|
|
16
16
|
import { acquireLock, waitForCredential } from './auth-lock.js';
|
|
17
17
|
import { runDeviceFlow } from './device-flow-runner.js';
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* How long a skill call waits for the user to approve in the browser.
|
|
20
|
+
*
|
|
21
|
+
* 90s, not 25s: when an agent host relays the link into a chat, the clock
|
|
22
|
+
* includes the user reading the message, switching to a browser and clicking —
|
|
23
|
+
* 25s reliably expired before a human could react. The ceiling is set by the
|
|
24
|
+
* host's own tool-call timeout (Claude Code's Bash tool defaults to 120s), so
|
|
25
|
+
* this must stay comfortably under that or the wait gets truncated anyway.
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_WAIT_MS = 90_000;
|
|
19
28
|
/** Extra slack on top of the wait window before a held lock is considered stale. */
|
|
20
29
|
const STALE_SLACK_MS = 10_000;
|
|
21
30
|
/** Resolve the short-wait window from env, falling back to the default. */
|
package/dist/auth/commands.js
CHANGED
|
@@ -6,50 +6,198 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { parseArgv } from '../argv.js';
|
|
8
8
|
import { resolveApiBaseUrl } from '../http.js';
|
|
9
|
-
import {
|
|
9
|
+
import { EXIT } from '../errors.js';
|
|
10
|
+
import { getCredential, listCredentials, removeCredential } from './credential-store.js';
|
|
10
11
|
import { resolvePrivToken } from './resolve.js';
|
|
11
|
-
import { runDeviceFlow } from './device-flow-runner.js';
|
|
12
|
+
import { persistApproval, runDeviceFlow, writeAuthPrompt } from './device-flow-runner.js';
|
|
13
|
+
import { openBrowser, pollForToken, requestDeviceCode, DeviceFlowError } from './device-flow.js';
|
|
14
|
+
import { clearPending, getPending, setPending } from './pending-store.js';
|
|
15
|
+
import { canAutoAuth, isAgentHost } from './environment.js';
|
|
16
|
+
/**
|
|
17
|
+
* Bounded wait used when an agent host runs `login` on the user's behalf. The
|
|
18
|
+
* device code lives ~10 minutes, but an agent's tool call does not — blocking
|
|
19
|
+
* that long guarantees the call is killed with nothing persisted. Stop early
|
|
20
|
+
* and tell the caller it can simply re-run.
|
|
21
|
+
*/
|
|
22
|
+
const AGENT_LOGIN_WAIT_MS = 90_000;
|
|
23
|
+
/**
|
|
24
|
+
* Explain, when the active backend has no credential, which backends DO have
|
|
25
|
+
* one. Returns '' when there is nothing useful to add.
|
|
26
|
+
*
|
|
27
|
+
* Credentials are keyed by API base URL, so being "logged in" to
|
|
28
|
+
* http://localhost:2999/api tells you nothing about production — but the bare
|
|
29
|
+
* message ("not logged in") reads as if `login` had never been run.
|
|
30
|
+
*/
|
|
31
|
+
async function describeOtherBackends(activeApiBaseUrl) {
|
|
32
|
+
const others = (await listCredentials()).filter((c) => c.apiBaseUrl !== activeApiBaseUrl);
|
|
33
|
+
if (others.length === 0)
|
|
34
|
+
return '';
|
|
35
|
+
const lines = others.map((c) => ` - ${c.apiBaseUrl}${c.userLabel ? `(${c.userLabel})` : ''}`);
|
|
36
|
+
return (`ℹ️ 当前后端:${activeApiBaseUrl}(无凭证)\n` +
|
|
37
|
+
` 已存储凭证的后端:\n${lines.join('\n')}\n` +
|
|
38
|
+
` 要使用其中之一,设置 MM_API_BASE_URL=<该地址>,或加 --api-base-url <该地址>。\n`);
|
|
39
|
+
}
|
|
12
40
|
async function login(apiBaseUrl) {
|
|
13
41
|
// Env token short-circuits the device flow (CI / agent-embedded hosts).
|
|
14
42
|
if ((process.env.PRIV_TOKEN ?? '').trim()) {
|
|
15
43
|
process.stdout.write('ℹ️ PRIV_TOKEN 环境变量已设置,CLI 将直接使用它,无需登录。\n');
|
|
16
|
-
return
|
|
44
|
+
return EXIT.OK;
|
|
17
45
|
}
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
46
|
+
// The device flow reads no keyboard input, so a TTY is not required — an
|
|
47
|
+
// agent host can run it and relay the link into chat. What IS required is a
|
|
48
|
+
// way to reach the user: canAutoAuth() rejects CI / headless, where a browser
|
|
49
|
+
// could never be opened and blocking would just hang the job.
|
|
50
|
+
if (!canAutoAuth()) {
|
|
51
|
+
process.stderr.write('❌ 当前环境无法完成浏览器授权(CI / 无桌面会话 / 已通过 REMIXMATE_NO_BROWSER_AUTH 关闭)。\n' +
|
|
52
|
+
' 请改用 PRIV_TOKEN 环境变量。\n');
|
|
53
|
+
return EXIT.NOT_AUTHENTICATED;
|
|
22
54
|
}
|
|
23
55
|
// Already authenticated for this backend?
|
|
24
56
|
const existing = await getCredential(apiBaseUrl);
|
|
25
57
|
if (existing) {
|
|
26
58
|
process.stdout.write(`✅ 已登录(${existing.userLabel ?? 'unknown'})。如需切换账号请先运行 remixmate logout。\n`);
|
|
27
|
-
return
|
|
59
|
+
return EXIT.OK;
|
|
28
60
|
}
|
|
29
|
-
//
|
|
30
|
-
//
|
|
61
|
+
// A human at a terminal can wait out the device code's full lifetime; an
|
|
62
|
+
// agent tool call cannot (see AGENT_LOGIN_WAIT_MS). Progress goes to stdout.
|
|
31
63
|
const outcome = await runDeviceFlow(apiBaseUrl, {
|
|
32
64
|
write: (msg) => process.stdout.write(msg),
|
|
65
|
+
maxWaitMs: isAgentHost() ? AGENT_LOGIN_WAIT_MS : undefined,
|
|
33
66
|
});
|
|
34
67
|
switch (outcome.status) {
|
|
35
68
|
case 'approved':
|
|
36
69
|
process.stdout.write(`\n✅ 登录成功:${outcome.userLabel ?? ''}\n`);
|
|
37
70
|
process.stdout.write('🔒 安全提示:若你此前曾把 PrivToken 粘贴到聊天框或写进 shell 历史,建议在控制台轮换该 token。\n');
|
|
38
|
-
return
|
|
71
|
+
return EXIT.OK;
|
|
39
72
|
case 'denied':
|
|
40
73
|
process.stderr.write('❌ 授权被拒绝。\n');
|
|
41
|
-
return
|
|
74
|
+
return EXIT.NOT_AUTHENTICATED;
|
|
42
75
|
case 'error':
|
|
43
76
|
process.stderr.write(`❌ 登录失败:${outcome.message}\n`);
|
|
44
|
-
return
|
|
77
|
+
return EXIT.ERROR;
|
|
45
78
|
case 'timeout':
|
|
79
|
+
// The wait window closed, not the device code. Authorization may still be
|
|
80
|
+
// pending in the browser — re-running picks up the approval immediately.
|
|
81
|
+
process.stderr.write('⏳ 已打开授权页但未在等待窗口内完成。在浏览器点击确认后,重跑 remixmate login(或直接重跑原命令)即可。\n');
|
|
82
|
+
return EXIT.NOT_AUTHENTICATED;
|
|
46
83
|
case 'expired':
|
|
47
84
|
default:
|
|
48
85
|
process.stderr.write('❌ 设备码已过期或无效,请重新运行 remixmate login。\n');
|
|
49
|
-
return
|
|
86
|
+
return EXIT.NOT_AUTHENTICATED;
|
|
50
87
|
}
|
|
51
88
|
}
|
|
89
|
+
/** Default bound for one `login --wait` call, overridable with --timeout <seconds>. */
|
|
90
|
+
const DEFAULT_WAIT_SECONDS = 60;
|
|
91
|
+
/**
|
|
92
|
+
* `login --start` — request a device code, persist it, print the link, return.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately does NOT poll: the caller (an agent, or a script) gets the link
|
|
95
|
+
* immediately and can surface it while the user takes as long as they need.
|
|
96
|
+
* Approval is collected later by `login --wait`.
|
|
97
|
+
*/
|
|
98
|
+
async function loginStart(apiBaseUrl, jsonOutput) {
|
|
99
|
+
const clientLabel = `remixmate-cli (${process.platform} ${process.arch})`;
|
|
100
|
+
let code;
|
|
101
|
+
try {
|
|
102
|
+
code = await requestDeviceCode(apiBaseUrl, clientLabel);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const msg = err instanceof DeviceFlowError ? err.message : err.message;
|
|
106
|
+
process.stderr.write(`❌ 无法获取设备码:${msg}\n`);
|
|
107
|
+
return EXIT.BACKEND_UNREACHABLE;
|
|
108
|
+
}
|
|
109
|
+
await setPending({
|
|
110
|
+
apiBaseUrl,
|
|
111
|
+
deviceCode: code.deviceCode,
|
|
112
|
+
userCode: code.userCode,
|
|
113
|
+
verificationUri: code.verificationUri,
|
|
114
|
+
verificationUriComplete: code.verificationUriComplete,
|
|
115
|
+
interval: code.interval,
|
|
116
|
+
expiresAt: Date.now() + code.expiresIn * 1000,
|
|
117
|
+
});
|
|
118
|
+
if (jsonOutput) {
|
|
119
|
+
process.stdout.write(JSON.stringify({
|
|
120
|
+
status: 'pending',
|
|
121
|
+
apiBaseUrl,
|
|
122
|
+
verificationUri: code.verificationUri,
|
|
123
|
+
verificationUriComplete: code.verificationUriComplete ?? code.verificationUri,
|
|
124
|
+
userCode: code.userCode,
|
|
125
|
+
expiresIn: code.expiresIn,
|
|
126
|
+
// The device_code itself is intentionally omitted — `login --wait` reads
|
|
127
|
+
// it from the pending file, so it never has to travel through a log or
|
|
128
|
+
// an agent's context.
|
|
129
|
+
}) + '\n');
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
writeAuthPrompt((m) => process.stdout.write(m), apiBaseUrl, code);
|
|
133
|
+
openBrowser(code.verificationUriComplete ?? code.verificationUri);
|
|
134
|
+
process.stdout.write('完成授权后运行 remixmate login --wait 收取凭证。\n');
|
|
135
|
+
}
|
|
136
|
+
return EXIT.OK;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* `login --wait [--timeout N]` — poll a previously started authorization for a
|
|
140
|
+
* bounded window. Safe to call repeatedly until it reports approved or expired.
|
|
141
|
+
*/
|
|
142
|
+
async function loginWait(apiBaseUrl, timeoutSeconds, jsonOutput) {
|
|
143
|
+
const emit = (payload, human, code) => {
|
|
144
|
+
if (jsonOutput)
|
|
145
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
146
|
+
else
|
|
147
|
+
process.stdout.write(human);
|
|
148
|
+
return code;
|
|
149
|
+
};
|
|
150
|
+
const pending = await getPending(apiBaseUrl);
|
|
151
|
+
if (!pending) {
|
|
152
|
+
if (jsonOutput)
|
|
153
|
+
process.stdout.write(JSON.stringify({ status: 'no_pending', apiBaseUrl }) + '\n');
|
|
154
|
+
else
|
|
155
|
+
process.stderr.write(`❌ 没有进行中的授权(或已过期 / 属于别的后端)。请先运行 remixmate login --start。\n`);
|
|
156
|
+
return EXIT.NOT_AUTHENTICATED;
|
|
157
|
+
}
|
|
158
|
+
// Rebuild the shape pollForToken expects; expiresIn is the REMAINING lifetime
|
|
159
|
+
// so a resumed wait can never poll past the device code's real expiry.
|
|
160
|
+
const remainingSec = Math.max(1, Math.ceil((pending.expiresAt - Date.now()) / 1000));
|
|
161
|
+
let result;
|
|
162
|
+
try {
|
|
163
|
+
result = await pollForToken(apiBaseUrl, {
|
|
164
|
+
deviceCode: pending.deviceCode,
|
|
165
|
+
userCode: pending.userCode,
|
|
166
|
+
verificationUri: pending.verificationUri,
|
|
167
|
+
verificationUriComplete: pending.verificationUriComplete,
|
|
168
|
+
interval: pending.interval,
|
|
169
|
+
expiresIn: remainingSec,
|
|
170
|
+
}, { maxWaitMs: timeoutSeconds * 1000 });
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
const msg = err instanceof DeviceFlowError ? err.message : err.message;
|
|
174
|
+
if (jsonOutput)
|
|
175
|
+
process.stdout.write(JSON.stringify({ status: 'error', message: msg }) + '\n');
|
|
176
|
+
else
|
|
177
|
+
process.stderr.write(`❌ 轮询失败:${msg}\n`);
|
|
178
|
+
return EXIT.BACKEND_UNREACHABLE;
|
|
179
|
+
}
|
|
180
|
+
if (result.status === 'approved' && result.privToken) {
|
|
181
|
+
await persistApproval(apiBaseUrl, result.privToken, result.userLabel, (m) => process.stdout.write(m));
|
|
182
|
+
await clearPending();
|
|
183
|
+
return emit({ status: 'approved', userLabel: result.userLabel, apiBaseUrl }, `✅ 登录成功:${result.userLabel ?? ''}\n`, EXIT.OK);
|
|
184
|
+
}
|
|
185
|
+
if (result.status === 'access_denied') {
|
|
186
|
+
await clearPending();
|
|
187
|
+
return emit({ status: 'denied' }, '❌ 授权被拒绝。\n', EXIT.NOT_AUTHENTICATED);
|
|
188
|
+
}
|
|
189
|
+
if (result.timedOut) {
|
|
190
|
+
// The wait window closed, not the device code — the pending state stays put
|
|
191
|
+
// so the caller can simply poll again.
|
|
192
|
+
return emit({ status: 'pending', apiBaseUrl, userCode: pending.userCode }, '⏳ 尚未完成授权。在浏览器确认后再次运行 remixmate login --wait。\n', EXIT.NOT_AUTHENTICATED);
|
|
193
|
+
}
|
|
194
|
+
await clearPending();
|
|
195
|
+
return emit({ status: 'expired' }, '❌ 设备码已过期,请重新运行 remixmate login --start。\n', EXIT.NOT_AUTHENTICATED);
|
|
196
|
+
}
|
|
52
197
|
async function logout(apiBaseUrl) {
|
|
198
|
+
// Drop any half-finished authorization too, so a later `--wait` can't redeem
|
|
199
|
+
// a device code the user just logged out of.
|
|
200
|
+
await clearPending();
|
|
53
201
|
const res = await removeCredential(apiBaseUrl);
|
|
54
202
|
const localNote = 'ℹ️ logout 仅清除本地凭证,不会在服务端吊销 PrivToken;如需服务端失效请轮换 token。\n';
|
|
55
203
|
if (!res.removedFile && !res.removedKeychain && !res.fileError && !res.keychainError) {
|
|
@@ -96,20 +244,23 @@ async function whoami(apiBaseUrl, flagToken) {
|
|
|
96
244
|
const resolved = await resolvePrivToken({ flag: flagToken, apiBaseUrl });
|
|
97
245
|
if (!resolved) {
|
|
98
246
|
process.stderr.write('❌ 未登录。运行 remixmate login,或设置 PRIV_TOKEN 环境变量。\n');
|
|
99
|
-
|
|
247
|
+
const hint = await describeOtherBackends(apiBaseUrl);
|
|
248
|
+
if (hint)
|
|
249
|
+
process.stderr.write(hint);
|
|
250
|
+
return EXIT.NOT_AUTHENTICATED;
|
|
100
251
|
}
|
|
101
252
|
const verdict = await verifyToken(apiBaseUrl, resolved.privToken);
|
|
102
253
|
if (verdict === 'unreachable') {
|
|
103
254
|
process.stderr.write('❌ 无法验证身份:后端不可达或超时(本地凭证未改动)。\n');
|
|
104
|
-
return
|
|
255
|
+
return EXIT.BACKEND_UNREACHABLE;
|
|
105
256
|
}
|
|
106
257
|
if (verdict === 'invalid') {
|
|
107
258
|
process.stderr.write(`❌ 凭证无效或已被吊销。来源:${resolved.source}\n`);
|
|
108
|
-
return
|
|
259
|
+
return EXIT.NOT_AUTHENTICATED;
|
|
109
260
|
}
|
|
110
261
|
const label = resolved.userLabel ?? '(已认证)';
|
|
111
|
-
process.stdout.write(`已认证:${label}\n来源:${resolved.source}\n`);
|
|
112
|
-
return
|
|
262
|
+
process.stdout.write(`已认证:${label}\n来源:${resolved.source}\n后端:${apiBaseUrl}\n`);
|
|
263
|
+
return EXIT.OK;
|
|
113
264
|
}
|
|
114
265
|
/** Dispatch an auth verb. Returns a process exit code. */
|
|
115
266
|
export async function runAuthCommand(verb, rest) {
|
|
@@ -117,14 +268,23 @@ export async function runAuthCommand(verb, rest) {
|
|
|
117
268
|
const apiBaseUrl = resolveApiBaseUrl(args.api_base_url);
|
|
118
269
|
const flagToken = args.token;
|
|
119
270
|
switch (verb) {
|
|
120
|
-
case 'login':
|
|
271
|
+
case 'login': {
|
|
272
|
+
const jsonOutput = args.json_output === true || args.json === true;
|
|
273
|
+
if (args.start === true)
|
|
274
|
+
return loginStart(apiBaseUrl, jsonOutput);
|
|
275
|
+
if (args.wait === true || typeof args.wait === 'string') {
|
|
276
|
+
const raw = Number(args.timeout);
|
|
277
|
+
const timeout = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_WAIT_SECONDS;
|
|
278
|
+
return loginWait(apiBaseUrl, timeout, jsonOutput);
|
|
279
|
+
}
|
|
121
280
|
return login(apiBaseUrl);
|
|
281
|
+
}
|
|
122
282
|
case 'logout':
|
|
123
283
|
return logout(apiBaseUrl);
|
|
124
284
|
case 'whoami':
|
|
125
285
|
return whoami(apiBaseUrl, flagToken);
|
|
126
286
|
default:
|
|
127
287
|
process.stderr.write(`❌ unknown auth command: ${verb}\n`);
|
|
128
|
-
return
|
|
288
|
+
return EXIT.USAGE;
|
|
129
289
|
}
|
|
130
290
|
}
|
|
@@ -40,5 +40,21 @@ export declare function setCredential(apiBaseUrl: string, cred: StoredCredential
|
|
|
40
40
|
}>;
|
|
41
41
|
/** Read a credential for `apiBaseUrl`, or null when none is stored. */
|
|
42
42
|
export declare function getCredential(apiBaseUrl: string): Promise<StoredCredential | null>;
|
|
43
|
+
/** Non-secret summary of one stored entry, for diagnostics (`whoami`, `doctor`). */
|
|
44
|
+
export interface CredentialSummary {
|
|
45
|
+
apiBaseUrl: string;
|
|
46
|
+
userLabel?: string;
|
|
47
|
+
createdAt: number;
|
|
48
|
+
secret: 'file' | 'keychain';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* List which backends have a stored credential, WITHOUT reading any secret.
|
|
52
|
+
*
|
|
53
|
+
* Credentials are keyed by API base URL, so "logged in" is per-backend. Without
|
|
54
|
+
* this, a credential stored for a local dev backend while the CLI defaults to
|
|
55
|
+
* production produced a bare "not logged in" — technically true, actively
|
|
56
|
+
* misleading, and the exact confusion that motivated this work.
|
|
57
|
+
*/
|
|
58
|
+
export declare function listCredentials(): Promise<CredentialSummary[]>;
|
|
43
59
|
/** Remove a credential from both stores; reports per-store outcome. */
|
|
44
60
|
export declare function removeCredential(apiBaseUrl: string): Promise<RemoveResult>;
|
|
@@ -99,6 +99,23 @@ export async function getCredential(apiBaseUrl) {
|
|
|
99
99
|
return null;
|
|
100
100
|
return { privToken, userLabel: entry.userLabel, createdAt: entry.createdAt };
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* List which backends have a stored credential, WITHOUT reading any secret.
|
|
104
|
+
*
|
|
105
|
+
* Credentials are keyed by API base URL, so "logged in" is per-backend. Without
|
|
106
|
+
* this, a credential stored for a local dev backend while the CLI defaults to
|
|
107
|
+
* production produced a bare "not logged in" — technically true, actively
|
|
108
|
+
* misleading, and the exact confusion that motivated this work.
|
|
109
|
+
*/
|
|
110
|
+
export async function listCredentials() {
|
|
111
|
+
const store = await readFileStore();
|
|
112
|
+
return Object.entries(store.credentials).map(([apiBaseUrl, entry]) => ({
|
|
113
|
+
apiBaseUrl,
|
|
114
|
+
userLabel: entry.userLabel,
|
|
115
|
+
createdAt: entry.createdAt,
|
|
116
|
+
secret: entry.secret,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
102
119
|
/** Remove a credential from both stores; reports per-store outcome. */
|
|
103
120
|
export async function removeCredential(apiBaseUrl) {
|
|
104
121
|
const result = { removedFile: false, removedKeychain: false };
|
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Security: the PrivToken value is NEVER written to the provided sink.
|
|
11
11
|
*/
|
|
12
|
+
import { type DeviceCodeResponse } from './device-flow.js';
|
|
13
|
+
export declare function renderAuthBlock(apiBaseUrl: string, code: DeviceCodeResponse): string;
|
|
14
|
+
/**
|
|
15
|
+
* Emit the authorization prompt: a one-click link (device code pre-filled),
|
|
16
|
+
* the manual fallback, and — for agent hosts — the machine-readable block.
|
|
17
|
+
* Shared by `runDeviceFlow` and `login --start` so both entry points present
|
|
18
|
+
* authorization identically.
|
|
19
|
+
*/
|
|
20
|
+
export declare function writeAuthPrompt(write: (msg: string) => void, apiBaseUrl: string, code: DeviceCodeResponse): void;
|
|
12
21
|
export interface DeviceFlowOptions {
|
|
13
22
|
/**
|
|
14
23
|
* Upper bound on how long to poll for approval. When omitted the device
|
|
@@ -35,6 +44,12 @@ export type DeviceFlowOutcome = {
|
|
|
35
44
|
status: 'error';
|
|
36
45
|
message: string;
|
|
37
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Persist an approved PrivToken. Shared by `runDeviceFlow` and `login --wait`
|
|
49
|
+
* so a credential written by either is indistinguishable. The token value is
|
|
50
|
+
* never written to `write`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function persistApproval(apiBaseUrl: string, privToken: string, userLabel: string | undefined, write: (msg: string) => void): Promise<void>;
|
|
38
53
|
/**
|
|
39
54
|
* Run one full Device Flow against `apiBaseUrl`. On approval the PrivToken is
|
|
40
55
|
* persisted via setCredential (keychain or file, same as `login`) and only the
|
|
@@ -11,9 +11,59 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { DeviceFlowError, openBrowser, pollForToken, requestDeviceCode, } from './device-flow.js';
|
|
13
13
|
import { setCredential } from './credential-store.js';
|
|
14
|
+
import { isAgentHost } from './environment.js';
|
|
15
|
+
/**
|
|
16
|
+
* Machine-readable authorization event, emitted alongside the human text when
|
|
17
|
+
* an agent host is driving the CLI. Delimited by markers (rather than a bare
|
|
18
|
+
* JSON line) because a skill's own stdout/stderr is interleaved on the same
|
|
19
|
+
* stream — the host greps for the block, never for prose that may be reworded.
|
|
20
|
+
*/
|
|
21
|
+
const AUTH_BLOCK_OPEN = '<<REMIXMATE_AUTH_REQUIRED';
|
|
22
|
+
const AUTH_BLOCK_CLOSE = 'REMIXMATE_AUTH_REQUIRED>>';
|
|
23
|
+
export function renderAuthBlock(apiBaseUrl, code) {
|
|
24
|
+
const payload = {
|
|
25
|
+
apiBaseUrl,
|
|
26
|
+
verificationUri: code.verificationUri,
|
|
27
|
+
verificationUriComplete: code.verificationUriComplete ?? code.verificationUri,
|
|
28
|
+
userCode: code.userCode,
|
|
29
|
+
expiresIn: code.expiresIn,
|
|
30
|
+
};
|
|
31
|
+
return `${AUTH_BLOCK_OPEN}\n${JSON.stringify(payload)}\n${AUTH_BLOCK_CLOSE}\n`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Emit the authorization prompt: a one-click link (device code pre-filled),
|
|
35
|
+
* the manual fallback, and — for agent hosts — the machine-readable block.
|
|
36
|
+
* Shared by `runDeviceFlow` and `login --start` so both entry points present
|
|
37
|
+
* authorization identically.
|
|
38
|
+
*/
|
|
39
|
+
export function writeAuthPrompt(write, apiBaseUrl, code) {
|
|
40
|
+
const openUrl = code.verificationUriComplete ?? code.verificationUri;
|
|
41
|
+
write(`\n🔐 需要授权 remixmate CLI,请在浏览器中打开:\n ${openUrl}\n`);
|
|
42
|
+
if (code.verificationUriComplete) {
|
|
43
|
+
write(`(若链接无法打开:访问 ${code.verificationUri} 并输入设备码 ${code.userCode})\n`);
|
|
44
|
+
}
|
|
45
|
+
write('\n');
|
|
46
|
+
if (isAgentHost())
|
|
47
|
+
write(renderAuthBlock(apiBaseUrl, code));
|
|
48
|
+
}
|
|
14
49
|
function nowSec() {
|
|
15
50
|
return Math.floor(Date.now() / 1000);
|
|
16
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Persist an approved PrivToken. Shared by `runDeviceFlow` and `login --wait`
|
|
54
|
+
* so a credential written by either is indistinguishable. The token value is
|
|
55
|
+
* never written to `write`.
|
|
56
|
+
*/
|
|
57
|
+
export async function persistApproval(apiBaseUrl, privToken, userLabel, write) {
|
|
58
|
+
const { usedKeychain, keychainError } = await setCredential(apiBaseUrl, {
|
|
59
|
+
privToken,
|
|
60
|
+
userLabel,
|
|
61
|
+
createdAt: nowSec(),
|
|
62
|
+
});
|
|
63
|
+
if (!usedKeychain && keychainError) {
|
|
64
|
+
write('(系统钥匙串不可用,凭证已写入受保护的本地文件 ~/.config/remixmate/credentials.json)\n');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
17
67
|
/**
|
|
18
68
|
* Run one full Device Flow against `apiBaseUrl`. On approval the PrivToken is
|
|
19
69
|
* persisted via setCredential (keychain or file, same as `login`) and only the
|
|
@@ -24,27 +74,25 @@ export async function runDeviceFlow(apiBaseUrl, opts = {}) {
|
|
|
24
74
|
const clientLabel = opts.clientLabel ?? `remixmate-cli (${process.platform} ${process.arch})`;
|
|
25
75
|
try {
|
|
26
76
|
const code = await requestDeviceCode(apiBaseUrl, clientLabel);
|
|
77
|
+
// The backend pre-fills the user code into verificationUriComplete, so the
|
|
78
|
+
// user only has to click and confirm. Lead with that link — asking someone
|
|
79
|
+
// to open a bare URL and transcribe a device code is a different (much
|
|
80
|
+
// higher) difficulty tier, and the whole point is that a non-technical user
|
|
81
|
+
// never has to touch a terminal.
|
|
27
82
|
const openUrl = code.verificationUriComplete ?? code.verificationUri;
|
|
28
|
-
// Always print the
|
|
29
|
-
//
|
|
30
|
-
write
|
|
83
|
+
// Always print the link, even when the browser auto-opens, so a host
|
|
84
|
+
// (Claude Code / Codex) can relay it into the chat.
|
|
85
|
+
writeAuthPrompt(write, apiBaseUrl, code);
|
|
31
86
|
if (openBrowser(openUrl)) {
|
|
32
87
|
write('已尝试自动打开浏览器…\n');
|
|
33
88
|
}
|
|
34
89
|
else {
|
|
35
|
-
write('
|
|
90
|
+
write('无法自动打开浏览器,请手动复制上面的链接。\n');
|
|
36
91
|
}
|
|
37
92
|
write('等待授权中(可在浏览器确认)…\n');
|
|
38
93
|
const result = await pollForToken(apiBaseUrl, code, { maxWaitMs: opts.maxWaitMs });
|
|
39
94
|
if (result.status === 'approved' && result.privToken) {
|
|
40
|
-
|
|
41
|
-
privToken: result.privToken,
|
|
42
|
-
userLabel: result.userLabel,
|
|
43
|
-
createdAt: nowSec(),
|
|
44
|
-
});
|
|
45
|
-
if (!usedKeychain && keychainError) {
|
|
46
|
-
write('(系统钥匙串不可用,凭证已写入受保护的本地文件 ~/.config/remixmate/credentials.json)\n');
|
|
47
|
-
}
|
|
95
|
+
await persistApproval(apiBaseUrl, result.privToken, result.userLabel, write);
|
|
48
96
|
return { status: 'approved', userLabel: result.userLabel };
|
|
49
97
|
}
|
|
50
98
|
if (result.status === 'access_denied')
|
package/dist/auth/device-flow.js
CHANGED
|
@@ -12,17 +12,34 @@ async function postJson(url, body, timeoutMs) {
|
|
|
12
12
|
const controller = new AbortController();
|
|
13
13
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
14
14
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
let resp;
|
|
16
|
+
try {
|
|
17
|
+
resp = await fetch(url, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
20
|
+
body: JSON.stringify(body),
|
|
21
|
+
signal: controller.signal,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
// A DNS/TCP/TLS failure or an abort rejects with a plain Error. Left
|
|
26
|
+
// unwrapped it escapes runDeviceFlow's DeviceFlowError handler and
|
|
27
|
+
// surfaces to the user as a raw stack trace.
|
|
28
|
+
const reason = err instanceof Error && err.name === 'AbortError'
|
|
29
|
+
? `请求超时(${Math.round(timeoutMs / 1000)}s)`
|
|
30
|
+
: err.message;
|
|
31
|
+
throw new DeviceFlowError(`无法连接授权服务 ${url}: ${reason}`);
|
|
32
|
+
}
|
|
21
33
|
const text = await resp.text();
|
|
22
34
|
if (!resp.ok) {
|
|
23
35
|
throw new DeviceFlowError(`HTTP ${resp.status}: ${text.slice(0, 200)}`);
|
|
24
36
|
}
|
|
25
|
-
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(text);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new DeviceFlowError(`授权服务返回了非 JSON 响应: ${text.slice(0, 200)}`);
|
|
42
|
+
}
|
|
26
43
|
}
|
|
27
44
|
finally {
|
|
28
45
|
clearTimeout(timer);
|