@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 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
- read -rs DINOX_TOKEN
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
- ```bash
132
- dino sync
133
- ```
124
+ 如果 App 提示二维码无效,先升级 Dinox App。这条命令最长等待 10 分钟。
134
125
 
135
- 这一步会把 Dinox 数据同步到本地 SQLite 数据库,后续很多查询都会直接使用本地缓存。
126
+ #### CI / 自动化
136
127
 
137
- ### 第 3 步:确认当前状态
128
+ 没有人可以扫码时,继续用 token。token 可在 App 的 **设置 → 同步设置 → API Token** 获取。
138
129
 
139
130
  ```bash
140
- dino auth status
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
- ### 第 4 步:搜索一条笔记试试
141
+ ### 第 2 步:搜索一条笔记试试
146
142
 
147
143
  ```bash
148
144
  dino note search "AI"
149
145
  ```
150
146
 
151
- ### 第 5 步:新建一条笔记试试
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
- 如果你能顺利完成上面 5 步,说明 CLI 已经可以正常使用。
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`:保存 token 并验证连接
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,并用真实握手确认能否启动。`--add` 把已确认的 agent 写入当前账号的 gateway 配置,并由 daemon 托管。`gateway list` 只显示环境变量名,不显示值。`dino gateway mcp` 由这些 agent 拉起,对外提供只读的 Dinox 知识工具。
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 sync`
1197
- 3. `dino note search`
1198
- 4. `dino note create`
1199
- 5. `dino todo search`
1200
- 6. `dino tag add` / `dino box add`
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
+ }