@dinoxx/dinox-cli 2.9.0 → 2.10.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
@@ -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 ...`
122
+ 终端会显示二维码。用 Dinox App 打开 **设置 → 设备配对 → 扫一扫**,扫码确认即可。登录时会完成首次同步,之后的读取命令会自动启动本地 daemon,一般不用再手动执行 `dino sync` 或 `dino auth status`。
128
123
 
129
- ### 第 2 步:同步数据到本地
124
+ 如果 App 提示二维码无效,先升级 Dinox App。这条命令最长等待 10 分钟。
130
125
 
131
- ```bash
132
- dino sync
133
- ```
126
+ #### CI / 自动化
134
127
 
135
- 这一步会把 Dinox 数据同步到本地 SQLite 数据库,后续很多查询都会直接使用本地缓存。
136
-
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 缓存
@@ -1008,13 +1006,12 @@ schema 包含命令路径、风险级别、参数、选项、输出字段和示
1008
1006
 
1009
1007
  原因:你还没有完成登录,或当前 `DINOX_TOKEN` 环境变量覆盖了本地 token,CLI 需要先在线解析这个 token 对应的用户身份。
1010
1008
 
1011
- 解决:
1009
+ 解决:在自己的终端运行 `dino auth login`,用 Dinox App 扫码。CI 或已有 token 时:
1012
1010
 
1013
1011
  ```bash
1014
1012
  read -rs DINOX_TOKEN
1015
1013
  printf '%s' "$DINOX_TOKEN" | dino auth login --token-stdin
1016
1014
  unset DINOX_TOKEN
1017
- dino sync
1018
1015
  ```
1019
1016
 
1020
1017
  如果你使用环境变量登录,确认 `DINOX_TOKEN` 已设置后直接运行一次在线命令:
@@ -1193,11 +1190,10 @@ dino schema <path> --format json
1193
1190
  如果你是第一次用,建议按下面顺序熟悉:
1194
1191
 
1195
1192
  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`
1193
+ 2. `dino note search`
1194
+ 3. `dino note create`
1195
+ 4. `dino todo search`
1196
+ 5. `dino tag add` / `dino box add`
1197
+ 6. `dino schema`
1202
1198
 
1203
1199
  这样会最快建立完整使用感。
@@ -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
+ }
@@ -1,4 +1,7 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import { authApiBaseFromTokenEndpoint, DevicePairCancelled, deviceNameFromHostname, pollDevicePairLogin, prepareCliDevice, renderTerminalQr, revokeDeviceBinding, saveDevicePairResult, } from '../../auth/devicePair.js';
2
5
  import { buildPendingAuthConfig, fetchUserInfoWithAuthorization, hydrateResolvedAuthIdentity, mergeAuthIdentityIntoConfig, persistedAuthStateMatches, rotatePersistedAuthStateRevision, } from '../../auth/userInfo.js';
3
6
  import { getLegacyPowerSyncDbPath, getPowerSyncDbPath } from '../../config/paths.js';
4
7
  import { loadConfig, updateConfig } from '../../config/store.js';
@@ -227,6 +230,45 @@ export async function performAuthLogin(rawToken, syncTimeout, overrides = {}) {
227
230
  }
228
231
  return outcome.value;
229
232
  }
233
+ async function loginWithDeviceQr() {
234
+ const config = await loadConfig();
235
+ const resolved = resolveConfig(config);
236
+ const apiBase = authApiBaseFromTokenEndpoint(resolved.powersync.tokenEndpoint);
237
+ const store = { load: loadConfig, update: updateConfig };
238
+ const prepared = await prepareCliDevice({
239
+ store,
240
+ apiBase,
241
+ authorization: normalizeAuthToken(config.auth?.authorization),
242
+ deviceBindingId: config.auth?.deviceBindingId,
243
+ randomUUID: crypto.randomUUID,
244
+ });
245
+ if (prepared.revokeWarning)
246
+ printHumanReadableWarning(prepared.revokeWarning);
247
+ const abort = new AbortController();
248
+ const onSigint = () => abort.abort();
249
+ process.once('SIGINT', onSigint);
250
+ try {
251
+ const paired = await pollDevicePairLogin({
252
+ apiBase,
253
+ deviceId: prepared.deviceId,
254
+ deviceName: deviceNameFromHostname(os.hostname()),
255
+ renderQr: renderTerminalQr,
256
+ writeStderr: (text) => { process.stderr.write(text); },
257
+ signal: abort.signal,
258
+ });
259
+ return { token: paired.token, deviceBindingId: paired.deviceBindingId };
260
+ }
261
+ catch (error) {
262
+ if (error instanceof DevicePairCancelled) {
263
+ process.exitCode = 130;
264
+ process.exit(130);
265
+ }
266
+ throw error;
267
+ }
268
+ finally {
269
+ process.off('SIGINT', onSigint);
270
+ }
271
+ }
230
272
  async function runAuthLogin(context) {
231
273
  const options = context.options;
232
274
  if (context.args.token && options.tokenStdin) {
@@ -234,17 +276,35 @@ async function runAuthLogin(context) {
234
276
  code: 'USAGE_ERROR',
235
277
  });
236
278
  }
