@alchemint/fuse-cli 0.0.0-stage → 0.1.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 +48 -2
- package/package.json +20 -4
- package/src/assets.mjs +386 -0
- package/src/auth.mjs +132 -0
- package/src/batch.mjs +523 -0
- package/src/core.mjs +353 -0
- package/src/creative.mjs +379 -0
- package/src/download.mjs +121 -0
- package/src/element-images.mjs +257 -0
- package/src/main.mjs +276 -0
- package/src/mutations.mjs +125 -0
- package/src/prompt.mjs +121 -0
- package/src/versions.mjs +358 -0
- package/src/video.mjs +349 -0
package/src/core.mjs
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
open,
|
|
5
|
+
readFile,
|
|
6
|
+
rename,
|
|
7
|
+
link,
|
|
8
|
+
unlink,
|
|
9
|
+
stat,
|
|
10
|
+
chmod,
|
|
11
|
+
} from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
|
|
15
|
+
export class CliError extends Error {
|
|
16
|
+
constructor(exit, code, message, detail = {}) {
|
|
17
|
+
super(message);
|
|
18
|
+
Object.assign(this, {
|
|
19
|
+
exit,
|
|
20
|
+
code,
|
|
21
|
+
kind:
|
|
22
|
+
['usage', 'auth', 'rejected', 'unknown', 'timeout', 'job_failed'][
|
|
23
|
+
exit - 2
|
|
24
|
+
] || 'error',
|
|
25
|
+
...detail,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export const need = (value, name) => {
|
|
30
|
+
if (value === undefined || value === '')
|
|
31
|
+
throw new CliError(2, 'INPUT_REQUIRED', `Required: ${name}`);
|
|
32
|
+
return value;
|
|
33
|
+
};
|
|
34
|
+
export const id = (value, name) => encodeURIComponent(need(value, name));
|
|
35
|
+
export function duration(value = '30m') {
|
|
36
|
+
const match = /^(\d+)(s|m|h)?$/.exec(String(value));
|
|
37
|
+
if (!match || Number(match[1]) === 0)
|
|
38
|
+
throw new CliError(
|
|
39
|
+
2,
|
|
40
|
+
'INVALID_TIMEOUT',
|
|
41
|
+
'Use a positive duration such as 30s, 30m, or 2h.',
|
|
42
|
+
);
|
|
43
|
+
return Number(match[1]) * { s: 1000, m: 60000, h: 3600000 }[match[2] || 's'];
|
|
44
|
+
}
|
|
45
|
+
export function canonical(value) {
|
|
46
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
47
|
+
if (value && typeof value === 'object')
|
|
48
|
+
return `{${Object.keys(value)
|
|
49
|
+
.sort()
|
|
50
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
|
|
51
|
+
.join(',')}}`;
|
|
52
|
+
return JSON.stringify(value);
|
|
53
|
+
}
|
|
54
|
+
export const digest = (value) =>
|
|
55
|
+
createHash('sha256')
|
|
56
|
+
.update(typeof value === 'string' ? value : canonical(value))
|
|
57
|
+
.digest('hex');
|
|
58
|
+
export async function readJson(file) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(await readFile(need(file, 'JSON file'), 'utf8'));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof CliError) throw error;
|
|
63
|
+
throw new CliError(2, 'INVALID_FILE', `Cannot read JSON file: ${file}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function exists(file) {
|
|
67
|
+
try {
|
|
68
|
+
await stat(file);
|
|
69
|
+
return true;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error.code === 'ENOENT') return false;
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function writeJson(file, data, exclusive = false) {
|
|
76
|
+
file = path.resolve(file);
|
|
77
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
78
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
79
|
+
const handle = await open(temp, 'wx', 0o600);
|
|
80
|
+
try {
|
|
81
|
+
await handle.writeFile(`${JSON.stringify(data, null, 2)}\n`);
|
|
82
|
+
await handle.sync();
|
|
83
|
+
} finally {
|
|
84
|
+
await handle.close();
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
if (exclusive) {
|
|
88
|
+
await link(temp, file);
|
|
89
|
+
await unlink(temp);
|
|
90
|
+
} else await rename(temp, file);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
await unlink(temp);
|
|
93
|
+
if (error.code === 'EEXIST')
|
|
94
|
+
throw new CliError(2, 'FILE_EXISTS', `File already exists: ${file}`);
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
const directory = await open(path.dirname(file), 'r');
|
|
98
|
+
try {
|
|
99
|
+
await directory.sync();
|
|
100
|
+
} finally {
|
|
101
|
+
await directory.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function cleanMessage(message) {
|
|
105
|
+
return String(message)
|
|
106
|
+
.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]')
|
|
107
|
+
.replace(/https?:\/\/\S+/g, '[URL]')
|
|
108
|
+
.slice(0, 500);
|
|
109
|
+
}
|
|
110
|
+
export function errorData(error) {
|
|
111
|
+
return {
|
|
112
|
+
kind: error.kind || 'error',
|
|
113
|
+
code: error.code || 'LOCAL_ERROR',
|
|
114
|
+
message: cleanMessage(error.message),
|
|
115
|
+
http_status: error.http_status ?? null,
|
|
116
|
+
request_id: error.request_id ?? null,
|
|
117
|
+
...(error.receipt ? { receipt: error.receipt } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export function project(data, fields) {
|
|
121
|
+
if (!fields) return data;
|
|
122
|
+
const keys = fields.split(',');
|
|
123
|
+
const one = (item) =>
|
|
124
|
+
Object.fromEntries(
|
|
125
|
+
keys.map((key) => [
|
|
126
|
+
key,
|
|
127
|
+
key.split('.').reduce((value, part) => value?.[part], item) ?? null,
|
|
128
|
+
]),
|
|
129
|
+
);
|
|
130
|
+
return Array.isArray(data) ? data.map(one) : one(data);
|
|
131
|
+
}
|
|
132
|
+
export function apiBase(
|
|
133
|
+
input = process.env.FUSE_API_URL || 'https://fuse.alchemint.xyz/api',
|
|
134
|
+
) {
|
|
135
|
+
const url = new URL(input);
|
|
136
|
+
if (
|
|
137
|
+
url.username ||
|
|
138
|
+
url.password ||
|
|
139
|
+
url.search ||
|
|
140
|
+
url.hash ||
|
|
141
|
+
!['', '/', '/api', '/api/'].includes(url.pathname) ||
|
|
142
|
+
(url.protocol !== 'https:' &&
|
|
143
|
+
!(
|
|
144
|
+
url.protocol === 'http:' &&
|
|
145
|
+
['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|
|
146
|
+
))
|
|
147
|
+
) {
|
|
148
|
+
throw new CliError(
|
|
149
|
+
2,
|
|
150
|
+
'INVALID_API_URL',
|
|
151
|
+
'Use the fixed HTTPS FUSE origin, or local HTTP localhost, with /api.',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return `${url.origin}/api`;
|
|
155
|
+
}
|
|
156
|
+
export const configRoot = () =>
|
|
157
|
+
process.env.FUSE_CONFIG_DIR || path.join(os.homedir(), '.config', 'fuse');
|
|
158
|
+
export const authDirectory = (base) => path.join(configRoot(), digest(base));
|
|
159
|
+
export async function loadCredential(base) {
|
|
160
|
+
if (process.env.FUSE_TOKEN) return process.env.FUSE_TOKEN;
|
|
161
|
+
const active = path.join(authDirectory(base), 'active.json');
|
|
162
|
+
if (!(await exists(active))) return null;
|
|
163
|
+
const selected = await readJson(active);
|
|
164
|
+
return (
|
|
165
|
+
await readJson(
|
|
166
|
+
path.join(
|
|
167
|
+
authDirectory(base),
|
|
168
|
+
`${id(selected.user_id, 'user_id')}-${id(selected.workspace_id, 'workspace_id')}.json`,
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
).access_token;
|
|
172
|
+
}
|
|
173
|
+
export async function saveCredential(base, token, account, expiresAt) {
|
|
174
|
+
const directory = authDirectory(base);
|
|
175
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
176
|
+
await chmod(directory, 0o700);
|
|
177
|
+
const subject = {
|
|
178
|
+
user_id: account.user.id,
|
|
179
|
+
workspace_id: account.workspace.id,
|
|
180
|
+
};
|
|
181
|
+
await writeJson(
|
|
182
|
+
path.join(directory, `${subject.user_id}-${subject.workspace_id}.json`),
|
|
183
|
+
{ api_url: base, ...subject, access_token: token, expires_at: expiresAt },
|
|
184
|
+
);
|
|
185
|
+
await writeJson(path.join(directory, 'active.json'), subject);
|
|
186
|
+
}
|
|
187
|
+
export async function forgetCredential(base) {
|
|
188
|
+
const active = path.join(authDirectory(base), 'active.json');
|
|
189
|
+
if (!(await exists(active))) return;
|
|
190
|
+
const selected = await readJson(active);
|
|
191
|
+
await unlink(
|
|
192
|
+
path.join(
|
|
193
|
+
authDirectory(base),
|
|
194
|
+
`${id(selected.user_id, 'user_id')}-${id(selected.workspace_id, 'workspace_id')}.json`,
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
await unlink(active);
|
|
198
|
+
}
|
|
199
|
+
export class Client {
|
|
200
|
+
constructor(base, token, signal) {
|
|
201
|
+
Object.assign(this, { base, token, signal });
|
|
202
|
+
}
|
|
203
|
+
async request(
|
|
204
|
+
method,
|
|
205
|
+
endpoint,
|
|
206
|
+
body,
|
|
207
|
+
{ anonymous = false, headers = {}, timeout = 60000 } = {},
|
|
208
|
+
) {
|
|
209
|
+
if (!anonymous && !this.token)
|
|
210
|
+
throw new CliError(
|
|
211
|
+
3,
|
|
212
|
+
'AUTH_REQUIRED',
|
|
213
|
+
'Run fuse auth login for this API origin.',
|
|
214
|
+
);
|
|
215
|
+
if (!endpoint.startsWith('/') || endpoint.startsWith('//'))
|
|
216
|
+
throw new CliError(2, 'INVALID_ENDPOINT', 'Expected a FUSE API path.');
|
|
217
|
+
let response;
|
|
218
|
+
try {
|
|
219
|
+
response = await fetch(`${this.base}${endpoint}`, {
|
|
220
|
+
method,
|
|
221
|
+
redirect: 'error',
|
|
222
|
+
headers: {
|
|
223
|
+
'Content-Type': 'application/json',
|
|
224
|
+
...headers,
|
|
225
|
+
...(!anonymous ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
226
|
+
},
|
|
227
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
228
|
+
signal: this.signal
|
|
229
|
+
? AbortSignal.any([this.signal, AbortSignal.timeout(timeout)])
|
|
230
|
+
: AbortSignal.timeout(timeout),
|
|
231
|
+
});
|
|
232
|
+
} catch {
|
|
233
|
+
throw new CliError(
|
|
234
|
+
5,
|
|
235
|
+
'TRANSPORT_UNKNOWN',
|
|
236
|
+
method === 'GET'
|
|
237
|
+
? 'Could not read FUSE. Retry this read when connected.'
|
|
238
|
+
: 'The acceptance result is unknown. Keep the intent; do not change its key or blindly repeat this mutation.',
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const request_id = response.headers.get('x-request-id');
|
|
242
|
+
if (response.status === 204) return undefined;
|
|
243
|
+
let envelope;
|
|
244
|
+
try {
|
|
245
|
+
envelope = await response.json();
|
|
246
|
+
} catch {
|
|
247
|
+
throw new CliError(
|
|
248
|
+
5,
|
|
249
|
+
'RESPONSE_UNKNOWN',
|
|
250
|
+
'FUSE returned no readable API response.',
|
|
251
|
+
{ http_status: response.status, request_id },
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (!response.ok || !envelope.success) {
|
|
255
|
+
const exit = response.status === 401 ? 3 : response.status >= 500 ? 5 : 4;
|
|
256
|
+
if (exit === 3) this.token = null;
|
|
257
|
+
throw new CliError(
|
|
258
|
+
exit,
|
|
259
|
+
envelope.error_code || 'API_ERROR',
|
|
260
|
+
typeof envelope.error === 'string'
|
|
261
|
+
? envelope.error
|
|
262
|
+
: `FUSE HTTP ${response.status}`,
|
|
263
|
+
{
|
|
264
|
+
http_status: response.status,
|
|
265
|
+
request_id,
|
|
266
|
+
retry_after: Number(response.headers.get('retry-after')) || null,
|
|
267
|
+
// DEV-2096:Script 那一集正在由誰編輯。
|
|
268
|
+
...(envelope.error_code === 'SCRIPT_EPISODE_LOCKED'
|
|
269
|
+
? { episode_id: envelope.episode_id, holder: envelope.holder }
|
|
270
|
+
: {}),
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return envelope.data;
|
|
275
|
+
}
|
|
276
|
+
get(endpoint) {
|
|
277
|
+
return this.request('GET', endpoint);
|
|
278
|
+
}
|
|
279
|
+
post(endpoint, body, options) {
|
|
280
|
+
return this.request('POST', endpoint, body, options);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
export async function subject(client) {
|
|
284
|
+
const account = await client.get('/identity/me');
|
|
285
|
+
return {
|
|
286
|
+
api_url: client.base,
|
|
287
|
+
user_id: account.user.id,
|
|
288
|
+
workspace_id: account.workspace.id,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
export function sameSubject(expected, actual) {
|
|
292
|
+
for (const key of ['api_url', 'user_id', 'workspace_id'])
|
|
293
|
+
if (expected[key] !== actual[key])
|
|
294
|
+
throw new CliError(
|
|
295
|
+
3,
|
|
296
|
+
'SUBJECT_MISMATCH',
|
|
297
|
+
'This plan or receipt belongs to another API origin or account/workspace.',
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
export async function pause(ms, signal) {
|
|
301
|
+
if (signal?.aborted)
|
|
302
|
+
throw new CliError(
|
|
303
|
+
6,
|
|
304
|
+
'INTERRUPTED',
|
|
305
|
+
'Stopped local work; accepted FUSE jobs continue.',
|
|
306
|
+
);
|
|
307
|
+
await new Promise((resolve, reject) => {
|
|
308
|
+
const timer = setTimeout(() => {
|
|
309
|
+
signal?.removeEventListener('abort', abort);
|
|
310
|
+
resolve();
|
|
311
|
+
}, ms);
|
|
312
|
+
function abort() {
|
|
313
|
+
clearTimeout(timer);
|
|
314
|
+
reject(
|
|
315
|
+
new CliError(
|
|
316
|
+
6,
|
|
317
|
+
'INTERRUPTED',
|
|
318
|
+
'Stopped local work; accepted FUSE jobs continue.',
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
export async function withWriter(directory, action) {
|
|
326
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
327
|
+
const file = path.join(directory, '.writer');
|
|
328
|
+
let handle;
|
|
329
|
+
try {
|
|
330
|
+
handle = await open(file, 'wx', 0o600);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (error.code === 'EEXIST')
|
|
333
|
+
throw new CliError(
|
|
334
|
+
2,
|
|
335
|
+
'STATE_LOCKED',
|
|
336
|
+
`State has a writer lock: ${file}. After confirming the recorded process is gone, remove that lock explicitly.`,
|
|
337
|
+
);
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
await handle.writeFile(
|
|
341
|
+
JSON.stringify({
|
|
342
|
+
pid: process.pid,
|
|
343
|
+
hostname: os.hostname(),
|
|
344
|
+
started_at: new Date().toISOString(),
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
try {
|
|
348
|
+
return await action();
|
|
349
|
+
} finally {
|
|
350
|
+
await handle.close();
|
|
351
|
+
await unlink(file);
|
|
352
|
+
}
|
|
353
|
+
}
|