@dinoxx/dinox-cli 2.9.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -32
- package/dist/auth/devicePair.d.ts +62 -0
- package/dist/auth/devicePair.js +377 -0
- package/dist/commands/auth/index.js +90 -8
- package/dist/commands/gateway/index.js +16 -10
- package/dist/commands/graph/index.js +2 -0
- package/dist/commands/schema/renderSkills.js +2 -2
- package/dist/commands/update.d.ts +16 -0
- package/dist/commands/update.js +61 -11
- package/dist/config/types.d.ts +4 -0
- package/dist/daemon/ensure.js +7 -0
- package/dist/daemon/route.js +2 -0
- package/dist/gateway/config.d.ts +1 -5
- package/dist/gateway/config.js +23 -2
- package/dist/gateway/detect.d.ts +26 -3
- package/dist/gateway/detect.js +126 -13
- package/dist/utils/errorCodes.d.ts +12 -0
- package/dist/utils/errorCodes.js +3 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ winget install OpenJS.NodeJS.LTS
|
|
|
103
103
|
dino update
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
它会自动根据你当前的安装方式选择合适的包管理器执行更新。升级完成后会检查本机已安装的 Agent:有 `claude` 但没有 ACP 命令时安装 `@agentclientprotocol/claude-agent-acp`,有 `codex` 时安装 `@agentclientprotocol/codex-acp`,有 `zcode` 时安装 `zcode-acp`。已经装过的适配器会跳过;某个适配器安装失败只给出警告,不影响 CLI 升级本身。
|
|
107
107
|
|
|
108
108
|
---
|
|
109
109
|
|
|
@@ -113,42 +113,38 @@ dino update
|
|
|
113
113
|
|
|
114
114
|
### 第 1 步:登录
|
|
115
115
|
|
|
116
|
+
在自己的终端运行:
|
|
117
|
+
|
|
116
118
|
```bash
|
|
117
|
-
|
|
118
|
-
printf '%s' "$DINOX_TOKEN" | dino auth login --token-stdin
|
|
119
|
-
unset DINOX_TOKEN
|
|
119
|
+
dino auth login
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
- 第一行会在当前终端隐藏输入 token,避免 token 出现在命令参数和 shell 历史中
|
|
125
|
-
- `--token-stdin` 接受 token 本体或 `Bearer ...`
|
|
126
|
-
- 登录时 CLI 会顺便验证连接是否可用
|
|
127
|
-
- 自动化或 CI 可以改用 `DINOX_TOKEN` 环境变量注入 token;它优先于本地配置,并支持 token 本体或 `Bearer ...`
|
|
128
|
-
|
|
129
|
-
### 第 2 步:同步数据到本地
|
|
122
|
+
终端会显示二维码。用 Dinox App 打开 **设置 → 设备配对 → 扫一扫**,扫码确认即可。登录时会完成首次同步,之后的读取命令会自动启动本地 daemon,一般不用再手动执行 `dino sync` 或 `dino auth status`。
|
|
130
123
|
|
|
131
|
-
|
|
132
|
-
dino sync
|
|
133
|
-
```
|
|
124
|
+
如果 App 提示二维码无效,先升级 Dinox App。这条命令最长等待 10 分钟。
|
|
134
125
|
|
|
135
|
-
|
|
126
|
+
#### CI / 自动化
|
|
136
127
|
|
|
137
|
-
|
|
128
|
+
没有人可以扫码时,继续用 token。token 可在 App 的 **设置 → 同步设置 → API Token** 获取。
|
|
138
129
|
|
|
139
130
|
```bash
|
|
140
|
-
|
|
131
|
+
read -rs DINOX_TOKEN
|
|
132
|
+
printf '%s' "$DINOX_TOKEN" | dino auth login --token-stdin
|
|
133
|
+
unset DINOX_TOKEN
|
|
141
134
|
```
|
|
142
135
|
|
|
143
|
-
|
|
136
|
+
说明:
|
|
137
|
+
|
|
138
|
+
- `--token-stdin` 接受 token 本体或 `Bearer ...`
|
|
139
|
+
- 也可以只在当前进程注入 `DINOX_TOKEN`(优先于本地配置,支持 token 本体或 `Bearer ...`),不写入配置文件
|
|
144
140
|
|
|
145
|
-
### 第
|
|
141
|
+
### 第 2 步:搜索一条笔记试试
|
|
146
142
|
|
|
147
143
|
```bash
|
|
148
144
|
dino note search "AI"
|
|
149
145
|
```
|
|
150
146
|
|
|
151
|
-
### 第
|
|
147
|
+
### 第 3 步:新建一条笔记试试
|
|
152
148
|
|
|
153
149
|
```bash
|
|
154
150
|
dino note create \
|
|
@@ -157,7 +153,7 @@ dino note create \
|
|
|
157
153
|
--type note
|
|
158
154
|
```
|
|
159
155
|
|
|
160
|
-
如果你能顺利完成上面
|
|
156
|
+
如果你能顺利完成上面 3 步,说明 CLI 已经可以正常使用。
|
|
161
157
|
|
|
162
158
|
---
|
|
163
159
|
|
|
@@ -692,6 +688,7 @@ dino note search "AI" --offline --format json
|
|
|
692
688
|
### 6.2 `auth`:登录与状态
|
|
693
689
|
|
|
694
690
|
```bash
|
|
691
|
+
dino auth login
|
|
695
692
|
read -rs DINOX_TOKEN
|
|
696
693
|
printf '%s' "$DINOX_TOKEN" | dino auth login --token-stdin
|
|
697
694
|
unset DINOX_TOKEN
|
|
@@ -702,7 +699,8 @@ dino auth logout --clear-local-db
|
|
|
702
699
|
|
|
703
700
|
用途说明:
|
|
704
701
|
|
|
705
|
-
- `login
|
|
702
|
+
- `login`:不带 token 时在终端显示二维码,用 Dinox App「设置 → 设备配对 → 扫一扫」确认登录;也可以保存 token 并验证连接
|
|
703
|
+
- 登录会完成首次同步。CI / 自动化没有扫码条件时,用 `--token-stdin` 或 `DINOX_TOKEN`
|
|
706
704
|
- `status`:查看登录状态、本地库位置、同步状态
|
|
707
705
|
- `logout`:退出登录
|
|
708
706
|
- `logout --clear-local-db`:退出并删除本地 SQLite 缓存
|
|
@@ -949,7 +947,7 @@ dino gateway reload
|
|
|
949
947
|
dino gateway remove Hermes
|
|
950
948
|
```
|
|
951
949
|
|
|
952
|
-
`detect` 会在 `PATH` 上查找 ACP agent
|
|
950
|
+
`detect` 会在 `PATH` 上查找 ACP agent,并用真实握手确认能否启动。覆盖范围与 [Paseo 的 ACP catalog](https://github.com/getpaseo/paseo/blob/main/packages/app/src/data/acp-provider-catalog.ts) 对齐,只认本机已经装好的命令,不会为了探测去 `npx -y` 下载。每个 agent 一握手完就打印,不用等最慢的那个。某个启动方式一直不应答时,不再换参数重试。`--add` 把已确认的 agent 写入当前账号的 gateway 配置,并由 daemon 托管。`gateway list` 只显示环境变量名,不显示值。`dino gateway mcp` 由这些 agent 拉起,对外提供只读的 Dinox 知识工具。
|
|
953
951
|
|
|
954
952
|
### 6.16 `info`:查看版本与技能仓库地址
|
|
955
953
|
|
|
@@ -963,6 +961,8 @@ dino info
|
|
|
963
961
|
dino update
|
|
964
962
|
```
|
|
965
963
|
|
|
964
|
+
升级 CLI 之后,会为本机已有、但还没有 ACP 命令的 Agent 安装适配器(Claude Code、Codex、ZCode)。`--format json` 的 `acpAdapters` 返回每一项的 `installed` 或 `failed`。
|
|
965
|
+
|
|
966
966
|
---
|
|
967
967
|
|
|
968
968
|
## 7. AI / 脚本自动化
|
|
@@ -1008,13 +1008,12 @@ schema 包含命令路径、风险级别、参数、选项、输出字段和示
|
|
|
1008
1008
|
|
|
1009
1009
|
原因:你还没有完成登录,或当前 `DINOX_TOKEN` 环境变量覆盖了本地 token,CLI 需要先在线解析这个 token 对应的用户身份。
|
|
1010
1010
|
|
|
1011
|
-
|
|
1011
|
+
解决:在自己的终端运行 `dino auth login`,用 Dinox App 扫码。CI 或已有 token 时:
|
|
1012
1012
|
|
|
1013
1013
|
```bash
|
|
1014
1014
|
read -rs DINOX_TOKEN
|
|
1015
1015
|
printf '%s' "$DINOX_TOKEN" | dino auth login --token-stdin
|
|
1016
1016
|
unset DINOX_TOKEN
|
|
1017
|
-
dino sync
|
|
1018
1017
|
```
|
|
1019
1018
|
|
|
1020
1019
|
如果你使用环境变量登录,确认 `DINOX_TOKEN` 已设置后直接运行一次在线命令:
|
|
@@ -1193,11 +1192,10 @@ dino schema <path> --format json
|
|
|
1193
1192
|
如果你是第一次用,建议按下面顺序熟悉:
|
|
1194
1193
|
|
|
1195
1194
|
1. `dino auth login`
|
|
1196
|
-
2. `dino
|
|
1197
|
-
3. `dino note
|
|
1198
|
-
4. `dino
|
|
1199
|
-
5. `dino
|
|
1200
|
-
6. `dino
|
|
1201
|
-
7. `dino schema`
|
|
1195
|
+
2. `dino note search`
|
|
1196
|
+
3. `dino note create`
|
|
1197
|
+
4. `dino todo search`
|
|
1198
|
+
5. `dino tag add` / `dino box add`
|
|
1199
|
+
6. `dino schema`
|
|
1202
1200
|
|
|
1203
1201
|
这样会最快建立完整使用感。
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { DinoxConfig } from '../config/types.js';
|
|
2
|
+
export declare const CLI_DEVICE_TYPE = "cli";
|
|
3
|
+
export declare const DEVICE_PAIR_POLL_INTERVAL_MS = 2000;
|
|
4
|
+
export declare const DEVICE_PAIR_MAX_WAIT_MS: number;
|
|
5
|
+
export declare class DevicePairCancelled extends Error {
|
|
6
|
+
constructor();
|
|
7
|
+
}
|
|
8
|
+
export type DevicePairConfigStore = {
|
|
9
|
+
load: () => Promise<DinoxConfig>;
|
|
10
|
+
update: (update: (current: DinoxConfig) => DinoxConfig | Promise<DinoxConfig>) => Promise<DinoxConfig>;
|
|
11
|
+
};
|
|
12
|
+
type FetchLike = typeof fetch;
|
|
13
|
+
export declare function deviceNameFromHostname(hostname: string): string;
|
|
14
|
+
export declare function buildDevicePairQrUrl(ticket: string, deviceName: string): string;
|
|
15
|
+
/** Origin of the configured PowerSync token endpoint. Pairing routes live on that same host. */
|
|
16
|
+
export declare function authApiBaseFromTokenEndpoint(tokenEndpoint: string): string;
|
|
17
|
+
export declare function renderTerminalQr(url: string): Promise<string>;
|
|
18
|
+
export declare function delay(ms: number, signal?: AbortSignal): Promise<void>;
|
|
19
|
+
export declare function ensureCliDeviceId(store: DevicePairConfigStore, randomUUID: () => string): Promise<string>;
|
|
20
|
+
export declare function revokeDeviceBinding(input: {
|
|
21
|
+
apiBase: string;
|
|
22
|
+
deviceBindingId: string;
|
|
23
|
+
authorization: string;
|
|
24
|
+
fetchImpl?: FetchLike;
|
|
25
|
+
}): Promise<{
|
|
26
|
+
revoked: boolean;
|
|
27
|
+
warning?: string;
|
|
28
|
+
}>;
|
|
29
|
+
export declare function prepareCliDevice(input: {
|
|
30
|
+
store: DevicePairConfigStore;
|
|
31
|
+
apiBase: string;
|
|
32
|
+
authorization?: string;
|
|
33
|
+
deviceBindingId?: string;
|
|
34
|
+
fetchImpl?: FetchLike;
|
|
35
|
+
randomUUID: () => string;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
deviceId: string;
|
|
38
|
+
revokeWarning?: string;
|
|
39
|
+
}>;
|
|
40
|
+
export declare function saveDevicePairResult(input: {
|
|
41
|
+
store: DevicePairConfigStore;
|
|
42
|
+
deviceBindingId: string;
|
|
43
|
+
}): Promise<void>;
|
|
44
|
+
export type DevicePairLoginResult = {
|
|
45
|
+
token: string;
|
|
46
|
+
deviceBindingId?: string;
|
|
47
|
+
userNo?: string;
|
|
48
|
+
};
|
|
49
|
+
export declare function pollDevicePairLogin(input: {
|
|
50
|
+
apiBase: string;
|
|
51
|
+
deviceId: string;
|
|
52
|
+
deviceName: string;
|
|
53
|
+
fetchImpl?: FetchLike;
|
|
54
|
+
sleep?: (ms: number) => Promise<void>;
|
|
55
|
+
now?: () => number;
|
|
56
|
+
renderQr?: (url: string) => Promise<string>;
|
|
57
|
+
writeStderr?: (text: string) => void;
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
pollIntervalMs?: number;
|
|
60
|
+
maxWaitMs?: number;
|
|
61
|
+
}): Promise<DevicePairLoginResult>;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { toString as qrCodeToString } from 'qrcode';
|
|
2
|
+
import { DinoxError } from '../utils/errors.js';
|
|
3
|
+
import { toAuthorizationHeaderValue } from '../utils/authToken.js';
|
|
4
|
+
export const CLI_DEVICE_TYPE = 'cli';
|
|
5
|
+
export const DEVICE_PAIR_POLL_INTERVAL_MS = 2_000;
|
|
6
|
+
export const DEVICE_PAIR_MAX_WAIT_MS = 10 * 60 * 1000;
|
|
7
|
+
const DEVICE_PAIR_REQUEST_TIMEOUT_MS = 15_000;
|
|
8
|
+
const AUTH_CODE_OK = '000000';
|
|
9
|
+
const AUTH_CODE_INVALID = '000001';
|
|
10
|
+
const AUTH_CODE_EXPIRED = '000002';
|
|
11
|
+
const AUTH_CODE_PENDING = '000003';
|
|
12
|
+
const AUTH_CODE_BOUND_ELSEWHERE = '000004';
|
|
13
|
+
const TOKEN_LOGIN_HINT = 'printf \'%s\' "$TOKEN" | dino auth login --token-stdin';
|
|
14
|
+
export class DevicePairCancelled extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super('Device pairing cancelled');
|
|
17
|
+
this.name = 'DevicePairCancelled';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function deviceNameFromHostname(hostname) {
|
|
21
|
+
const stripped = hostname.replace(/\.local$/i, '').trim();
|
|
22
|
+
return stripped || 'Dinox CLI';
|
|
23
|
+
}
|
|
24
|
+
export function buildDevicePairQrUrl(ticket, deviceName) {
|
|
25
|
+
return `dinoxapp://device-pair?t=${encodeURIComponent(ticket)}&dt=${CLI_DEVICE_TYPE}&dn=${encodeURIComponent(deviceName)}`;
|
|
26
|
+
}
|
|
27
|
+
/** Origin of the configured PowerSync token endpoint. Pairing routes live on that same host. */
|
|
28
|
+
export function authApiBaseFromTokenEndpoint(tokenEndpoint) {
|
|
29
|
+
try {
|
|
30
|
+
return new URL(tokenEndpoint).origin;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
throw new DinoxError(`Invalid token endpoint: ${tokenEndpoint}`, {
|
|
34
|
+
code: 'COMMAND_FAILED',
|
|
35
|
+
cause: error,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function renderTerminalQr(url) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
qrCodeToString(url, { type: 'terminal', small: true }, (error, qr) => {
|
|
42
|
+
if (error)
|
|
43
|
+
reject(error);
|
|
44
|
+
else
|
|
45
|
+
resolve(qr);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function delay(ms, signal) {
|
|
50
|
+
if (signal?.aborted)
|
|
51
|
+
return Promise.reject(new DevicePairCancelled());
|
|
52
|
+
if (ms <= 0)
|
|
53
|
+
return Promise.resolve();
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const timer = setTimeout(finish, ms);
|
|
56
|
+
const onAbort = () => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
reject(new DevicePairCancelled());
|
|
59
|
+
};
|
|
60
|
+
function finish() {
|
|
61
|
+
signal?.removeEventListener('abort', onAbort);
|
|
62
|
+
resolve();
|
|
63
|
+
}
|
|
64
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export async function ensureCliDeviceId(store, randomUUID) {
|
|
68
|
+
const current = await store.load();
|
|
69
|
+
const existing = current.auth?.deviceId?.trim();
|
|
70
|
+
if (existing?.startsWith(`${CLI_DEVICE_TYPE}-`))
|
|
71
|
+
return existing;
|
|
72
|
+
const deviceId = `${CLI_DEVICE_TYPE}-${randomUUID()}`;
|
|
73
|
+
await store.update((config) => ({
|
|
74
|
+
...config,
|
|
75
|
+
auth: {
|
|
76
|
+
...(config.auth ?? {}),
|
|
77
|
+
deviceId,
|
|
78
|
+
},
|
|
79
|
+
}));
|
|
80
|
+
return deviceId;
|
|
81
|
+
}
|
|
82
|
+
export async function revokeDeviceBinding(input) {
|
|
83
|
+
const endpoint = `${input.apiBase}/auth/device/revoke`;
|
|
84
|
+
try {
|
|
85
|
+
const result = await postDeviceApi({
|
|
86
|
+
fetchImpl: input.fetchImpl ?? fetch,
|
|
87
|
+
url: endpoint,
|
|
88
|
+
body: { id: input.deviceBindingId },
|
|
89
|
+
authorization: input.authorization,
|
|
90
|
+
});
|
|
91
|
+
if (!result.ok) {
|
|
92
|
+
return { revoked: false, warning: revokeWarning(input.deviceBindingId, errorText(result.error)) };
|
|
93
|
+
}
|
|
94
|
+
if (result.code !== AUTH_CODE_OK) {
|
|
95
|
+
return {
|
|
96
|
+
revoked: false,
|
|
97
|
+
warning: revokeWarning(input.deviceBindingId, `${result.code} ${result.msg}`.trim()),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { revoked: true };
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error instanceof DevicePairCancelled)
|
|
104
|
+
throw error;
|
|
105
|
+
return { revoked: false, warning: revokeWarning(input.deviceBindingId, errorText(error)) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function revokeWarning(deviceBindingId, detail) {
|
|
109
|
+
return `warning: could not revoke paired device ${deviceBindingId} (${detail}). Continuing.`;
|
|
110
|
+
}
|
|
111
|
+
export async function prepareCliDevice(input) {
|
|
112
|
+
let revokeWarning;
|
|
113
|
+
let bindingReleased = true;
|
|
114
|
+
const bindingId = input.deviceBindingId?.trim();
|
|
115
|
+
if (bindingId) {
|
|
116
|
+
bindingReleased = false;
|
|
117
|
+
if (!input.authorization) {
|
|
118
|
+
revokeWarning = `warning: could not revoke paired device ${bindingId} (no saved token). Continuing.`;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const revoked = await revokeDeviceBinding({
|
|
122
|
+
apiBase: input.apiBase,
|
|
123
|
+
deviceBindingId: bindingId,
|
|
124
|
+
authorization: input.authorization,
|
|
125
|
+
fetchImpl: input.fetchImpl,
|
|
126
|
+
});
|
|
127
|
+
if (revoked.revoked) {
|
|
128
|
+
bindingReleased = true;
|
|
129
|
+
await input.store.update((config) => ({
|
|
130
|
+
...config,
|
|
131
|
+
auth: {
|
|
132
|
+
...(config.auth ?? {}),
|
|
133
|
+
deviceBindingId: undefined,
|
|
134
|
+
},
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
revokeWarning = revoked.warning;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// The server refuses a device id still actively bound to another account
|
|
143
|
+
// (000004, shown on the phone, never to the polling CLI). A released binding
|
|
144
|
+
// lets any account reuse the id; one that could not be released gets a fresh
|
|
145
|
+
// id before pairing, so switching accounts cannot hit that refusal.
|
|
146
|
+
if (!bindingReleased) {
|
|
147
|
+
const deviceId = `${CLI_DEVICE_TYPE}-${input.randomUUID()}`;
|
|
148
|
+
await input.store.update((config) => ({
|
|
149
|
+
...config,
|
|
150
|
+
auth: { ...(config.auth ?? {}), deviceId, deviceBindingId: undefined },
|
|
151
|
+
}));
|
|
152
|
+
return { deviceId, revokeWarning };
|
|
153
|
+
}
|
|
154
|
+
const deviceId = await ensureCliDeviceId(input.store, input.randomUUID);
|
|
155
|
+
return { deviceId, revokeWarning };
|
|
156
|
+
}
|
|
157
|
+
export async function saveDevicePairResult(input) {
|
|
158
|
+
await input.store.update((config) => ({
|
|
159
|
+
...config,
|
|
160
|
+
auth: { ...(config.auth ?? {}), deviceBindingId: input.deviceBindingId },
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
export async function pollDevicePairLogin(input) {
|
|
164
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
165
|
+
const sleep = input.sleep ?? ((ms) => delay(ms, input.signal));
|
|
166
|
+
const now = input.now ?? (() => Date.now());
|
|
167
|
+
const renderQr = input.renderQr ?? renderTerminalQr;
|
|
168
|
+
const writeStderr = input.writeStderr ?? ((text) => { process.stderr.write(text); });
|
|
169
|
+
const pollIntervalMs = input.pollIntervalMs ?? DEVICE_PAIR_POLL_INTERVAL_MS;
|
|
170
|
+
const deadline = now() + (input.maxWaitMs ?? DEVICE_PAIR_MAX_WAIT_MS);
|
|
171
|
+
// Expiry is the server's call (000002): its `expireAt` has no time zone, so a
|
|
172
|
+
// local comparison would misjudge it on any machine in another zone.
|
|
173
|
+
let ticket = null;
|
|
174
|
+
let shownTicket = null;
|
|
175
|
+
const waitForNextPoll = async () => {
|
|
176
|
+
throwIfCancelled(input.signal);
|
|
177
|
+
const remaining = deadline - now();
|
|
178
|
+
if (remaining <= 0)
|
|
179
|
+
return;
|
|
180
|
+
await sleep(Math.min(pollIntervalMs, remaining));
|
|
181
|
+
throwIfCancelled(input.signal);
|
|
182
|
+
};
|
|
183
|
+
const issueTicket = async () => {
|
|
184
|
+
const generated = await postDeviceApi({
|
|
185
|
+
fetchImpl,
|
|
186
|
+
url: `${input.apiBase}/auth/device/pair/qr/generate`,
|
|
187
|
+
body: { deviceId: input.deviceId },
|
|
188
|
+
signal: input.signal,
|
|
189
|
+
});
|
|
190
|
+
if (!generated.ok)
|
|
191
|
+
return false;
|
|
192
|
+
if (generated.code !== AUTH_CODE_OK) {
|
|
193
|
+
throw pairingBusinessError(generated.code, generated.msg);
|
|
194
|
+
}
|
|
195
|
+
const data = asRecord(generated.data);
|
|
196
|
+
const nextTicket = readString(data?.ticket);
|
|
197
|
+
if (!nextTicket) {
|
|
198
|
+
throw new DinoxError('Device pairing ticket response did not include a ticket.', {
|
|
199
|
+
code: 'AUTH_PAIRING_FAILED',
|
|
200
|
+
details: { backendCode: generated.code, backendMessage: generated.msg },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
ticket = nextTicket;
|
|
204
|
+
// The server hands back the same ticket while it is still valid; reprint only a new one.
|
|
205
|
+
if (nextTicket !== shownTicket) {
|
|
206
|
+
shownTicket = nextTicket;
|
|
207
|
+
await showPairingQr({ ticket: nextTicket, deviceName: input.deviceName, renderQr, writeStderr });
|
|
208
|
+
}
|
|
209
|
+
return true;
|
|
210
|
+
};
|
|
211
|
+
while (now() < deadline) {
|
|
212
|
+
throwIfCancelled(input.signal);
|
|
213
|
+
if (!ticket) {
|
|
214
|
+
try {
|
|
215
|
+
const issued = await issueTicket();
|
|
216
|
+
if (!issued) {
|
|
217
|
+
await waitForNextPoll();
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (error instanceof DinoxError || error instanceof DevicePairCancelled)
|
|
223
|
+
throw error;
|
|
224
|
+
await waitForNextPoll();
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!ticket)
|
|
229
|
+
continue;
|
|
230
|
+
let polled;
|
|
231
|
+
try {
|
|
232
|
+
polled = await postDeviceApi({
|
|
233
|
+
fetchImpl,
|
|
234
|
+
url: `${input.apiBase}/auth/device/pair/qr/token`,
|
|
235
|
+
body: {
|
|
236
|
+
deviceId: input.deviceId,
|
|
237
|
+
ticket,
|
|
238
|
+
deviceType: CLI_DEVICE_TYPE,
|
|
239
|
+
deviceName: input.deviceName,
|
|
240
|
+
},
|
|
241
|
+
signal: input.signal,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error instanceof DevicePairCancelled)
|
|
246
|
+
throw error;
|
|
247
|
+
await waitForNextPoll();
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (!polled.ok) {
|
|
251
|
+
await waitForNextPoll();
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (polled.code === AUTH_CODE_OK) {
|
|
255
|
+
return readPairToken(polled.data, polled.code, polled.msg);
|
|
256
|
+
}
|
|
257
|
+
if (polled.code === AUTH_CODE_PENDING) {
|
|
258
|
+
await waitForNextPoll();
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (polled.code === AUTH_CODE_EXPIRED) {
|
|
262
|
+
ticket = null;
|
|
263
|
+
await waitForNextPoll();
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
throw pairingBusinessError(polled.code, polled.msg);
|
|
267
|
+
}
|
|
268
|
+
throw new DinoxError('Timed out waiting for the Dinox app to approve this device.', {
|
|
269
|
+
code: 'AUTH_PAIRING_TIMEOUT',
|
|
270
|
+
suggestion: 'Run `dino auth login` again and approve the QR code within 10 minutes.',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
async function showPairingQr(input) {
|
|
274
|
+
const url = buildDevicePairQrUrl(input.ticket, input.deviceName);
|
|
275
|
+
const rendered = await input.renderQr(url);
|
|
276
|
+
input.writeStderr(rendered.endsWith('\n') ? rendered : `${rendered}\n`);
|
|
277
|
+
input.writeStderr('用 Dinox App 扫码:设置 → 设备配对 → 扫一扫\n');
|
|
278
|
+
input.writeStderr(`${url}\n`);
|
|
279
|
+
input.writeStderr(`App 提示二维码无效?请先升级 Dinox App,或改用 token 登录:\`${TOKEN_LOGIN_HINT}\`\n`);
|
|
280
|
+
}
|
|
281
|
+
function pairingBusinessError(code, msg) {
|
|
282
|
+
if (code === AUTH_CODE_INVALID && isUnsupportedDeviceType(msg)) {
|
|
283
|
+
return new DinoxError('This server does not support CLI device pairing yet (deviceType is not allowed).', {
|
|
284
|
+
code: 'AUTH_PAIRING_UNSUPPORTED',
|
|
285
|
+
details: { backendCode: code, backendMessage: msg },
|
|
286
|
+
suggestion: 'The Dinox server does not accept CLI QR login yet. Log in with a token via `dino auth login --token-stdin` for now.',
|
|
287
|
+
suggestedAction: {
|
|
288
|
+
command: 'dino auth login --token-stdin',
|
|
289
|
+
reason: 'The server rejected deviceType cli. Use token login until CLI pairing is enabled.',
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const boundElsewhere = code === AUTH_CODE_BOUND_ELSEWHERE;
|
|
294
|
+
return new DinoxError(`Device pairing failed (${code}): ${msg || 'unknown error'}`, {
|
|
295
|
+
code: 'AUTH_PAIRING_FAILED',
|
|
296
|
+
details: { backendCode: code, backendMessage: msg },
|
|
297
|
+
suggestion: boundElsewhere
|
|
298
|
+
? 'Remove this computer from the original account in the Dinox app under 我的设备, then run `dino auth login` again.'
|
|
299
|
+
: 'Retry `dino auth login`, or log in with a token via `dino auth login --token-stdin`.',
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
function isUnsupportedDeviceType(msg) {
|
|
303
|
+
return /deviceType/i.test(msg) && /不合法|invalid|illegal|unsupported/i.test(msg);
|
|
304
|
+
}
|
|
305
|
+
function readPairToken(data, code, msg) {
|
|
306
|
+
const record = asRecord(data);
|
|
307
|
+
const token = readString(record?.token);
|
|
308
|
+
if (!token) {
|
|
309
|
+
throw new DinoxError('Device pairing succeeded but did not return a token.', {
|
|
310
|
+
code: 'AUTH_PAIRING_FAILED',
|
|
311
|
+
details: { backendCode: code, backendMessage: msg },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
token,
|
|
316
|
+
deviceBindingId: readString(record?.deviceBindingId),
|
|
317
|
+
userNo: readString(record?.userNo),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
async function postDeviceApi(input) {
|
|
321
|
+
throwIfCancelled(input.signal);
|
|
322
|
+
const timeout = AbortSignal.timeout(DEVICE_PAIR_REQUEST_TIMEOUT_MS);
|
|
323
|
+
const signal = input.signal ? AbortSignal.any([input.signal, timeout]) : timeout;
|
|
324
|
+
try {
|
|
325
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
326
|
+
if (input.authorization)
|
|
327
|
+
headers.Authorization = toAuthorizationHeaderValue(input.authorization);
|
|
328
|
+
const response = await input.fetchImpl(input.url, {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers,
|
|
331
|
+
body: JSON.stringify(input.body),
|
|
332
|
+
signal,
|
|
333
|
+
});
|
|
334
|
+
throwIfCancelled(input.signal);
|
|
335
|
+
const text = await response.text();
|
|
336
|
+
let payload;
|
|
337
|
+
try {
|
|
338
|
+
payload = text ? JSON.parse(text) : null;
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
return { ok: false, error };
|
|
342
|
+
}
|
|
343
|
+
const record = asRecord(payload);
|
|
344
|
+
if (!record || record.code === undefined || record.code === null) {
|
|
345
|
+
return { ok: false, error: new Error(response.ok ? 'missing response code' : `HTTP ${response.status}`) };
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
code: String(record.code),
|
|
350
|
+
msg: typeof record.msg === 'string' ? record.msg : '',
|
|
351
|
+
data: record.data,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
if (input.signal?.aborted || error instanceof DevicePairCancelled)
|
|
356
|
+
throw new DevicePairCancelled();
|
|
357
|
+
return { ok: false, error };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function throwIfCancelled(signal) {
|
|
361
|
+
if (signal?.aborted)
|
|
362
|
+
throw new DevicePairCancelled();
|
|
363
|
+
}
|
|
364
|
+
function asRecord(value) {
|
|
365
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
366
|
+
return null;
|
|
367
|
+
return value;
|
|
368
|
+
}
|
|
369
|
+
function readString(value) {
|
|
370
|
+
if (typeof value !== 'string')
|
|
371
|
+
return undefined;
|
|
372
|
+
const trimmed = value.trim();
|
|
373
|
+
return trimmed || undefined;
|
|
374
|
+
}
|
|
375
|
+
function errorText(error) {
|
|
376
|
+
return error instanceof Error ? error.message : String(error);
|
|
377
|
+
}
|