@uzooai/anime-ai-cli 0.0.0-stage → 0.6.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/.agents/skills/uzoo-anime-cli/SKILL.md +89 -0
- package/.agents/skills/uzoo-anime-cli/references/agreement-workflow.md +41 -0
- package/.agents/skills/uzoo-anime-cli/references/canvas-workflow.md +53 -0
- package/.agents/skills/uzoo-anime-cli/references/commands.md +242 -0
- package/.agents/skills/uzoo-anime-cli/references/generation-workflow.md +142 -0
- package/.agents/skills/uzoo-anime-cli/references/image-tools-workflow.md +153 -0
- package/.agents/skills/uzoo-anime-cli/references/interaction-workflow.md +67 -0
- package/.agents/skills/uzoo-anime-cli/references/material-graph-workflow.md +113 -0
- package/.agents/skills/uzoo-anime-cli/references/node-capabilities.md +64 -0
- package/.agents/skills/uzoo-anime-cli/references/node-workflow.md +90 -0
- package/.agents/skills/uzoo-anime-cli/references/remake-workflow.md +54 -0
- package/.agents/skills/uzoo-anime-cli/references/repaint-workflow.md +13 -0
- package/.agents/skills/uzoo-anime-cli/references/seedance-review-workflow.md +37 -0
- package/.agents/skills/uzoo-anime-cli/references/video-tools-workflow.md +67 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +46 -3
- package/assets/favicon-uzoo.png +0 -0
- package/assets/logo-uzoo.svg +7 -0
- package/docs/Agent/346/216/245/345/205/245/344/270/216/347/211/210/346/234/254/346/233/264/346/226/260.md +43 -0
- package/examples/matting.json +5 -0
- package/extensions/uzoo-cli-login/manifest.json +9 -0
- package/extensions/uzoo-cli-login/popup.css +1 -0
- package/extensions/uzoo-cli-login/popup.html +14 -0
- package/extensions/uzoo-cli-login/popup.js +65 -0
- package/package.json +53 -6
- package/src/agreement-page.js +62 -0
- package/src/agreement.js +419 -0
- package/src/annotation-media.js +386 -0
- package/src/api.js +168 -0
- package/src/browser-login.js +13 -0
- package/src/callback-page.js +44 -0
- package/src/canvas.js +105 -0
- package/src/cli-authorization.js +122 -0
- package/src/cli.js +464 -0
- package/src/download-name.js +17 -0
- package/src/draft-finalize.js +229 -0
- package/src/generation-graph.js +124 -0
- package/src/generation-input.js +472 -0
- package/src/graph-disconnect.js +89 -0
- package/src/graph.js +431 -0
- package/src/group-edit.js +53 -0
- package/src/group-layout.js +113 -0
- package/src/group-membership.js +125 -0
- package/src/history-add.js +119 -0
- package/src/image-annotation.js +246 -0
- package/src/image-crop.js +283 -0
- package/src/image-matting.js +82 -0
- package/src/image-media.js +89 -0
- package/src/image-output.js +385 -0
- package/src/install.js +237 -0
- package/src/local-output.js +23 -0
- package/src/material-download.js +55 -0
- package/src/material-save.js +254 -0
- package/src/materials.js +147 -0
- package/src/matting-background.js +73 -0
- package/src/matting-jobs.js +63 -0
- package/src/matting-worker.js +49 -0
- package/src/matting.js +170 -0
- package/src/media-copy.js +53 -0
- package/src/media.js +343 -0
- package/src/model-options.js +76 -0
- package/src/multipart.js +28 -0
- package/src/node-copy.js +95 -0
- package/src/node-delete.js +49 -0
- package/src/node-history.js +231 -0
- package/src/normal-login.js +118 -0
- package/src/package-info.js +8 -0
- package/src/png-alpha.js +60 -0
- package/src/protocol.js +83 -0
- package/src/remake.js +738 -0
- package/src/repaint-archive.js +53 -0
- package/src/repaint-delivery.js +783 -0
- package/src/repaint-export.js +63 -0
- package/src/repaint-graph.js +214 -0
- package/src/repaint-history.js +267 -0
- package/src/repaint-materials.js +601 -0
- package/src/repaint-reverse.js +614 -0
- package/src/repaint-sequence.js +132 -0
- package/src/repaint-settings.js +576 -0
- package/src/repaint-video.js +798 -0
- package/src/repaint.js +326 -0
- package/src/resource.js +110 -0
- package/src/review.js +435 -0
- package/src/service.js +85 -0
- package/src/store.js +100 -0
- package/src/video-clip.js +381 -0
- package/src/video-frame.js +296 -0
- package/src/workflows.js +214 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { atomic } from './store.js'
|
|
5
|
+
import { scopedCanvas } from './materials.js'
|
|
6
|
+
import { segment, uploadParts } from './workflows.js'
|
|
7
|
+
import { bindUpload } from './resource.js'
|
|
8
|
+
import { hashFile } from './media.js'
|
|
9
|
+
const text = (value) => String(value ?? '').trim()
|
|
10
|
+
const digest = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
11
|
+
const fail = (code, message, error) =>
|
|
12
|
+
Object.assign(new Error(message), {
|
|
13
|
+
code,
|
|
14
|
+
...(error
|
|
15
|
+
? {
|
|
16
|
+
cause: error,
|
|
17
|
+
diagnostic: {
|
|
18
|
+
...(error.status ? { http_status: error.status } : {}),
|
|
19
|
+
...(error.apiCode ? { api_code: error.apiCode } : {}),
|
|
20
|
+
...(error.requestId ? { request_id: error.requestId } : {})
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
: {})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Shared persistence/upload contract for locally processed image outputs.
|
|
27
|
+
// Callers own source validation, geometry, decoding and operation journals.
|
|
28
|
+
export function imageOutputWorkflow({
|
|
29
|
+
recordPath,
|
|
30
|
+
readContext,
|
|
31
|
+
nodeTitle,
|
|
32
|
+
nodePosition,
|
|
33
|
+
edgeRole,
|
|
34
|
+
edgePrefix,
|
|
35
|
+
codePrefix,
|
|
36
|
+
mime,
|
|
37
|
+
summarize,
|
|
38
|
+
nodeType = () => 'image_upload'
|
|
39
|
+
}) {
|
|
40
|
+
async function persistNodes(client, profile, record, current) {
|
|
41
|
+
const doc = current.doc
|
|
42
|
+
const existing = record.items.map((item) => doc.nodes.find((node) => node.id === item.nodeId))
|
|
43
|
+
if (existing.every(Boolean)) {
|
|
44
|
+
for (let i = 0; i < existing.length; i++) {
|
|
45
|
+
const node = existing[i],
|
|
46
|
+
item = record.items[i]
|
|
47
|
+
if (
|
|
48
|
+
node.type !== nodeType(record, item) ||
|
|
49
|
+
(node.data?.extra?.node_kind &&
|
|
50
|
+
node.data.extra.node_kind !==
|
|
51
|
+
(nodeType(record, item) === 'image' ? 'generation' : 'asset')) ||
|
|
52
|
+
(node.data?.file_name && node.data.file_name !== item.fileName) ||
|
|
53
|
+
!doc.edges.some(
|
|
54
|
+
(e) =>
|
|
55
|
+
e.source === record.input.node_id && e.target === node.id && e.data?.role === edgeRole
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
throw new Error('图片处理结果节点或来源连线已变化,停止保存')
|
|
59
|
+
}
|
|
60
|
+
record.nodesSaved = true
|
|
61
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (record.nodesSaved || record.saveAttempted || existing.some(Boolean))
|
|
65
|
+
throw fail(
|
|
66
|
+
codePrefix + '_SYNC_PENDING',
|
|
67
|
+
'图片处理节点/连线缺失或保存结果不明;先核对用户删除/画布内容,不自动重建'
|
|
68
|
+
)
|
|
69
|
+
if (
|
|
70
|
+
!Number.isSafeInteger(doc.version) ||
|
|
71
|
+
!doc.viewport ||
|
|
72
|
+
!doc.meta ||
|
|
73
|
+
!Array.isArray(doc.edges)
|
|
74
|
+
)
|
|
75
|
+
throw new Error('图片处理画布快照不完整')
|
|
76
|
+
const nodes = structuredClone(doc.nodes),
|
|
77
|
+
edges = structuredClone(doc.edges)
|
|
78
|
+
for (const item of record.items) {
|
|
79
|
+
const node = {
|
|
80
|
+
id: item.nodeId,
|
|
81
|
+
type: nodeType(record, item),
|
|
82
|
+
position: nodePosition(nodes, current.node),
|
|
83
|
+
data: {
|
|
84
|
+
title: nodeTitle(item),
|
|
85
|
+
mediaType: 'image',
|
|
86
|
+
url: '',
|
|
87
|
+
file_name: item.fileName,
|
|
88
|
+
file_size: item.local.bytes,
|
|
89
|
+
width: item.local.width,
|
|
90
|
+
height: item.local.height,
|
|
91
|
+
uploading: true,
|
|
92
|
+
...(nodeType(record, item) === 'image'
|
|
93
|
+
? {
|
|
94
|
+
status: 'processing',
|
|
95
|
+
runtime: { status: 'processing', operation: 'upload', progress: 0, error: '' }
|
|
96
|
+
}
|
|
97
|
+
: {}),
|
|
98
|
+
extra: {
|
|
99
|
+
node_kind: nodeType(record, item) === 'image_upload' ? 'asset' : 'generation',
|
|
100
|
+
aspect_ratio: item.local.width + ':' + item.local.height
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
nodes.push(node)
|
|
105
|
+
edges.push({
|
|
106
|
+
id: edgePrefix + item.nodeId,
|
|
107
|
+
source: record.input.node_id,
|
|
108
|
+
target: item.nodeId,
|
|
109
|
+
sourceHandle: '',
|
|
110
|
+
targetHandle: '',
|
|
111
|
+
type: 'default',
|
|
112
|
+
data: { role: edgeRole }
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
record.saveAttempted = true
|
|
116
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
117
|
+
try {
|
|
118
|
+
const saved = await client.raw(
|
|
119
|
+
'/canvas/docs/' + segment(doc.id),
|
|
120
|
+
{
|
|
121
|
+
method: 'PUT',
|
|
122
|
+
body: {
|
|
123
|
+
version: doc.version,
|
|
124
|
+
name: doc.name,
|
|
125
|
+
viewport: doc.viewport,
|
|
126
|
+
nodes,
|
|
127
|
+
edges,
|
|
128
|
+
meta: doc.meta
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
{ retry: false }
|
|
132
|
+
)
|
|
133
|
+
if (
|
|
134
|
+
saved.id !== doc.id ||
|
|
135
|
+
saved.version <= doc.version ||
|
|
136
|
+
record.items.some(
|
|
137
|
+
(item) =>
|
|
138
|
+
!saved.nodes?.some((n) => n.id === item.nodeId) ||
|
|
139
|
+
!saved.edges?.some((e) => e.id === edgePrefix + item.nodeId)
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
throw new Error('节点保存响应不完整')
|
|
143
|
+
record.nodesSaved = true
|
|
144
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
145
|
+
} catch (error) {
|
|
146
|
+
record.state = 'sync_pending'
|
|
147
|
+
if (error.status === 409 || error.apiCode === 3002) {
|
|
148
|
+
record.saveAttempted = false
|
|
149
|
+
}
|
|
150
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
151
|
+
throw fail(
|
|
152
|
+
codePrefix + '_SYNC_PENDING',
|
|
153
|
+
'图片处理已在本地导出,节点保存未确认;查询原画布后恢复,不重复处理',
|
|
154
|
+
error
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function validateUpload(response, record, item) {
|
|
159
|
+
if (
|
|
160
|
+
response?.source_doc_id !== record.input.doc_id ||
|
|
161
|
+
response.source_node_id !== item.nodeId ||
|
|
162
|
+
response.segment_type !== 'image' ||
|
|
163
|
+
!response.result?.file_ref ||
|
|
164
|
+
!response.result.url ||
|
|
165
|
+
!/^image\//.test(response.result.mime_type || '') ||
|
|
166
|
+
response.result.width !== item.local.width ||
|
|
167
|
+
response.result.height !== item.local.height ||
|
|
168
|
+
!/^[1-9]\d*$/.test(text(response.task_id))
|
|
169
|
+
)
|
|
170
|
+
throw fail(
|
|
171
|
+
codePrefix + '_UPLOAD_UNKNOWN',
|
|
172
|
+
'上传响应缺少真实图片/任务关联;查询原节点和任务,不重复上传'
|
|
173
|
+
)
|
|
174
|
+
return response
|
|
175
|
+
}
|
|
176
|
+
async function reconcile(client, record, item) {
|
|
177
|
+
const list = await client.raw(
|
|
178
|
+
'/canvas/generations?' +
|
|
179
|
+
new URLSearchParams({
|
|
180
|
+
doc_id: record.input.doc_id,
|
|
181
|
+
node_id: item.nodeId,
|
|
182
|
+
status: 'completed',
|
|
183
|
+
limit: '100'
|
|
184
|
+
})
|
|
185
|
+
)
|
|
186
|
+
const current = await readContext(client, record.input)
|
|
187
|
+
if (digest(current.binding) !== digest(record.binding))
|
|
188
|
+
throw new Error('恢复上传前源素材已变化,停止')
|
|
189
|
+
const node = current.doc.nodes.find((n) => n.id === item.nodeId)
|
|
190
|
+
const uploadTask = String(node?.data?.task_id || '')
|
|
191
|
+
const uploadRef = node?.data?.file_ref
|
|
192
|
+
const candidates = list.items?.filter(
|
|
193
|
+
(task) =>
|
|
194
|
+
task.doc_id === record.input.doc_id &&
|
|
195
|
+
task.node_id === item.nodeId &&
|
|
196
|
+
task.segment_type === 'image' &&
|
|
197
|
+
(task.model_name === 'local-upload' ||
|
|
198
|
+
(uploadTask &&
|
|
199
|
+
String(task.task_id ?? task.id) === uploadTask &&
|
|
200
|
+
uploadRef &&
|
|
201
|
+
task.result?.file_ref === uploadRef &&
|
|
202
|
+
task.credit_cost === 0 &&
|
|
203
|
+
task.billing_status === 'charged')) &&
|
|
204
|
+
task.status === 'completed' &&
|
|
205
|
+
task.result?.filename === item.fileName &&
|
|
206
|
+
task.result?.file_ref &&
|
|
207
|
+
task.result?.url
|
|
208
|
+
)
|
|
209
|
+
if (candidates?.length !== 1)
|
|
210
|
+
throw fail(
|
|
211
|
+
codePrefix + '_UPLOAD_UNKNOWN',
|
|
212
|
+
'原上传 complete 结果未知;原节点未查到唯一真实任务,不重新 complete 或重传'
|
|
213
|
+
)
|
|
214
|
+
const task = candidates[0]
|
|
215
|
+
item.response = validateUpload(
|
|
216
|
+
{
|
|
217
|
+
source_doc_id: task.doc_id,
|
|
218
|
+
source_node_id: task.node_id,
|
|
219
|
+
segment_type: 'image',
|
|
220
|
+
task_id: task.task_id ?? task.id,
|
|
221
|
+
result: task.result
|
|
222
|
+
},
|
|
223
|
+
record,
|
|
224
|
+
item
|
|
225
|
+
)
|
|
226
|
+
item.state = 'uploaded'
|
|
227
|
+
}
|
|
228
|
+
async function runUploads(client, profile, record) {
|
|
229
|
+
record.state = 'uploading'
|
|
230
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
231
|
+
for (const item of record.items) {
|
|
232
|
+
const current = await readContext(client, record.input)
|
|
233
|
+
if (digest(record.binding) !== digest(current.binding))
|
|
234
|
+
throw new Error('上传前源素材已变化,停止')
|
|
235
|
+
const node = current.doc.nodes.find((node) => node.id === item.nodeId)
|
|
236
|
+
if (
|
|
237
|
+
!node ||
|
|
238
|
+
node.type !== nodeType(record, item) ||
|
|
239
|
+
!current.doc.edges.some(
|
|
240
|
+
(e) =>
|
|
241
|
+
e.source === record.input.node_id &&
|
|
242
|
+
e.target === item.nodeId &&
|
|
243
|
+
e.data?.role === edgeRole
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
throw new Error('图片处理节点或连线已被删除,不自动重建')
|
|
247
|
+
const currentRef =
|
|
248
|
+
node.data?.file_ref || node.data?.extra?.file_ref || node.data?.result?.file_ref
|
|
249
|
+
if (
|
|
250
|
+
currentRef &&
|
|
251
|
+
currentRef !== item.response?.result?.file_ref &&
|
|
252
|
+
!['completing', 'unknown'].includes(item.state)
|
|
253
|
+
)
|
|
254
|
+
throw new Error('图片处理节点已换成其他素材;保留用户编辑')
|
|
255
|
+
if (['completing', 'unknown'].includes(item.state)) {
|
|
256
|
+
record.state = 'upload_unknown'
|
|
257
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
258
|
+
await reconcile(client, record, item)
|
|
259
|
+
record.state = 'uploading'
|
|
260
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
261
|
+
}
|
|
262
|
+
if (item.state === 'session_unknown') {
|
|
263
|
+
record.state = 'upload_unknown'
|
|
264
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
265
|
+
throw fail(
|
|
266
|
+
codePrefix + '_UPLOAD_UNKNOWN',
|
|
267
|
+
'上传会话创建结果未知,停止;不自动创建第二个会话'
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
if (!item.response) {
|
|
271
|
+
const image = path.join(record.directory, item.fileName)
|
|
272
|
+
const local = await hashFile(image)
|
|
273
|
+
if (local.sha256 !== item.local.sha256 || local.bytes !== item.local.bytes)
|
|
274
|
+
throw new Error('本地图片处理产物发生变化,不上传')
|
|
275
|
+
const endpoint =
|
|
276
|
+
'/canvas/docs/' + segment(record.input.doc_id) + '/nodes/' + segment(item.nodeId)
|
|
277
|
+
if (!item.session) {
|
|
278
|
+
item.state = 'session_unknown'
|
|
279
|
+
record.state = 'upload_unknown'
|
|
280
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
281
|
+
try {
|
|
282
|
+
item.session = await client.raw(
|
|
283
|
+
endpoint + '/upload-session',
|
|
284
|
+
{
|
|
285
|
+
method: 'POST',
|
|
286
|
+
body: {
|
|
287
|
+
filename: item.fileName,
|
|
288
|
+
file_size: local.bytes,
|
|
289
|
+
expected_type: 'image',
|
|
290
|
+
mime_type: mime
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
{ retry: false }
|
|
294
|
+
)
|
|
295
|
+
} catch (error) {
|
|
296
|
+
throw fail(
|
|
297
|
+
codePrefix + '_UPLOAD_UNKNOWN',
|
|
298
|
+
'创建图片处理上传会话未确认;停止并保留原操作',
|
|
299
|
+
error
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
item.state = 'uploading'
|
|
303
|
+
record.state = 'uploading'
|
|
304
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
305
|
+
}
|
|
306
|
+
if (!item.parts) {
|
|
307
|
+
const handle = await fs.open(image, 'r')
|
|
308
|
+
try {
|
|
309
|
+
item.parts = await uploadParts(client, handle, await handle.stat(), item.session, mime)
|
|
310
|
+
} finally {
|
|
311
|
+
await handle.close()
|
|
312
|
+
}
|
|
313
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
314
|
+
}
|
|
315
|
+
item.state = 'completing'
|
|
316
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
317
|
+
try {
|
|
318
|
+
item.response = validateUpload(
|
|
319
|
+
await client.raw(
|
|
320
|
+
endpoint + '/upload-complete',
|
|
321
|
+
{
|
|
322
|
+
method: 'POST',
|
|
323
|
+
body: {
|
|
324
|
+
upload_id: item.session.upload_id,
|
|
325
|
+
object_key: item.session.object_key,
|
|
326
|
+
expected_parts: item.parts.length,
|
|
327
|
+
parts: item.parts
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
{ retry: false }
|
|
331
|
+
),
|
|
332
|
+
record,
|
|
333
|
+
item
|
|
334
|
+
)
|
|
335
|
+
} catch (error) {
|
|
336
|
+
item.state = 'unknown'
|
|
337
|
+
record.state = 'upload_unknown'
|
|
338
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
339
|
+
throw fail(
|
|
340
|
+
codePrefix + '_UPLOAD_UNKNOWN',
|
|
341
|
+
'图片处理上传完成结果未确认;只查询原节点/任务恢复,不再次 complete',
|
|
342
|
+
error
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
item.state = 'uploaded'
|
|
346
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
347
|
+
}
|
|
348
|
+
if (!item.bound) {
|
|
349
|
+
try {
|
|
350
|
+
await bindUpload(client, {
|
|
351
|
+
docId: record.input.doc_id,
|
|
352
|
+
nodeId: item.nodeId,
|
|
353
|
+
result: item.response,
|
|
354
|
+
imageNode: nodeType(record, item) === 'image'
|
|
355
|
+
})
|
|
356
|
+
// Clear the preparation flag only after real upload binding succeeds.
|
|
357
|
+
const doc = await scopedCanvas(
|
|
358
|
+
client,
|
|
359
|
+
record.input.doc_id,
|
|
360
|
+
record.input.space,
|
|
361
|
+
record.input.team_uid
|
|
362
|
+
)
|
|
363
|
+
const node = doc.nodes.find((n) => n.id === item.nodeId)
|
|
364
|
+
if (node?.data?.extra?.file_ref !== item.response.result.file_ref)
|
|
365
|
+
throw new Error('图片处理引用读回不一致')
|
|
366
|
+
item.bound = true
|
|
367
|
+
item.state = 'completed'
|
|
368
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
369
|
+
} catch (error) {
|
|
370
|
+
record.state = 'sync_pending'
|
|
371
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
372
|
+
throw fail(
|
|
373
|
+
codePrefix + '_SYNC_PENDING',
|
|
374
|
+
'图片已上传,画布绑定未确认;恢复原响应,不重传图片',
|
|
375
|
+
error
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
record.state = 'completed'
|
|
381
|
+
await atomic(recordPath(profile, record.operation), record)
|
|
382
|
+
return summarize(record)
|
|
383
|
+
}
|
|
384
|
+
return { persistNodes, runUploads }
|
|
385
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import { createInterface } from 'node:readline/promises'
|
|
8
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
9
|
+
import { packageInfo } from './package-info.js'
|
|
10
|
+
|
|
11
|
+
const exec = promisify(execFile)
|
|
12
|
+
const skillName = 'uzoo-anime-cli'
|
|
13
|
+
const receipt = '.uzoo-install.json'
|
|
14
|
+
const bundle = path.join('.agents', 'skills', skillName)
|
|
15
|
+
const registry = 'https://registry.npmjs.org/'
|
|
16
|
+
const agents = {
|
|
17
|
+
codex: { label: 'Codex', config: '.agents', detect: '.codex' },
|
|
18
|
+
workbuddy: { label: 'WorkBuddy', config: '.workbuddy' },
|
|
19
|
+
'claude-code': { label: 'Claude Code', config: '.claude' },
|
|
20
|
+
}
|
|
21
|
+
const help = `安装或更新同版本的 CLI 和 Skill(用户级)
|
|
22
|
+
install 检测并选择一个或多个 Agent
|
|
23
|
+
install --agent codex --agent workbuddy
|
|
24
|
+
install --agent codex --yes 非交互,须明确指定 Agent
|
|
25
|
+
install --agent workbuddy --dry-run 仅预览,不安装
|
|
26
|
+
Agent:codex、workbuddy、claude-code
|
|
27
|
+
原 npm install -g 仍只安装 CLI 和随包文件,不写 Agent 技能目录。`
|
|
28
|
+
|
|
29
|
+
export function parseInstall(args) {
|
|
30
|
+
const selected = [], flags = new Set()
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const arg = args[i]
|
|
33
|
+
if (arg === '--agent') {
|
|
34
|
+
const id = args[++i]?.toLowerCase()
|
|
35
|
+
if (!Object.hasOwn(agents, id)) throw new Error('未知或缺少 --agent;可选 codex、workbuddy、claude-code')
|
|
36
|
+
if (!selected.includes(id)) selected.push(id)
|
|
37
|
+
} else if (['--yes', '--dry-run', '--help', '-h'].includes(arg) && !flags.has(arg)) {
|
|
38
|
+
flags.add(arg)
|
|
39
|
+
} else throw new Error(`未知或重复的安装参数:${arg}`)
|
|
40
|
+
}
|
|
41
|
+
if (flags.has('--yes') && !selected.length) throw new Error('--yes 需要明确指定至少一个 --agent')
|
|
42
|
+
return { selected, yes: flags.has('--yes'), dryRun: flags.has('--dry-run'), help: flags.has('--help') || flags.has('-h') }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function stat(file) {
|
|
46
|
+
try { return await fs.lstat(file) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function safeDirectories(...directories) {
|
|
50
|
+
for (const dir of directories) {
|
|
51
|
+
const s = await stat(dir)
|
|
52
|
+
if (s && (!s.isDirectory() || s.isSymbolicLink())) throw new Error(`不是普通目录,未修改:${dir}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function agentTargets(home) {
|
|
57
|
+
return Promise.all(Object.entries(agents).map(async ([id, agent]) => {
|
|
58
|
+
const config = path.join(home, agent.config)
|
|
59
|
+
const root = path.join(config, 'skills')
|
|
60
|
+
const detected = Boolean((await stat(path.join(home, agent.detect || agent.config)))?.isDirectory())
|
|
61
|
+
return { id, label: agent.label, config, root, target: path.join(root, skillName), detected }
|
|
62
|
+
}))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function fingerprint(directory) {
|
|
66
|
+
const hash = createHash('sha256')
|
|
67
|
+
async function walk(dir, prefix = '') {
|
|
68
|
+
for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : a.name === b.name ? 0 : 1)) {
|
|
69
|
+
if (!prefix && entry.name === receipt) continue
|
|
70
|
+
const file = path.join(dir, entry.name), name = prefix + entry.name
|
|
71
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
72
|
+
hash.update(JSON.stringify(['directory', name]))
|
|
73
|
+
await walk(file, name + '/')
|
|
74
|
+
} else if (entry.isFile() && !entry.isSymbolicLink()) {
|
|
75
|
+
hash.update(JSON.stringify(['file', name, createHash('sha256').update(await fs.readFile(file)).digest('hex')]))
|
|
76
|
+
} else throw new Error(`技能中存在链接或特殊文件:${file}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await safeDirectories(directory)
|
|
80
|
+
await walk(directory)
|
|
81
|
+
return hash.digest('hex')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function inspect(target) {
|
|
85
|
+
await safeDirectories(target.config, target.root, target.target)
|
|
86
|
+
if (!await stat(target.target)) return { kind: 'new' }
|
|
87
|
+
const marker = path.join(target.target, receipt)
|
|
88
|
+
if (!(await stat(marker))?.isFile()) return { kind: 'protected' }
|
|
89
|
+
let meta
|
|
90
|
+
try { meta = JSON.parse(await fs.readFile(marker, 'utf8')) } catch (error) {
|
|
91
|
+
if (!(error instanceof SyntaxError)) throw error
|
|
92
|
+
return { kind: 'protected' }
|
|
93
|
+
}
|
|
94
|
+
if (meta?.package !== packageInfo.name || meta?.schema !== 1) return { kind: 'protected' }
|
|
95
|
+
const digest = await fingerprint(target.target)
|
|
96
|
+
if (meta.digest !== digest) return { kind: 'protected' }
|
|
97
|
+
return { kind: 'managed', digest, version: meta.version }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function verifyBundle(directory) {
|
|
101
|
+
if (!(await stat(path.join(directory, 'SKILL.md')))?.isFile() ||
|
|
102
|
+
!(await stat(path.join(directory, 'references')))?.isDirectory() ||
|
|
103
|
+
await stat(path.join(directory, receipt))) throw new Error('npm 包内 Skill 不完整或含安装记录')
|
|
104
|
+
return fingerprint(directory)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function renameSkill(from, to) {
|
|
108
|
+
// Windows indexers can briefly hold a handle to freshly copied files.
|
|
109
|
+
for (const wait of [25, 50, 100, 200, 400, 0]) {
|
|
110
|
+
if (await stat(to)) throw new Error(`目标目录已存在,未覆盖:${to}`)
|
|
111
|
+
try { await fs.rename(from, to); return } catch (error) {
|
|
112
|
+
if (process.platform !== 'win32' || !wait || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) throw error
|
|
113
|
+
await delay(wait)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function installSkill(target, directory, digest, version, expected) {
|
|
119
|
+
const current = await inspect(target)
|
|
120
|
+
if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error(`${target.label} 的 Skill 在确认后有变化,请重新预览`)
|
|
121
|
+
if (current.digest === digest && current.version === version) return 'unchanged'
|
|
122
|
+
await fs.mkdir(target.root, { recursive: true })
|
|
123
|
+
const stage = await fs.mkdtemp(path.join(target.config, '.uzoo-install-'))
|
|
124
|
+
const next = path.join(stage, 'next'), previous = path.join(stage, 'previous')
|
|
125
|
+
let moved = false
|
|
126
|
+
try {
|
|
127
|
+
await fs.cp(directory, next, { recursive: true, errorOnExist: true, force: false })
|
|
128
|
+
if (await fingerprint(next) !== digest) throw new Error('Skill 暂存校验失败')
|
|
129
|
+
await fs.writeFile(path.join(next, receipt), JSON.stringify({ schema: 1, package: packageInfo.name, version, digest }) + '\n', { flag: 'wx' })
|
|
130
|
+
if (JSON.stringify(await inspect(target)) !== JSON.stringify(current)) throw new Error(`${target.label} 的 Skill 已变化,请重新预览`)
|
|
131
|
+
if (current.kind === 'managed') {
|
|
132
|
+
await renameSkill(target.target, previous); moved = true
|
|
133
|
+
// Preserve an edit made between the final check and the directory move.
|
|
134
|
+
if (await fingerprint(previous) !== current.digest) throw new Error(`${target.label} 的 Skill 在替换前有变化`)
|
|
135
|
+
}
|
|
136
|
+
await renameSkill(next, target.target)
|
|
137
|
+
moved = false
|
|
138
|
+
return current.kind === 'new' ? 'installed' : 'updated'
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (moved) {
|
|
141
|
+
try { await renameSkill(previous, target.target); moved = false } catch {
|
|
142
|
+
throw new Error(`${error.message};原 Skill 保留于 ${previous}`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw error
|
|
146
|
+
} finally {
|
|
147
|
+
if (!moved) await fs.rm(stage, { recursive: true, force: true }).catch(() => {})
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function runNpm(args, signal) {
|
|
152
|
+
const npmCli = process.env.npm_execpath
|
|
153
|
+
const options = { timeout: 120_000, windowsHide: true, signal }
|
|
154
|
+
if (npmCli && path.isAbsolute(npmCli) && path.basename(npmCli) === 'npm-cli.js') {
|
|
155
|
+
return (await exec(process.execPath, [npmCli, ...args], options)).stdout.trim()
|
|
156
|
+
}
|
|
157
|
+
if (process.platform !== 'win32') return (await exec('npm', args, options)).stdout.trim()
|
|
158
|
+
// cmd is needed for npm.cmd. Only the fixed command and validated tokens may reach it.
|
|
159
|
+
if (args.some(arg => !/^[\w.@:/-]+$/.test(arg))) throw new Error('不支持的 npm 参数')
|
|
160
|
+
return (await exec('cmd.exe', ['/d', '/c', 'npm', ...args], options)).stdout.trim()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function runInstall(args, {
|
|
164
|
+
home = homedir(), npm = runNpm, input = process.stdin, output = process.stdout,
|
|
165
|
+
log = console.log, version = packageInfo.version, signal,
|
|
166
|
+
} = {}) {
|
|
167
|
+
const opts = parseInstall(args)
|
|
168
|
+
if (opts.help) { log(help); return }
|
|
169
|
+
if (!/^\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(version)) throw new Error('安装入口要求明确的包版本')
|
|
170
|
+
const pkg = `${packageInfo.name}@${version}`
|
|
171
|
+
const all = await agentTargets(home)
|
|
172
|
+
let selected = all.filter(agent => opts.selected.includes(agent.id))
|
|
173
|
+
const detected = all.filter(agent => agent.detected)
|
|
174
|
+
const interactive = input.isTTY && output.isTTY
|
|
175
|
+
let rl
|
|
176
|
+
const ask = async prompt => {
|
|
177
|
+
rl ??= createInterface({ input, output })
|
|
178
|
+
return (await rl.question(prompt, { signal })).trim()
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
if (!selected.length && opts.dryRun) {
|
|
182
|
+
log(JSON.stringify({ package: pkg, dry_run: true, detected: detected.map(({ id, target }) => ({ id, target })) }, null, 2))
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (!interactive && !opts.dryRun && !opts.yes) throw new Error('非交互安装需指定 --agent NAME --yes;可先运行 install --dry-run')
|
|
186
|
+
if (!selected.length) {
|
|
187
|
+
const choices = detected.length ? detected : all
|
|
188
|
+
log(detected.length ? '检测到以下 Agent:' : '未检测到 Agent,可手动选择已安装的宿主:')
|
|
189
|
+
choices.forEach(({ label, target }, i) => log(` ${i + 1}. ${label} ${target}`))
|
|
190
|
+
if (detected.length && detected.length < all.length) log('其他宿主可通过 --agent 明确指定。')
|
|
191
|
+
const answer = await ask('输入一个或多个序号(空格或逗号分隔,回车取消):')
|
|
192
|
+
if (!answer) { log('已取消,未安装。'); return }
|
|
193
|
+
const indexes = [...new Set(answer.split(/[\s,,]+/))]
|
|
194
|
+
if (indexes.some(i => !/^[1-9]\d*$/.test(i) || Number(i) > choices.length)) throw new Error('Agent 序号无效')
|
|
195
|
+
selected = indexes.map(i => choices[Number(i) - 1])
|
|
196
|
+
}
|
|
197
|
+
const states = await Promise.all(selected.map(inspect))
|
|
198
|
+
const blocked = selected.filter((_, i) => states[i].kind === 'protected')
|
|
199
|
+
log(JSON.stringify({ package: pkg, dry_run: opts.dryRun, skills: selected.map(({ id, target }, i) => ({ id, target, state: states[i].kind })) }, null, 2))
|
|
200
|
+
if (opts.dryRun) return
|
|
201
|
+
if (blocked.length) throw new Error(`已有手动安装或改动过的 Skill:${blocked.map(x => x.target).join(';')}。未安装;请先备份至扫描目录之外,或只选择其它 Agent。`)
|
|
202
|
+
if (!opts.yes && !/^(?:y|yes)$/i.test(await ask('安装或更新以上 CLI 和 Skill?[y/N] '))) { log('已取消,未安装。'); return }
|
|
203
|
+
|
|
204
|
+
signal?.throwIfAborted()
|
|
205
|
+
const root = await npm(['root', '--global'], signal)
|
|
206
|
+
if (!path.isAbsolute(root)) throw new Error('无法定位 npm 全局目录')
|
|
207
|
+
const directory = path.join(root, ...packageInfo.name.split('/'))
|
|
208
|
+
const metadata = async () => {
|
|
209
|
+
try { return JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8')) } catch (error) {
|
|
210
|
+
if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
let installed = await metadata()
|
|
214
|
+
if (installed?.name !== packageInfo.name || installed?.version !== version) {
|
|
215
|
+
await npm(['install', '--global', pkg, '--registry=' + registry, '--ignore-scripts', '--no-audit', '--no-fund'], signal)
|
|
216
|
+
installed = await metadata()
|
|
217
|
+
}
|
|
218
|
+
if (installed?.name !== packageInfo.name || installed?.version !== version || installed?.bin?.['uzoo-anime'] !== 'src/cli.js') {
|
|
219
|
+
throw new Error('全局 CLI 包身份、版本或入口验证失败,未写入 Skill')
|
|
220
|
+
}
|
|
221
|
+
const entry = path.join(directory, 'src', 'cli.js')
|
|
222
|
+
const prefix = await npm(['prefix', '--global'], signal)
|
|
223
|
+
if (!path.isAbsolute(prefix)) throw new Error('无法定位 npm 全局命令目录,未写入 Skill')
|
|
224
|
+
const bin = process.platform === 'win32' ? path.join(prefix, 'uzoo-anime.cmd') : path.join(prefix, 'bin', 'uzoo-anime')
|
|
225
|
+
const result = await exec(process.execPath, [entry, '--version'], { timeout: 15_000, windowsHide: true, signal })
|
|
226
|
+
if (!await stat(bin) || result.stdout.trim() !== version) throw new Error('全局 CLI 命令或版本验证失败,未写入 Skill')
|
|
227
|
+
const source = path.join(directory, bundle)
|
|
228
|
+
const digest = await verifyBundle(source)
|
|
229
|
+
for (let i = 0; i < selected.length; i++) {
|
|
230
|
+
signal?.throwIfAborted()
|
|
231
|
+
const status = await installSkill(selected[i], source, digest, version, states[i])
|
|
232
|
+
log(`${selected[i].label}: ${status} — ${selected[i].target}`)
|
|
233
|
+
}
|
|
234
|
+
log(`CLI ${version}: ${bin}`)
|
|
235
|
+
log('请在目标 Agent 的新会话验证 uzoo-anime --version;若命令不可见,请检查 npm 全局命令目录是否在其 PATH。')
|
|
236
|
+
} finally { rl?.close() }
|
|
237
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
// A failed download/write never occupies the requested filename. Linking in the
|
|
6
|
+
// same directory publishes a complete file without replacing an existing file.
|
|
7
|
+
export async function exclusiveOutput(output, write) {
|
|
8
|
+
const target = path.resolve(output)
|
|
9
|
+
const temporary = path.join(path.dirname(target), '.' + crypto.randomUUID() + '.part')
|
|
10
|
+
try {
|
|
11
|
+
try {
|
|
12
|
+
await fs.lstat(target)
|
|
13
|
+
throw Object.assign(new Error('目标文件已存在;请指定新文件,不覆盖'), { code: 'EEXIST' })
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error.code !== 'ENOENT') throw error
|
|
16
|
+
}
|
|
17
|
+
const result = await write(temporary)
|
|
18
|
+
await fs.link(temporary, target)
|
|
19
|
+
return { output: target, ...result }
|
|
20
|
+
} finally {
|
|
21
|
+
await fs.rm(temporary, { force: true })
|
|
22
|
+
}
|
|
23
|
+
}
|