237
- const token = options.tokenStdin ? await readAuthTokenFromStdin() : context.args.token;
238
- const rawToken = normalizeAuthToken(token);
239
- if (!rawToken) {
240
- throw new DinoxError('Token is required');
241
- }
279
+ const explicitToken = options.tokenStdin ? await readAuthTokenFromStdin() : context.args.token;
280
+ let rawToken = normalizeAuthToken(explicitToken);
281
+ let pairedDeviceBindingId;
242
282
  const envToken = readEnvAuthToken();
283
+ if (options.tokenStdin || context.args.token) {
284
+ if (!rawToken) {
285
+ throw new DinoxError('Token is required');
286
+ }
287
+ }
288
+ else {
289
+ // A scanned login can never equal DINOX_TOKEN, so refuse before the phone binds this computer.
290
+ if (envToken) {
291
+ throw new DinoxError('DINOX_TOKEN is set, so a QR login would be overridden by it. Unset DINOX_TOKEN before running `dino auth login`, or log in with that token via `--token-stdin`.', { code: 'USAGE_ERROR' });
292
+ }
293
+ const paired = await loginWithDeviceQr();
294
+ rawToken = paired.token;
295
+ pairedDeviceBindingId = paired.deviceBindingId;
296
+ }
243
297
  if (envToken && envToken !== rawToken) {
244
298
  throw new DinoxError('DINOX_TOKEN is set and differs from the login token. Unset DINOX_TOKEN before running `dino auth login`, or use the same token.');
245
299
  }
246
300
  const structuredOutput = getRequestedStructuredOutputFormat() !== null;
247
301
  const result = await performAuthLogin(rawToken, context.globals.syncTimeout);
302
+ if (pairedDeviceBindingId) {
303
+ await saveDevicePairResult({
304
+ store: { load: loadConfig, update: updateConfig },
305
+ deviceBindingId: pairedDeviceBindingId,
306
+ });
307
+ }
248
308
  // After the login lock is released: the service starts the daemon under the new login.
249
309
  const daemonService = await autoInstallDaemonService();
250
310
  if (structuredOutput) {
@@ -269,6 +329,22 @@ async function runAuthLogoutLocked(options) {
269
329
  const config = await loadConfig();
270
330
  const resolved = resolveConfig(config);
271
331
  const envTokenActive = Boolean(readEnvAuthToken());
332
+ const savedToken = normalizeAuthToken(config.auth?.authorization);
333
+ const deviceBindingId = config.auth?.deviceBindingId?.trim();
334
+ if (deviceBindingId) {
335
+ if (!savedToken) {
336
+ printHumanReadableWarning(`warning: could not revoke paired device ${deviceBindingId} (no saved token). Continuing.`);
337
+ }
338
+ else {
339
+ const revoked = await revokeDeviceBinding({
340
+ apiBase: authApiBaseFromTokenEndpoint(resolved.powersync.tokenEndpoint),
341
+ deviceBindingId,
342
+ authorization: savedToken,
343
+ });
344
+ if (revoked.warning)
345
+ printHumanReadableWarning(revoked.warning);
346
+ }
347
+ }
272
348
  const daemonInfo = await getDaemonInfo();
273
349
  let initiallyResolvedUserId = resolved.auth.userId;
274
350
  if (!initiallyResolvedUserId
@@ -340,6 +416,8 @@ async function runAuthLogoutLocked(options) {
340
416
  username: undefined,
341
417
  email: undefined,
342
418
  userInfoSyncedAt: undefined,
419
+ deviceBindingId: undefined,
420
+ deviceId: undefined,
343
421
  },
344
422
  });
345
423
  }, { timeoutMs: resolved.sync.timeoutMs });
