@dinoxx/dinox-cli 2.8.1 → 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 +27 -30
- package/dist/auth/devicePair.d.ts +62 -0
- package/dist/auth/devicePair.js +377 -0
- package/dist/commands/auth/index.js +98 -10
- package/dist/commands/gateway/index.js +14 -4
- package/dist/commands/graph/index.js +2 -0
- package/dist/commands/schema/renderSkills.js +2 -2
- package/dist/config/types.d.ts +4 -0
- package/dist/daemon/autoInstall.d.ts +32 -0
- package/dist/daemon/autoInstall.js +106 -0
- package/dist/daemon/ensure.js +7 -0
- package/dist/daemon/route.js +2 -0
- package/dist/utils/errorCodes.d.ts +12 -0
- package/dist/utils/errorCodes.js +3 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -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 ...`
|
|
122
|
+
终端会显示二维码。用 Dinox App 打开 **设置 → 设备配对 → 扫一扫**,扫码确认即可。登录时会完成首次同步,之后的读取命令会自动启动本地 daemon,一般不用再手动执行 `dino sync` 或 `dino auth status`。
|
|
128
123
|
|
|
129
|
-
|
|
124
|
+
如果 App 提示二维码无效,先升级 Dinox App。这条命令最长等待 10 分钟。
|
|
130
125
|
|
|
131
|
-
|
|
132
|
-
dino sync
|
|
133
|
-
```
|
|
126
|
+
#### CI / 自动化
|
|
134
127
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
### 第 3 步:确认当前状态
|
|
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 缓存
|
|
@@ -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` 已设置后直接运行一次在线命令:
|
|
@@ -1135,6 +1132,7 @@ dino schema todo.update --format json
|
|
|
1135
1132
|
| `DINOX_DATA_DIR` | 覆盖本地数据目录 |
|
|
1136
1133
|
| `DINOX_TOKEN` | 用环境变量注入登录 token,优先级高于本地配置;可传 token 本体或 `Bearer ...` |
|
|
1137
1134
|
| `DINOX_SYNC_TIMEOUT_MS` | 覆盖同步超时 |
|
|
1135
|
+
| `DINOX_DAEMON_AUTO_INSTALL` | 设为 `0` 时,`auth login` / `gateway add` 不再自动把 daemon 安装为登录服务 |
|
|
1138
1136
|
|
|
1139
1137
|
### 9.3 macOS / Linux 与 Windows 的多行命令差异
|
|
1140
1138
|
|
|
@@ -1192,11 +1190,10 @@ dino schema <path> --format json
|
|
|
1192
1190
|
如果你是第一次用,建议按下面顺序熟悉:
|
|
1193
1191
|
|
|
1194
1192
|
1. `dino auth login`
|
|
1195
|
-
2. `dino
|
|
1196
|
-
3. `dino note
|
|
1197
|
-
4. `dino
|
|
1198
|
-
5. `dino
|
|
1199
|
-
6. `dino
|
|
1200
|
-
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`
|
|
1201
1198
|
|
|
1202
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';
|
|
@@ -6,6 +9,7 @@ import { resolveConfig } from '../../config/resolve.js';
|
|
|
6
9
|
import { getDaemonInfo } from '../../daemon/client.js';
|
|
7
10
|
import { restoreSuspendedDaemonLocked, suspendDaemonForExclusiveAccessLocked, } from '../../daemon/lifecycle.js';
|
|
8
11
|
import { stopDaemonLocked } from '../../daemon/index.js';
|
|
12
|
+
import { autoInstallDaemonService, describeDaemonServiceAutoInstall } from '../../daemon/autoInstall.js';
|
|
9
13
|
import { acquireDaemonStartLock } from '../../daemon/pid.js';
|
|
10
14
|
import { fetchCredentialsWithAuthorization } from '../../powersync/connector.js';
|
|
11
15
|
import { withExclusivePowerSync } from '../../powersync/exclusiveRuntime.js';
|
|
@@ -226,6 +230,45 @@ export async function performAuthLogin(rawToken, syncTimeout, overrides = {}) {
|
|
|
226
230
|
}
|
|
227
231
|
return outcome.value;
|
|
228
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
|
+
}
|
|
229
272
|
async function runAuthLogin(context) {
|
|
230
273
|
const options = context.options;
|
|
231
274
|
if (context.args.token && options.tokenStdin) {
|
|
@@ -233,19 +276,39 @@ async function runAuthLogin(context) {
|
|
|
233
276
|
code: 'USAGE_ERROR',
|
|
234
277
|
});
|
|
235
278
|
}
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
throw new DinoxError('Token is required');
|
|
240
|
-
}
|
|
279
|
+
const explicitToken = options.tokenStdin ? await readAuthTokenFromStdin() : context.args.token;
|
|
280
|
+
let rawToken = normalizeAuthToken(explicitToken);
|
|
281
|
+
let pairedDeviceBindingId;
|
|
241
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
|
+
}
|
|
242
297
|
if (envToken && envToken !== rawToken) {
|
|
243
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.');
|
|
244
299
|
}
|
|
245
300
|
const structuredOutput = getRequestedStructuredOutputFormat() !== null;
|
|
246
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
|
+
}
|
|
308
|
+
// After the login lock is released: the service starts the daemon under the new login.
|
|
309
|
+
const daemonService = await autoInstallDaemonService();
|
|
247
310
|
if (structuredOutput) {
|
|
248
|
-
printSuccess(result);
|
|
311
|
+
printSuccess({ ...result, daemonService });
|
|
249
312
|
return;
|
|
250
313
|
}
|
|
251
314
|
printHumanReadableLine('Login verified.');
|
|
@@ -258,11 +321,30 @@ async function runAuthLogin(context) {
|
|
|
258
321
|
if (result.stale) {
|
|
259
322
|
printHumanReadableLine('- warning: initial sync timed out; local cache may be stale');
|
|
260
323
|
}
|
|
324
|
+
const serviceNote = describeDaemonServiceAutoInstall(daemonService);
|
|
325
|
+
if (serviceNote)
|
|
326
|
+
printHumanReadableWarning(serviceNote);
|
|
261
327
|
}
|
|
262
328
|
async function runAuthLogoutLocked(options) {
|
|
263
329
|
const config = await loadConfig();
|
|
264
330
|
const resolved = resolveConfig(config);
|
|
265
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
|
+
}
|
|
266
348
|
const daemonInfo = await getDaemonInfo();
|
|
267
349
|
let initiallyResolvedUserId = resolved.auth.userId;
|
|
268
350
|
if (!initiallyResolvedUserId
|
|
@@ -334,6 +416,8 @@ async function runAuthLogoutLocked(options) {
|
|
|
334
416
|
username: undefined,
|
|
335
417
|
email: undefined,
|
|
336
418
|
userInfoSyncedAt: undefined,
|
|
419
|
+
deviceBindingId: undefined,
|
|
420
|
+
deviceId: undefined,
|
|
337
421
|
},
|
|
338
422
|
});
|
|
339
423
|
}, { timeoutMs: resolved.sync.timeoutMs });
|
|
@@ -483,8 +567,8 @@ export const authCommandGroup = {
|
|
|
483
567
|
commands: [
|
|
484
568
|
{
|
|
485
569
|
name: 'login',
|
|
486
|
-
description: '
|
|
487
|
-
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)' }],
|
|
488
572
|
options: [{ flags: '--token-stdin', description: 'Read the login token from piped stdin instead of argv', type: 'boolean' }],
|
|
489
573
|
schema: {
|
|
490
574
|
path: 'auth.login',
|
|
@@ -494,9 +578,13 @@ export const authCommandGroup = {
|
|
|
494
578
|
output: {
|
|
495
579
|
type: 'structured',
|
|
496
580
|
summary: 'Login verification result',
|
|
497
|
-
fields: ['verified', 'endpoint', 'dbPath', 'stale', 'lastSyncedAt', 'userId'],
|
|
581
|
+
fields: ['verified', 'endpoint', 'dbPath', 'stale', 'lastSyncedAt', 'userId', 'daemonService'],
|
|
498
582
|
},
|
|
499
|
-
examples: [
|
|
583
|
+
examples: [
|
|
584
|
+
'dino auth login',
|
|
585
|
+
'dino auth login --format json',
|
|
586
|
+
'dino auth login --token-stdin --format json',
|
|
587
|
+
],
|
|
500
588
|
},
|
|
501
589
|
action: runAuthLogin,
|
|
502
590
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getDaemonInfo, isDaemonRunning, sendDaemonRequest } from '../../daemon/client.js';
|
|
2
2
|
import { getDaemonService } from '../../daemon/supervisor.js';
|
|
3
3
|
import { ensureDaemonRunning } from '../../daemon/ensure.js';
|
|
4
|
+
import { autoInstallDaemonService, describeDaemonServiceAutoInstall } from '../../daemon/autoInstall.js';
|
|
4
5
|
import { loadConfig } from '../../config/store.js';
|
|
5
6
|
import { addGateway, adoptDetectedAgent, getGatewaysFilePath, loadGateways, removeGateway, } from '../../gateway/config.js';
|
|
6
7
|
import { detectAcpAgents, inferAgentId } from '../../gateway/detect.js';
|
|
@@ -56,8 +57,17 @@ async function warnIfDaemonUnmanaged() {
|
|
|
56
57
|
printHumanReadableWarning('warning: gateways run in a daemon owned by the shell or agent session that started it, and go offline when that session ends. Run `dino daemon install` to keep them online.');
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
|
-
/**
|
|
60
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Applies the account's gateways to the running daemon, starting it when needed.
|
|
62
|
+
* `keepOnline` first hands the daemon to the login service (once), so a gateway
|
|
63
|
+
* added from an agent session does not go offline when that session ends.
|
|
64
|
+
*/
|
|
65
|
+
async function applyToDaemon(options = {}) {
|
|
66
|
+
if (options.keepOnline) {
|
|
67
|
+
const note = describeDaemonServiceAutoInstall(await autoInstallDaemonService());
|
|
68
|
+
if (note)
|
|
69
|
+
printHumanReadableWarning(note);
|
|
70
|
+
}
|
|
61
71
|
let gateways;
|
|
62
72
|
if (!(await isDaemonRunning())) {
|
|
63
73
|
await ensureDaemonRunning();
|
|
@@ -98,7 +108,7 @@ async function runAdd(context) {
|
|
|
98
108
|
cwd: context.options.cwd,
|
|
99
109
|
env: parseEnv(context.options.env),
|
|
100
110
|
});
|
|
101
|
-
const statuses = await applyToDaemon();
|
|
111
|
+
const statuses = await applyToDaemon({ keepOnline: true });
|
|
102
112
|
if (getRequestedStructuredOutputFormat()) {
|
|
103
113
|
printSuccess({ gateway: describeGateway(gateway), status: statuses.find((item) => item.gatewayId === gateway.id) ?? null });
|
|
104
114
|
return;
|
|
@@ -122,7 +132,7 @@ async function runDetect(context) {
|
|
|
122
132
|
updated.push(result.gateway);
|
|
123
133
|
}
|
|
124
134
|
}
|
|
125
|
-
const statuses = added.length || updated.length ? await applyToDaemon() : [];
|
|
135
|
+
const statuses = added.length || updated.length ? await applyToDaemon({ keepOnline: true }) : [];
|
|
126
136
|
if (getRequestedStructuredOutputFormat()) {
|
|
127
137
|
printSuccess({
|
|
128
138
|
agents: detected,
|
|
@@ -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;
|
|
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.
|
|
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.',
|
package/dist/config/types.d.ts
CHANGED
|
@@ -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). */
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DinoxConfig } from '../config/types.js';
|
|
2
|
+
import { type DaemonService, type DaemonServiceKind } from './supervisor.js';
|
|
3
|
+
/** `0` / `false` / `off` / `no` keeps the CLI from installing the daemon service on its own. */
|
|
4
|
+
export declare const DAEMON_AUTO_INSTALL_ENV = "DINOX_DAEMON_AUTO_INSTALL";
|
|
5
|
+
export type DaemonServiceAutoInstallSkipReason = 'disabled' | 'ci' | 'custom-data-dir' | 'unsupported' | 'no-login' | 'env-token';
|
|
6
|
+
export type DaemonServiceAutoInstallResult = {
|
|
7
|
+
status: 'installed' | 'already-installed' | 'skipped' | 'failed';
|
|
8
|
+
service: DaemonServiceKind | null;
|
|
9
|
+
reason: DaemonServiceAutoInstallSkipReason | null;
|
|
10
|
+
error: string | null;
|
|
11
|
+
};
|
|
12
|
+
export type DaemonServiceAutoInstallDependencies = {
|
|
13
|
+
env: NodeJS.ProcessEnv;
|
|
14
|
+
getService: () => DaemonService | null;
|
|
15
|
+
loadConfig: () => Promise<DinoxConfig>;
|
|
16
|
+
install: () => Promise<unknown>;
|
|
17
|
+
/** Removes whatever a failed install left behind. */
|
|
18
|
+
rollback: () => Promise<unknown>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Makes the daemon a login service without the user having to know it exists.
|
|
22
|
+
* Without one it belongs to whichever shell or agent session first started it
|
|
23
|
+
* and dies with that session. Called after `auth login` and when gateways are
|
|
24
|
+
* added; never throws, because neither of those should fail over it.
|
|
25
|
+
*
|
|
26
|
+
* Skipped where installing something that runs at login would surprise:
|
|
27
|
+
* an opt-out, CI, and custom data directories (tests, sandboxes, second
|
|
28
|
+
* profiles), which still get it from an explicit `dino daemon install`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function autoInstallDaemonService(dependencies?: DaemonServiceAutoInstallDependencies): Promise<DaemonServiceAutoInstallResult>;
|
|
31
|
+
/** One stderr line for a human caller; nothing when there is nothing to say. */
|
|
32
|
+
export declare function describeDaemonServiceAutoInstall(result: DaemonServiceAutoInstallResult): string | null;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { loadConfig } from '../config/store.js';
|
|
3
|
+
import { normalizeAuthToken } from '../utils/authToken.js';
|
|
4
|
+
import { DinoxError } from '../utils/errors.js';
|
|
5
|
+
import { installDaemonService, uninstallDaemonService } from './index.js';
|
|
6
|
+
import { envTokenOverridesLogin } from './launch.js';
|
|
7
|
+
import { getDaemonService } from './supervisor.js';
|
|
8
|
+
/** `0` / `false` / `off` / `no` keeps the CLI from installing the daemon service on its own. */
|
|
9
|
+
export const DAEMON_AUTO_INSTALL_ENV = 'DINOX_DAEMON_AUTO_INSTALL';
|
|
10
|
+
/**
|
|
11
|
+
* systemd --user is missing in WSL without systemd, most containers, and
|
|
12
|
+
* other non-systemd distros; `/run/systemd/system` is how sd_booted() tells.
|
|
13
|
+
*/
|
|
14
|
+
function usableService() {
|
|
15
|
+
const service = getDaemonService();
|
|
16
|
+
if (service?.kind === 'systemd' && !existsSync('/run/systemd/system'))
|
|
17
|
+
return null;
|
|
18
|
+
return service;
|
|
19
|
+
}
|
|
20
|
+
const defaultDependencies = {
|
|
21
|
+
env: process.env,
|
|
22
|
+
getService: usableService,
|
|
23
|
+
loadConfig,
|
|
24
|
+
install: () => installDaemonService(),
|
|
25
|
+
rollback: () => uninstallDaemonService(),
|
|
26
|
+
};
|
|
27
|
+
function isFalsy(value) {
|
|
28
|
+
return ['0', 'false', 'off', 'no'].includes((value ?? '').trim().toLowerCase());
|
|
29
|
+
}
|
|
30
|
+
function isSet(value) {
|
|
31
|
+
return Boolean(value?.trim()) && !isFalsy(value);
|
|
32
|
+
}
|
|
33
|
+
function skipped(service, reason) {
|
|
34
|
+
return { status: 'skipped', service, reason, error: null };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Makes the daemon a login service without the user having to know it exists.
|
|
38
|
+
* Without one it belongs to whichever shell or agent session first started it
|
|
39
|
+
* and dies with that session. Called after `auth login` and when gateways are
|
|
40
|
+
* added; never throws, because neither of those should fail over it.
|
|
41
|
+
*
|
|
42
|
+
* Skipped where installing something that runs at login would surprise:
|
|
43
|
+
* an opt-out, CI, and custom data directories (tests, sandboxes, second
|
|
44
|
+
* profiles), which still get it from an explicit `dino daemon install`.
|
|
45
|
+
*/
|
|
46
|
+
export async function autoInstallDaemonService(dependencies = defaultDependencies) {
|
|
47
|
+
const { env } = dependencies;
|
|
48
|
+
if (isFalsy(env[DAEMON_AUTO_INSTALL_ENV]))
|
|
49
|
+
return skipped(null, 'disabled');
|
|
50
|
+
if (isSet(env.CI))
|
|
51
|
+
return skipped(null, 'ci');
|
|
52
|
+
if (env.DINOX_DATA_DIR)
|
|
53
|
+
return skipped(null, 'custom-data-dir');
|
|
54
|
+
const service = dependencies.getService();
|
|
55
|
+
if (!service)
|
|
56
|
+
return skipped(null, 'unsupported');
|
|
57
|
+
if (service.isInstalled()) {
|
|
58
|
+
return { status: 'already-installed', service: service.kind, reason: null, error: null };
|
|
59
|
+
}
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = await dependencies.loadConfig();
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return { status: 'failed', service: service.kind, reason: null, error: errorMessage(error) };
|
|
66
|
+
}
|
|
67
|
+
if (!normalizeAuthToken(raw.auth?.authorization))
|
|
68
|
+
return skipped(service.kind, 'no-login');
|
|
69
|
+
if (envTokenOverridesLogin(raw, env))
|
|
70
|
+
return skipped(service.kind, 'env-token');
|
|
71
|
+
try {
|
|
72
|
+
await dependencies.install();
|
|
73
|
+
return { status: 'installed', service: service.kind, reason: null, error: null };
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
// Every service writes its definition before asking the manager to load it,
|
|
77
|
+
// and the CLI starts the daemon through any definition it finds. Left in
|
|
78
|
+
// place after a failure (no GUI session over SSH, no user bus, a policy
|
|
79
|
+
// blocking Task Scheduler), it would break the on-demand start that worked
|
|
80
|
+
// before, so restore the state from before this attempt.
|
|
81
|
+
let message = errorMessage(error);
|
|
82
|
+
try {
|
|
83
|
+
await dependencies.rollback();
|
|
84
|
+
}
|
|
85
|
+
catch (rollbackError) {
|
|
86
|
+
message += `; cleanup also failed (${errorMessage(rollbackError)}), run \`dino daemon uninstall\``;
|
|
87
|
+
}
|
|
88
|
+
if (error instanceof DinoxError && error.code === 'DAEMON_SERVICE_UNSUPPORTED') {
|
|
89
|
+
return { status: 'skipped', service: service.kind, reason: 'unsupported', error: message };
|
|
90
|
+
}
|
|
91
|
+
return { status: 'failed', service: service.kind, reason: null, error: message };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function errorMessage(error) {
|
|
95
|
+
return error instanceof Error ? error.message : String(error);
|
|
96
|
+
}
|
|
97
|
+
/** One stderr line for a human caller; nothing when there is nothing to say. */
|
|
98
|
+
export function describeDaemonServiceAutoInstall(result) {
|
|
99
|
+
if (result.status === 'installed') {
|
|
100
|
+
return `Background service installed (${result.service}): the daemon now starts at login and restarts after a crash. Remove it with \`dino daemon uninstall\`.`;
|
|
101
|
+
}
|
|
102
|
+
if (result.status === 'failed') {
|
|
103
|
+
return `warning: could not install the background service (${result.error}). The daemon still starts on demand; run \`dino daemon install\` to retry.`;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
package/dist/daemon/ensure.js
CHANGED
|
@@ -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;
|
package/dist/daemon/route.js
CHANGED
|
@@ -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.";
|
package/dist/utils/errorCodes.js
CHANGED
|
@@ -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.
|
|
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",
|