doubao-cli 0.4.1 → 0.5.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 +24 -2
- package/package.json +1 -1
- package/src/attachments.mjs +65 -18
- package/src/automation.mjs +14 -4
- package/src/cli.mjs +75 -0
- package/src/update.mjs +165 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ npm install --global doubao-cli@latest
|
|
|
11
11
|
doubao --version
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
Upgrade an existing installation with
|
|
14
|
+
Upgrade an existing installation with `doubao update`. To run without a global install:
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
npx --yes doubao-cli@latest status
|
|
@@ -37,6 +37,9 @@ doubao model select doubao-2.1-turbo
|
|
|
37
37
|
doubao sessions send 38439138239851266 "hello" --model gpt-5.6-sol --wait
|
|
38
38
|
doubao cdp status
|
|
39
39
|
doubao cdp launch
|
|
40
|
+
doubao update check
|
|
41
|
+
doubao update
|
|
42
|
+
doubao update auto on
|
|
40
43
|
doubao capabilities
|
|
41
44
|
```
|
|
42
45
|
|
|
@@ -52,6 +55,25 @@ If Doubao is already running without CDP, the command asks for confirmation befo
|
|
|
52
55
|
|
|
53
56
|
Set `DOUBAO_CDP_ENDPOINT` if using another port. `sessions send --wait` waits for and returns the completed assistant reply.
|
|
54
57
|
|
|
58
|
+
### Updates
|
|
59
|
+
|
|
60
|
+
`doubao update check` compares the running version with npm without changing the installation. `doubao update` installs the latest release globally through npm when an update is available:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
doubao update check --json
|
|
64
|
+
doubao update
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Automatic installation is opt-in and checks at most once every 24 hours:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
doubao update auto on
|
|
71
|
+
doubao update auto status
|
|
72
|
+
doubao update auto off
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
An automatic update never blocks the requested Doubao command if npm or the network fails. Set `DOUBAO_CLI_DISABLE_AUTO_UPDATE=1` to skip configured automatic updates in CI or a one-off invocation. Settings are stored under `~/Library/Application Support/doubao-cli/update.json`; override that directory with `DOUBAO_CLI_CONFIG_DIR`.
|
|
76
|
+
|
|
55
77
|
### New sessions and attachments
|
|
56
78
|
|
|
57
79
|
`sessions create` opens a clean composer. A numeric conversation id does not exist until the first message is sent, so `sessions create` without a message returns `conversationId: null`. Create and persist a session in one command by providing its first message:
|
|
@@ -97,7 +119,7 @@ No hard-coded UI coordinates, image recognition, Cookie extraction, or private c
|
|
|
97
119
|
|
|
98
120
|
## Limits
|
|
99
121
|
|
|
100
|
-
Message read/send and attachment upload use stable DOM
|
|
122
|
+
Message read/send and attachment upload use stable DOM attributes in the authenticated Doubao renderer over localhost CDP. A Doubao update can change these selectors. The CLI treats image previews and file cards separately, waits for their respective upload completion signals, and verifies both the exact user message and sent attachment count before reporting success. The CLI currently accepts up to 50 attachments per command and files up to 100 MiB each; the Doubao service can impose stricter type or size limits.
|
|
101
123
|
|
|
102
124
|
## Development
|
|
103
125
|
|
package/package.json
CHANGED
package/src/attachments.mjs
CHANGED
|
@@ -2,7 +2,9 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
const DROP_AREA = '[data-testid="file_drop_area"]';
|
|
5
|
+
const ATTACHMENT_AREA = '[data-testid="attachment_area"]';
|
|
5
6
|
const ATTACHMENT_ITEM = '[data-testid="attachment_area"] [data-testid="attachment_file_item"]';
|
|
7
|
+
const IMAGE_ITEM = '[data-testid="attachment_area"] [data-testid="mdbox_image"]';
|
|
6
8
|
const MAX_FILE_COUNT = 50;
|
|
7
9
|
const MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
8
10
|
const CHUNK_BYTES = 384 * 1024;
|
|
@@ -98,20 +100,51 @@ async function dispatchDrop(client, stateKey) {
|
|
|
98
100
|
})()`);
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
|
|
103
|
+
export function attachmentUploadReady(state, expected) {
|
|
104
|
+
return state.files.length === expected.files
|
|
105
|
+
&& state.files.every((item) => item.available)
|
|
106
|
+
&& state.images.length === expected.images
|
|
107
|
+
&& state.images.every((item) => item.loaded)
|
|
108
|
+
&& !state.progressing;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function waitForUploads(client, files, timeoutMs) {
|
|
112
|
+
const expected = {
|
|
113
|
+
files: files.filter((file) => !file.type.startsWith('image/')).length,
|
|
114
|
+
images: files.filter((file) => file.type.startsWith('image/')).length,
|
|
115
|
+
};
|
|
102
116
|
const deadline = Date.now() + timeoutMs;
|
|
103
117
|
while (Date.now() < deadline) {
|
|
104
118
|
const state = await client.evaluate(`(() => {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
119
|
+
const area = document.querySelector(${JSON.stringify(ATTACHMENT_AREA)});
|
|
120
|
+
const fileItems = ${expected.files}
|
|
121
|
+
? [...document.querySelectorAll(${JSON.stringify(ATTACHMENT_ITEM)})].slice(-${expected.files})
|
|
122
|
+
: [];
|
|
123
|
+
const imageItems = ${expected.images}
|
|
124
|
+
? [...document.querySelectorAll(${JSON.stringify(IMAGE_ITEM)})]
|
|
125
|
+
.filter((item) => !item.closest('[data-testid="attachment_file_item"]'))
|
|
126
|
+
.slice(-${expected.images})
|
|
127
|
+
: [];
|
|
128
|
+
return {
|
|
129
|
+
files: fileItems.map((item) => ({
|
|
130
|
+
available: item.getAttribute('data-available') === 'true',
|
|
131
|
+
name: (item.querySelector('[data-testid="message_nested_content_file_name"]')?.innerText || '').trim(),
|
|
132
|
+
status: (item.querySelector('[data-testid="message_nested_content_file_subtitle"]')?.innerText || '').trim(),
|
|
133
|
+
})),
|
|
134
|
+
images: imageItems.map((item) => {
|
|
135
|
+
const image = item.querySelector('img[alt="image"]');
|
|
136
|
+
return { loaded: Boolean(image?.complete && image?.naturalWidth > 0) };
|
|
137
|
+
}),
|
|
138
|
+
progressing: /(?:^|\\s)\\d{1,3}%(?:\\s|$)/u.test(area?.innerText || ''),
|
|
139
|
+
status: (area?.innerText || '').trim(),
|
|
140
|
+
};
|
|
111
141
|
})()`);
|
|
112
|
-
if (state
|
|
113
|
-
const failed = state.find((item) => /(?:失败|不支持|超出|error|failed)/iu.test(item.status));
|
|
142
|
+
if (attachmentUploadReady(state, expected)) return;
|
|
143
|
+
const failed = state.files.find((item) => /(?:失败|不支持|超出|error|failed)/iu.test(item.status));
|
|
114
144
|
if (failed) throw new Error(`Doubao failed to upload attachment ${failed.name || ''}: ${failed.status}`.trim());
|
|
145
|
+
if (/(?:失败|不支持|超出|error|failed)/iu.test(state.status)) {
|
|
146
|
+
throw new Error(`Doubao failed to upload an attachment: ${state.status}`);
|
|
147
|
+
}
|
|
115
148
|
await delay(250);
|
|
116
149
|
}
|
|
117
150
|
throw new Error(`Doubao attachments did not finish uploading within ${timeoutMs} ms`);
|
|
@@ -123,13 +156,33 @@ async function clearComposerAttachments(client) {
|
|
|
123
156
|
item.querySelector('[data-testid="message_nested_content_file_delete"]')
|
|
124
157
|
?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
125
158
|
}
|
|
159
|
+
const area = document.querySelector(${JSON.stringify(ATTACHMENT_AREA)});
|
|
160
|
+
const imageItems = [...document.querySelectorAll(${JSON.stringify(IMAGE_ITEM)})]
|
|
161
|
+
.filter((item) => !item.closest('[data-testid="attachment_file_item"]'));
|
|
162
|
+
for (const image of imageItems) {
|
|
163
|
+
let container = image.parentElement;
|
|
164
|
+
while (container && container !== area) {
|
|
165
|
+
const deleteButton = [...container.children]
|
|
166
|
+
.find((child) => /(?:^|\\s)delete-btn-[^\\s]+/u.test(child.className || ''));
|
|
167
|
+
if (deleteButton) {
|
|
168
|
+
deleteButton.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
container = container.parentElement;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
126
174
|
})()`);
|
|
127
175
|
}
|
|
128
176
|
|
|
129
177
|
export async function uploadAttachmentsFromClient(client, filePaths, options = {}) {
|
|
130
178
|
const files = await resolveAttachmentFiles(filePaths);
|
|
131
179
|
if (!files.length) return [];
|
|
132
|
-
const existingAttachmentCount = await client.evaluate(`
|
|
180
|
+
const existingAttachmentCount = await client.evaluate(`(() => {
|
|
181
|
+
const files = document.querySelectorAll(${JSON.stringify(ATTACHMENT_ITEM)}).length;
|
|
182
|
+
const images = [...document.querySelectorAll(${JSON.stringify(IMAGE_ITEM)})]
|
|
183
|
+
.filter((item) => !item.closest('[data-testid="attachment_file_item"]')).length;
|
|
184
|
+
return files + images;
|
|
185
|
+
})()`);
|
|
133
186
|
if (existingAttachmentCount) {
|
|
134
187
|
throw new Error('Doubao composer already contains draft attachments; remove them before using --attach');
|
|
135
188
|
}
|
|
@@ -139,19 +192,13 @@ export async function uploadAttachmentsFromClient(client, filePaths, options = {
|
|
|
139
192
|
await stageFiles(client, files, stateKey);
|
|
140
193
|
const dropped = await dispatchDrop(client, stateKey);
|
|
141
194
|
if (dropped.length !== files.length) throw new Error('Doubao did not accept every attachment from the drop event');
|
|
142
|
-
let uploaded;
|
|
143
195
|
try {
|
|
144
|
-
|
|
196
|
+
await waitForUploads(client, files, timeoutMs);
|
|
145
197
|
} catch (error) {
|
|
146
198
|
await clearComposerAttachments(client).catch(() => {});
|
|
147
199
|
throw error;
|
|
148
200
|
}
|
|
149
|
-
return
|
|
150
|
-
name: item.name || files[index].name,
|
|
151
|
-
path: files[index].path,
|
|
152
|
-
size: files[index].size,
|
|
153
|
-
type: files[index].type,
|
|
154
|
-
}));
|
|
201
|
+
return files;
|
|
155
202
|
} finally {
|
|
156
203
|
await client.evaluate(`delete globalThis[${JSON.stringify(stateKey)}]`).catch(() => {});
|
|
157
204
|
}
|
package/src/automation.mjs
CHANGED
|
@@ -78,8 +78,14 @@ const READ_MESSAGES_EXPRESSION = `(() => [...document.querySelectorAll('[data-te
|
|
|
78
78
|
const attachments = [...element.querySelectorAll('[data-testid="message_nested_content_file_name"]')]
|
|
79
79
|
.map((part) => (part.innerText || '').trim())
|
|
80
80
|
.filter(Boolean);
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
const images = element.querySelectorAll('[data-plugin-identifier="block_type:10052"] img').length;
|
|
82
|
+
return role && (parts.length || attachments.length || images)
|
|
83
|
+
? {
|
|
84
|
+
role,
|
|
85
|
+
text: parts.join('\\n'),
|
|
86
|
+
...(attachments.length ? { attachments } : {}),
|
|
87
|
+
...(images ? { images } : {}),
|
|
88
|
+
}
|
|
83
89
|
: null;
|
|
84
90
|
})
|
|
85
91
|
.filter(Boolean))()`;
|
|
@@ -109,12 +115,16 @@ export function attachmentsConfirmed(before, after, attachments) {
|
|
|
109
115
|
const beforeCounts = attachmentCounts(before);
|
|
110
116
|
const afterCounts = attachmentCounts(after);
|
|
111
117
|
const expectedCounts = new Map();
|
|
112
|
-
for (const attachment of attachments) {
|
|
118
|
+
for (const attachment of attachments.filter((item) => !item.type?.startsWith('image/'))) {
|
|
113
119
|
expectedCounts.set(attachment.name, (expectedCounts.get(attachment.name) || 0) + 1);
|
|
114
120
|
}
|
|
115
|
-
|
|
121
|
+
const filesConfirmed = [...expectedCounts].every(([name, expected]) => (
|
|
116
122
|
(afterCounts.get(name) || 0) - (beforeCounts.get(name) || 0) >= expected
|
|
117
123
|
));
|
|
124
|
+
const expectedImages = attachments.filter((item) => item.type?.startsWith('image/')).length;
|
|
125
|
+
const beforeImages = before.reduce((total, item) => total + (item.role === 'user' ? item.images || 0 : 0), 0);
|
|
126
|
+
const afterImages = after.reduce((total, item) => total + (item.role === 'user' ? item.images || 0 : 0), 0);
|
|
127
|
+
return filesConfirmed && afterImages - beforeImages >= expectedImages;
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
export async function readConversation(id, options = {}) {
|
package/src/cli.mjs
CHANGED
|
@@ -6,6 +6,14 @@ import { currentSession, getDataDir, listSessions, resolveProfile } from './stor
|
|
|
6
6
|
import { cdpStatus } from './cdp.mjs';
|
|
7
7
|
import { createConversation, openConversation, readConversation, sendMessage } from './automation.mjs';
|
|
8
8
|
import { currentModel, listModels, selectModel } from './models.mjs';
|
|
9
|
+
import {
|
|
10
|
+
checkForUpdate,
|
|
11
|
+
installUpdate,
|
|
12
|
+
maybeAutoUpdate,
|
|
13
|
+
readUpdateState,
|
|
14
|
+
setAutoUpdate,
|
|
15
|
+
updateStatePath,
|
|
16
|
+
} from './update.mjs';
|
|
9
17
|
|
|
10
18
|
const DEFAULT_APP = '/Applications/Doubao.app';
|
|
11
19
|
const CLI_VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
@@ -24,12 +32,17 @@ const HELP = `Usage:
|
|
|
24
32
|
doubao model select <model> [--json]
|
|
25
33
|
doubao cdp status [--json]
|
|
26
34
|
doubao cdp launch [--yes] [--json]
|
|
35
|
+
doubao update [--json]
|
|
36
|
+
doubao update check [--json]
|
|
37
|
+
doubao update auto <on|off|status> [--json]
|
|
27
38
|
doubao capabilities [--json]
|
|
28
39
|
|
|
29
40
|
Environment:
|
|
30
41
|
DOUBAO_APP Override the Doubao.app path
|
|
31
42
|
DOUBAO_DATA_DIR Override the Doubao user-data directory
|
|
32
43
|
DOUBAO_CDP_ENDPOINT CDP endpoint (default: http://127.0.0.1:9225)
|
|
44
|
+
DOUBAO_CLI_CONFIG_DIR Override the doubao-cli settings directory
|
|
45
|
+
DOUBAO_CLI_DISABLE_AUTO_UPDATE Set to 1 to skip configured automatic updates
|
|
33
46
|
`;
|
|
34
47
|
|
|
35
48
|
export function parseOptions(argv) {
|
|
@@ -169,6 +182,16 @@ function sessionWithTitle(profilePath, id) {
|
|
|
169
182
|
return listSessions(profilePath).find((session) => session.id === id) || { id, title: null };
|
|
170
183
|
}
|
|
171
184
|
|
|
185
|
+
async function runConfiguredAutoUpdate(command, json) {
|
|
186
|
+
if (['help', '--help', '-h', 'version', '--version', '-v', 'update'].includes(command)) return;
|
|
187
|
+
const result = await maybeAutoUpdate(CLI_VERSION);
|
|
188
|
+
if (result.updated) {
|
|
189
|
+
if (!json) console.error(`doubao: automatically updated to ${result.latestVersion}; the new version applies next run`);
|
|
190
|
+
} else if (result.error && !json) {
|
|
191
|
+
console.error(`doubao: automatic update failed: ${result.error}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
172
195
|
export async function main(argv) {
|
|
173
196
|
const { args, profile: requestedProfile, json, yes, wait, timeoutMs, limit, model, attachments } = parseOptions(argv);
|
|
174
197
|
const [command, subcommand, operand] = args;
|
|
@@ -183,6 +206,54 @@ export async function main(argv) {
|
|
|
183
206
|
return;
|
|
184
207
|
}
|
|
185
208
|
|
|
209
|
+
if (command === 'update') {
|
|
210
|
+
if (subcommand === 'auto') {
|
|
211
|
+
const action = operand || 'status';
|
|
212
|
+
if (!['on', 'off', 'status'].includes(action)) {
|
|
213
|
+
throw new Error('update auto requires on, off, or status');
|
|
214
|
+
}
|
|
215
|
+
const state = action === 'status'
|
|
216
|
+
? await readUpdateState()
|
|
217
|
+
: await setAutoUpdate(action === 'on');
|
|
218
|
+
const result = {
|
|
219
|
+
enabled: state.autoUpdate,
|
|
220
|
+
lastCheckedAt: state.lastCheckedAt,
|
|
221
|
+
lastUpdatedVersion: state.lastUpdatedVersion,
|
|
222
|
+
settingsPath: updateStatePath(),
|
|
223
|
+
};
|
|
224
|
+
if (json) output(result, true);
|
|
225
|
+
else {
|
|
226
|
+
console.log(`automatic updates\t${result.enabled ? 'on' : 'off'}`);
|
|
227
|
+
if (result.lastCheckedAt) console.log(`last checked\t${result.lastCheckedAt}`);
|
|
228
|
+
if (result.lastUpdatedVersion) console.log(`last updated\t${result.lastUpdatedVersion}`);
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (subcommand && subcommand !== 'check') {
|
|
234
|
+
throw new Error(`unknown update command "${subcommand}". Run "doubao help".`);
|
|
235
|
+
}
|
|
236
|
+
const check = await checkForUpdate(CLI_VERSION);
|
|
237
|
+
if (subcommand === 'check') {
|
|
238
|
+
if (json) output(check, true);
|
|
239
|
+
else {
|
|
240
|
+
console.log(`current\t${check.currentVersion}`);
|
|
241
|
+
console.log(`latest\t${check.latestVersion}`);
|
|
242
|
+
console.log(`update available\t${check.updateAvailable ? 'yes' : 'no'}`);
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const result = check.updateAvailable
|
|
247
|
+
? { ...check, ...installUpdate(check.latestVersion, { inherit: !json }) }
|
|
248
|
+
: { ...check, updated: false };
|
|
249
|
+
if (json) output(result, true);
|
|
250
|
+
else if (result.updated) console.log(`updated\t${result.latestVersion}`);
|
|
251
|
+
else console.log(`up to date\t${result.currentVersion}`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await runConfiguredAutoUpdate(command, json);
|
|
256
|
+
|
|
186
257
|
if (command === 'profiles') {
|
|
187
258
|
const { readProfiles } = await import('./storage.mjs');
|
|
188
259
|
const profiles = readProfiles(dataDir);
|
|
@@ -208,6 +279,8 @@ export async function main(argv) {
|
|
|
208
279
|
sendMessages: cdp.available,
|
|
209
280
|
uploadAttachments: cdp.available,
|
|
210
281
|
selectModels: cdp.available,
|
|
282
|
+
selfUpdate: true,
|
|
283
|
+
automaticUpdates: true,
|
|
211
284
|
cdp,
|
|
212
285
|
note: cdp.available
|
|
213
286
|
? 'Message automation is available through the authenticated Doubao renderer over local CDP.'
|
|
@@ -224,6 +297,8 @@ export async function main(argv) {
|
|
|
224
297
|
console.log(`messages send\t${capabilities.sendMessages ? 'yes' : 'no'}`);
|
|
225
298
|
console.log(`attachments upload\t${capabilities.uploadAttachments ? 'yes' : 'no'}`);
|
|
226
299
|
console.log(`models select\t${capabilities.selectModels ? 'yes' : 'no'}`);
|
|
300
|
+
console.log('self update\tyes');
|
|
301
|
+
console.log('automatic updates\tyes');
|
|
227
302
|
console.log(`note\t${capabilities.note}`);
|
|
228
303
|
}
|
|
229
304
|
return;
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const PACKAGE_NAME = 'doubao-cli';
|
|
7
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
8
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const DEFAULT_STATE = Object.freeze({
|
|
10
|
+
autoUpdate: false,
|
|
11
|
+
lastCheckedAt: null,
|
|
12
|
+
lastUpdatedVersion: null,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function parseVersion(version) {
|
|
16
|
+
const match = /^(?:v)?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/u.exec(String(version || '').trim());
|
|
17
|
+
if (!match) throw new Error(`invalid semantic version "${version}"`);
|
|
18
|
+
return {
|
|
19
|
+
numbers: match.slice(1, 4).map(Number),
|
|
20
|
+
prerelease: match[4]?.split('.') || [],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function comparePrerelease(left, right) {
|
|
25
|
+
if (!left.length && !right.length) return 0;
|
|
26
|
+
if (!left.length) return 1;
|
|
27
|
+
if (!right.length) return -1;
|
|
28
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
29
|
+
if (left[index] === undefined) return -1;
|
|
30
|
+
if (right[index] === undefined) return 1;
|
|
31
|
+
if (left[index] === right[index]) continue;
|
|
32
|
+
const leftNumber = /^\d+$/u.test(left[index]) ? Number(left[index]) : null;
|
|
33
|
+
const rightNumber = /^\d+$/u.test(right[index]) ? Number(right[index]) : null;
|
|
34
|
+
if (leftNumber !== null && rightNumber !== null) return Math.sign(leftNumber - rightNumber);
|
|
35
|
+
if (leftNumber !== null) return -1;
|
|
36
|
+
if (rightNumber !== null) return 1;
|
|
37
|
+
return Math.sign(left[index].localeCompare(right[index], 'en-US'));
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function compareVersions(leftVersion, rightVersion) {
|
|
43
|
+
const left = parseVersion(leftVersion);
|
|
44
|
+
const right = parseVersion(rightVersion);
|
|
45
|
+
for (let index = 0; index < left.numbers.length; index += 1) {
|
|
46
|
+
if (left.numbers[index] !== right.numbers[index]) {
|
|
47
|
+
return Math.sign(left.numbers[index] - right.numbers[index]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return comparePrerelease(left.prerelease, right.prerelease);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function updateStatePath(env = process.env) {
|
|
54
|
+
const directory = env.DOUBAO_CLI_CONFIG_DIR
|
|
55
|
+
|| path.join(os.homedir(), 'Library', 'Application Support', PACKAGE_NAME);
|
|
56
|
+
return path.join(directory, 'update.json');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function readUpdateState(env = process.env) {
|
|
60
|
+
const statePath = updateStatePath(env);
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(await fs.readFile(statePath, 'utf8'));
|
|
63
|
+
return { ...DEFAULT_STATE, ...parsed };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error.code === 'ENOENT') return { ...DEFAULT_STATE };
|
|
66
|
+
throw new Error(`cannot read update settings at ${statePath}: ${error.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function writeUpdateState(state, env = process.env) {
|
|
71
|
+
const statePath = updateStatePath(env);
|
|
72
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
73
|
+
const temporaryPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
|
|
74
|
+
try {
|
|
75
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
76
|
+
await fs.rename(temporaryPath, statePath);
|
|
77
|
+
} finally {
|
|
78
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => {});
|
|
79
|
+
}
|
|
80
|
+
return state;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function setAutoUpdate(enabled, env = process.env) {
|
|
84
|
+
const state = await readUpdateState(env);
|
|
85
|
+
return writeUpdateState({
|
|
86
|
+
...state,
|
|
87
|
+
autoUpdate: Boolean(enabled),
|
|
88
|
+
...(enabled ? { lastCheckedAt: null } : {}),
|
|
89
|
+
}, env);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function checkForUpdate(currentVersion, options = {}) {
|
|
93
|
+
parseVersion(currentVersion);
|
|
94
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs || 5000);
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetchImpl(REGISTRY_URL, {
|
|
99
|
+
headers: { accept: 'application/json' },
|
|
100
|
+
signal: controller.signal,
|
|
101
|
+
});
|
|
102
|
+
if (!response.ok) throw new Error(`npm registry returned HTTP ${response.status}`);
|
|
103
|
+
const payload = await response.json();
|
|
104
|
+
parseVersion(payload.version);
|
|
105
|
+
return {
|
|
106
|
+
currentVersion,
|
|
107
|
+
latestVersion: payload.version,
|
|
108
|
+
updateAvailable: compareVersions(currentVersion, payload.version) < 0,
|
|
109
|
+
};
|
|
110
|
+
} finally {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function installUpdate(version, options = {}) {
|
|
116
|
+
parseVersion(version);
|
|
117
|
+
const spawnImpl = options.spawnImpl || spawnSync;
|
|
118
|
+
const result = spawnImpl('npm', ['install', '--global', `${PACKAGE_NAME}@${version}`], {
|
|
119
|
+
encoding: 'utf8',
|
|
120
|
+
env: options.env || process.env,
|
|
121
|
+
stdio: options.inherit ? 'inherit' : 'pipe',
|
|
122
|
+
});
|
|
123
|
+
if (result.error) throw new Error(`failed to run npm: ${result.error.message}`);
|
|
124
|
+
if (result.status !== 0) {
|
|
125
|
+
throw new Error(result.stderr?.trim() || `npm exited with status ${result.status}`);
|
|
126
|
+
}
|
|
127
|
+
return { updated: true, version };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function automaticUpdatesDisabled(env) {
|
|
131
|
+
return /^(?:1|true|yes)$/iu.test(env.DOUBAO_CLI_DISABLE_AUTO_UPDATE || '');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function maybeAutoUpdate(currentVersion, options = {}) {
|
|
135
|
+
const env = options.env || process.env;
|
|
136
|
+
if (automaticUpdatesDisabled(env)) return { skipped: 'disabled' };
|
|
137
|
+
try {
|
|
138
|
+
const state = await readUpdateState(env);
|
|
139
|
+
if (!state.autoUpdate) return { skipped: 'not-enabled' };
|
|
140
|
+
|
|
141
|
+
const now = options.now || Date.now();
|
|
142
|
+
const lastCheckedAt = state.lastCheckedAt ? Date.parse(state.lastCheckedAt) : 0;
|
|
143
|
+
if (Number.isFinite(lastCheckedAt) && now - lastCheckedAt < (options.intervalMs || CHECK_INTERVAL_MS)) {
|
|
144
|
+
return { skipped: 'not-due', state };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const check = await checkForUpdate(currentVersion, options);
|
|
148
|
+
const nextState = {
|
|
149
|
+
...state,
|
|
150
|
+
lastCheckedAt: new Date(now).toISOString(),
|
|
151
|
+
};
|
|
152
|
+
await writeUpdateState(nextState, env);
|
|
153
|
+
if (!check.updateAvailable) {
|
|
154
|
+
return { ...check, updated: false };
|
|
155
|
+
}
|
|
156
|
+
installUpdate(check.latestVersion, options);
|
|
157
|
+
await writeUpdateState({
|
|
158
|
+
...nextState,
|
|
159
|
+
lastUpdatedVersion: check.latestVersion,
|
|
160
|
+
}, env);
|
|
161
|
+
return { ...check, updated: true };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return { error: error.message };
|
|
164
|
+
}
|
|
165
|
+
}
|