@@ -489,8 +567,8 @@ export const authCommandGroup = {
489
567
  commands: [
490
568
  {
491
569
  name: 'login',
492
- description: 'Save login token and verify PowerSync connectivity',
493
- arguments: [{ syntax: '[token]', description: 'Login token (omit when using --token-stdin)' }],
570
+ description: 'Log in by scanning a QR code, or save a token and verify PowerSync connectivity',
571
+ arguments: [{ syntax: '[token]', description: 'Login token (omit to scan a QR code, or when using --token-stdin)' }],
494
572
  options: [{ flags: '--token-stdin', description: 'Read the login token from piped stdin instead of argv', type: 'boolean' }],
495
573
  schema: {
496
574
  path: 'auth.login',
@@ -502,7 +580,11 @@ export const authCommandGroup = {
502
580
  summary: 'Login verification result',
503
581
  fields: ['verified', 'endpoint', 'dbPath', 'stale', 'lastSyncedAt', 'userId', 'daemonService'],
504
582
  },
505
- examples: ['dino auth login --token-stdin --format json'],
583
+ examples: [
584
+ 'dino auth login',
585
+ 'dino auth login --format json',
586
+ 'dino auth login --token-stdin --format json',
587
+ ],
506
588
  },
507
589
  action: runAuthLogin,
508
590
  },
@@ -48,6 +48,8 @@ async function runGraphCommand(context, onlineAction, offlineAction) {
48
48
  });
49
49
  }
50
50
  catch (error) {
51
+ if (error instanceof DinoxError && error.code === 'AUTH_REQUIRED')
52
+ throw error;
51
53
  const message = error instanceof Error ? error.message : String(error);
52
54
  throw new DinoxError(`Daemon execution failed: ${message}`, {
53
55
  code: 'DAEMON_UNAVAILABLE',
@@ -242,7 +242,7 @@ export function renderBundledSkillReference() {
242
242
  lines.push('```');
243
243
  lines.push('');
244
244
  if (section.title === 'Auth') {
245
- lines.push('Agent safety: the positional login-token form is legacy compatibility and is intentionally omitted from this agent-facing signature. Agent workflows must never place a Dinox authentication token in argv; use `--token-stdin` from the user\'s own shell or secret store.');
245
+ lines.push('Agent safety: the positional login-token form is legacy compatibility and is intentionally omitted from this agent-facing signature. Agent workflows must never place a Dinox authentication token in argv. Prefer `dino auth login` in the user\'s terminal (QR code; it blocks for up to 10 minutes; in Claude Code the user can type `! dino auth login`). Use `--token-stdin` only from the user\'s own shell or secret store.');
246
246
  lines.push('');
247
247
  }
248
248
  }
@@ -287,7 +287,7 @@ export function renderPortableRuntimeContract() {
287
287
  '- On a nonzero exit, parse structured stderr. Use top-level `code`, `recoverable`, `exit_code`, and `suggested_action`; inspect `error` for details. `dino schema errors --format json` lists every code and its exit status.',
288
288
  '- A direct user request for an exact write authorizes that write when the dry-run matches. Ask again before delete, overwrite, merge, bulk mutation, local-cache removal, repair, global CLI update, or whenever targets or impact are ambiguous.',
289
289
  '- A dry-run does not apply the planned mutation, but an online PowerSync runtime may flush older queued writes. Use `--offline` only when the user accepts stale local-cache semantics.',
290
- '- Do not ask for Dinox authentication credentials in chat or place a Dinox authentication token in argv. The `dino` child process must inherit `DINOX_TOKEN` or receive it from the host secret store at execution time; if the host cannot inject it safely, have the user perform persistent `--token-stdin` login in their own terminal.',
290
+ '- Do not ask for Dinox authentication credentials in chat or place a Dinox authentication token in argv. For persistent login, have the user run `dino auth login` in their own terminal and scan the QR code with the Dinox app. In Claude Code they can type `! dino auth login`. That command blocks for up to 10 minutes; say so before an agent runs it. Inherited `DINOX_TOKEN` and `dino auth login --token-stdin` remain for CI and secret stores.',
291
291
  '- Execute `dino` in an environment that can access the intended CLI binary, Dinox config/cache, and injected secret. Host-turn secret injection does not automatically cross into a sandbox or remote execution backend; never copy a secret through prompts to bridge that boundary.',
292
292
  '- If the host times out before receiving a structured envelope, treat the outcome as unknown. Verify freshness or the write postcondition before retrying.',
293
293
  '- Before claiming complete, current, all, none, latest, or an exact count, verify the command\'s freshness field is false (usually `data.stale`; note search uses `data.meta.stale`), inspect `_notice`, and check pagination or truncation fields returned by the command.',
@@ -13,6 +13,10 @@ export type DinoxConfig = {
13
13
  userInfoSyncedAt?: string;
14
14
  /** Internal CAS token changed by every persisted login/logout transition. */
15
15
  stateRevision?: string;
16
+ /** Stable id for QR login (`cli-` + uuid). Reused for the same account on this machine. */
17
+ deviceId?: string;
18
+ /** `c_user_device.id` from the last successful QR login. */
19
+ deviceBindingId?: string;
16
20
  };
17
21
  powersync?: {
18
22
  /** PowerSync sync service endpoint (downloads/subscriptions). */
@@ -31,6 +31,13 @@ function matchesRuntimeTokenBinding(info, version, config) {
31
31
  async function ensureDaemonRunningUnlocked() {
32
32
  const expectedVersion = getPackageVersion();
33
33
  const baseConfig = resolveConfig(await loadConfig());
34
+ // A daemon cannot serve anything without a login; say so now instead of
35
+ // waiting for a launch that cannot become ready.
36
+ if (!baseConfig.auth.authorization) {
37
+ throw new DinoxError('Not logged in. Set DINOX_TOKEN or pipe a token to `dino auth login --token-stdin`.', {
38
+ code: 'AUTH_REQUIRED',
39
+ });
40
+ }
34
41
  const info = await getDaemonInfo();
35
42
  if (info && matchesRuntimeTokenBinding(info, expectedVersion, baseConfig)) {
36
43
  return;
@@ -25,6 +25,8 @@ export async function routeRead(globals, daemonMethod, daemonParams, localRun) {
25
25
  return (await sendDaemonRequest(daemonMethod, daemonParams));
26
26
  }
27
27
  catch (error) {
28
+ if (error instanceof DinoxError && error.code === 'AUTH_REQUIRED')
29
+ throw error;
28
30
  const message = error instanceof Error ? error.message : String(error);
29
31
  throw new DinoxError(`Daemon execution failed: ${message}`, {
30
32
  code: 'DAEMON_UNAVAILABLE',
@@ -11,6 +11,18 @@ export declare const ERROR_CODES: {
11
11
  readonly exitCode: 3;
12
12
  readonly description: "The configured credentials were rejected.";
13
13
  };
14
+ readonly AUTH_PAIRING_FAILED: {
15
+ readonly exitCode: 3;
16
+ readonly description: "Device QR login failed because the server rejected the pairing request.";
17
+ };
18
+ readonly AUTH_PAIRING_TIMEOUT: {
19
+ readonly exitCode: 3;
20
+ readonly description: "Device QR login timed out before the phone approved this computer.";
21
+ };
22
+ readonly AUTH_PAIRING_UNSUPPORTED: {
23
+ readonly exitCode: 3;
24
+ readonly description: "This server does not accept the CLI device type for QR login yet.";
25
+ };
14
26
  readonly AUTH_REQUIRED: {
15
27
  readonly exitCode: 3;
16
28
  readonly description: "The command needs an authenticated Dinox account.";
@@ -8,6 +8,9 @@
8
8
  export const ERROR_CODES = {
9
9
  ALREADY_EXISTS: { exitCode: 5, description: 'The target resource already exists.' },
10
10
  AUTH_INVALID: { exitCode: 3, description: 'The configured credentials were rejected.' },
11
+ AUTH_PAIRING_FAILED: { exitCode: 3, description: 'Device QR login failed because the server rejected the pairing request.' },
12
+ AUTH_PAIRING_TIMEOUT: { exitCode: 3, description: 'Device QR login timed out before the phone approved this computer.' },
13
+ AUTH_PAIRING_UNSUPPORTED: { exitCode: 3, description: 'This server does not accept the CLI device type for QR login yet.' },
11
14
  AUTH_REQUIRED: { exitCode: 3, description: 'The command needs an authenticated Dinox account.' },
12
15
  AUTH_STATE_CHANGED: { exitCode: 3, description: 'Authentication changed while the command was running.' },
13
16
  CACHE_CLEANUP_FAILED: { exitCode: 1, description: 'Local cache cleanup after logout did not complete.' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dinoxx/dinox-cli",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "Dinox CLI",
5
5
  "main": "dist/dinox.js",
6
6
  "scripts": {
@@ -78,6 +78,7 @@
78
78
  "@tiptap/markdown": "^3.20.0",
79
79
  "better-sqlite3": "^12.6.2",
80
80
  "commander": "^14.0.3",
81
+ "qrcode": "1.5.4",
81
82
  "remark-gfm": "^4.0.1",
82
83
  "remark-parse": "^11.0.0",
83
84
  "remark-stringify": "^11.0.0",
@@ -93,6 +94,7 @@
93
94
  "devDependencies": {
94
95
  "@types/better-sqlite3": "^7.6.13",
95
96
  "@types/node": "^20.19.0",
97
+ "@types/qrcode": "1.5.6",
96
98
  "eslint": "^10.12.0",
97
99
  "tsx": "^4.20.3",
98
100
  "typescript": "^5.9.3",