@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 CHANGED
@@ -1,3 +1,49 @@
1
- # Temporary Holding Version
1
+ # Fuse CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 用 Claude Code、Codex 或終端機操作你的 Fuse 工作區。你可以讀寫分集劇本、調整鏡頭順序、上傳參考素材、選模型、提交生成,並把指定的 Take 下載到本機目錄。
4
+
5
+ ## 在電腦上安裝
6
+
7
+ 使用 Node.js 22.x 與 npm。套件名稱是 `@alchemint/fuse-cli`,安裝後執行的命令是 `fuse`。套件沒有執行階段相依套件,也沒有安裝腳本。
8
+
9
+ 從 [Fuse 官網](https://fuse.alchemint.xyz/zh-tw/cli) 取得 0.1.0 安裝包後,在下載目錄執行:
10
+
11
+ ```sh
12
+ npm install --global ./alchemint-fuse-cli-0.1.0.tgz --ignore-scripts
13
+ fuse --version
14
+ ```
15
+
16
+ 透過 npm registry 安裝同一個公開版本的指令為 `npm install --global @alchemint/fuse-cli@0.1.0 --ignore-scripts`。若電腦上已有其他程式使用 `fuse` 這個名稱,請先確認執行檔的位置,再選擇安裝目錄。
17
+
18
+ ## 連接 Fuse 工作區
19
+
20
+ ```sh
21
+ fuse auth login
22
+ fuse auth status
23
+ fuse creative list --page 1 --limit 10
24
+ ```
25
+
26
+ 預設登入頁位於 `https://fuse.alchemint.xyz/cli/authorize`。只核准剛剛由自己發起的連線;請比對瀏覽器與終端機的授權碼,確認帳戶與工作區後再按「允許連接」。上面最後一行只讀取作品清單,不提交生成。如果終端機無法開啟瀏覽器,用 `fuse auth login --no-browser`,依照提示開啟授權頁。
27
+
28
+ CLI 會將這次連線的憑證保存在使用者的 `~/.config/fuse`,或你指定的 `FUSE_CONFIG_DIR`。請勿將這個目錄加入 Git、傳給助理或貼進支援對話。執行 `fuse auth logout` 可撤銷這次 CLI 連線。若另行設定 `FUSE_API_URL` 或注入 `FUSE_TOKEN`,請先確認目標服務是你信任的 Fuse 部署。
29
+
30
+ ## 用平常的話交代助理
31
+
32
+ 在能執行本機命令的助理裡開啟專案,登入後就可以這樣說:
33
+
34
+ - 「列出第一集的鏡頭,把屋頂那一鏡移到閱覽室前面。」
35
+ - 「查一下這個鏡頭能用哪些影片模型。先列出選項,等我決定再送出。」
36
+ - 「把選用的 Take 存到目前目錄,檔名用 shot.mp4。」
37
+
38
+ 用終端機也可以完成同樣的操作。以下只查模型,並下載一個既有的選用版本。請用自己的作品 UUID 和鏡頭 ID 取代佔位文字。
39
+
40
+ ```sh
41
+ fuse models list --stage video
42
+ fuse download take --creative CREATIVE_UUID --shot SHOT_ID --out ./shot.mp4
43
+ ```
44
+
45
+ ## 生成、費用與收據
46
+
47
+ `video submit`、`version submit` 和 `batch run` 會提交生成,依工作區的模型、點數與方案計費。`creative create` 也會從輸入的構想或劇本啟動製作流程。查看進度可以使用 `job show` 或 `batch status`。購買點數與訂閱會員請前往 [Fuse 方案頁](https://fuse.alchemint.xyz/zh-tw/plans)。
48
+
49
+ 提交時保留原收據。若連線中斷,用同一份收據與 `--resume` 確認服務是否已受理,再決定下一步。收據可能包含劇本、提示詞與素材參照,請保存在自己的專案目錄。輸入 `fuse --help` 查看指令和接受的參數。
package/package.json CHANGED
@@ -1,6 +1,22 @@
1
1
  {
2
2
  "name": "@alchemint/fuse-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Use your local AI assistant to edit shots, submit video jobs and download results from Fuse.",
6
+ "engines": {
7
+ "node": "22.x"
8
+ },
9
+ "bin": {
10
+ "fuse": "src/main.mjs"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "license": "UNLICENSED",
17
+ "homepage": "https://fuse.alchemint.xyz/zh-tw/cli",
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "registry": "https://registry.npmjs.org/"
21
+ }
22
+ }
package/src/assets.mjs ADDED
@@ -0,0 +1,386 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import {
5
+ CliError,
6
+ need,
7
+ id,
8
+ readJson,
9
+ writeJson,
10
+ exists,
11
+ subject,
12
+ sameSubject,
13
+ digest,
14
+ duration,
15
+ pause,
16
+ withWriter,
17
+ errorData,
18
+ } from './core.mjs';
19
+ import { fileDigest } from './download.mjs';
20
+
21
+ const memberSummary = (item) => ({
22
+ asset_id: item.id,
23
+ name: item.name,
24
+ type: item.type,
25
+ medium: item.medium,
26
+ availability: item.availability,
27
+ representation_result_id: item.canonical_result?.id ?? null,
28
+ });
29
+ const entrySummary = (entry) => ({
30
+ id: entry.id,
31
+ name: entry.name,
32
+ key: entry.key,
33
+ is_pack: entry.is_pack,
34
+ members: entry.members.map(memberSummary),
35
+ });
36
+ async function transfer(file, declaration, plan, context) {
37
+ async function put(url, start, end, headers) {
38
+ let response;
39
+ try {
40
+ response = await fetch(url, {
41
+ method: 'PUT',
42
+ redirect: 'error',
43
+ headers: { ...headers, 'Content-Length': String(end - start + 1) },
44
+ body: createReadStream(file, { start, end }),
45
+ duplex: 'half',
46
+ signal: AbortSignal.any([
47
+ context.signal,
48
+ AbortSignal.timeout(15 * 60000),
49
+ ]),
50
+ });
51
+ } catch {
52
+ throw new CliError(
53
+ 5,
54
+ 'UPLOAD_TRANSFER_UNKNOWN',
55
+ 'Upload transfer was interrupted. Resume the same receipt/session.',
56
+ );
57
+ }
58
+ if (!response.ok)
59
+ throw new CliError(
60
+ 4,
61
+ 'UPLOAD_TRANSFER_REJECTED',
62
+ `Upload transfer HTTP ${response.status}; resume the same session to refresh its plan.`,
63
+ { http_status: response.status },
64
+ );
65
+ return response.headers.get('etag');
66
+ }
67
+ if (plan.kind === 'single') {
68
+ await put(plan.url, 0, declaration.byteSize - 1, plan.requiredHeaders);
69
+ return {};
70
+ }
71
+ if (plan.kind !== 'multipart')
72
+ throw new CliError(
73
+ 4,
74
+ 'UNKNOWN_TRANSFER',
75
+ 'The server returned an unsupported transfer kind.',
76
+ );
77
+ const parts = [];
78
+ for (const part of plan.parts) {
79
+ const start = (part.partNumber - 1) * plan.partSizeBytes;
80
+ const etag = await put(
81
+ part.url,
82
+ start,
83
+ Math.min(start + plan.partSizeBytes, declaration.byteSize) - 1,
84
+ {},
85
+ );
86
+ if (!etag)
87
+ throw new CliError(
88
+ 5,
89
+ 'UPLOAD_ETAG_UNKNOWN',
90
+ 'The uploaded part did not return its completion ETag. Resume the same session.',
91
+ );
92
+ parts.push({ partNumber: part.partNumber, etag });
93
+ context.progress(`Uploaded part ${part.partNumber}/${plan.parts.length}`);
94
+ }
95
+ return { parts };
96
+ }
97
+ /**
98
+ * One private Upload frozen in a receipt, then the caller's acceptance of its ready
99
+ * receipt. `accepting` names the receipt kind and command, builds the acceptance
100
+ * metadata, accepts (recording its identities on the receipt) and reads the result back.
101
+ */
102
+ export async function uploadWithReceipt(options, context, accepting) {
103
+ const file = path.resolve(need(options.receipt, '--receipt'));
104
+ return withWriter(`${file}.lock`, async () => {
105
+ const owner = await subject(context.client);
106
+ let receipt;
107
+ if (await exists(file)) {
108
+ if (!options.resume)
109
+ throw new CliError(
110
+ 2,
111
+ 'RECEIPT_EXISTS',
112
+ `Use ${accepting.command} --resume with this original receipt.`,
113
+ );
114
+ receipt = await readJson(file);
115
+ sameSubject(receipt, owner);
116
+ if (receipt.schema_version !== 1 || receipt.kind !== accepting.kind)
117
+ throw new CliError(
118
+ 2,
119
+ 'WRONG_RECEIPT',
120
+ `This receipt is not from ${accepting.command}.`,
121
+ );
122
+ if (receipt.state === 'adopted') return accepting.result(receipt, file);
123
+ } else {
124
+ if (options.resume)
125
+ throw new CliError(
126
+ 2,
127
+ 'RECEIPT_NOT_FOUND',
128
+ 'Resume requires the existing upload receipt.',
129
+ );
130
+ const media = path.resolve(need(options.file, '--file'));
131
+ const metadata = await accepting.metadata(media);
132
+ const declared = await fileDigest(media);
133
+ receipt = {
134
+ schema_version: 1,
135
+ kind: accepting.kind,
136
+ ...owner,
137
+ media,
138
+ metadata,
139
+ metadata_sha256: digest(metadata),
140
+ declaration: {
141
+ name: path.basename(media),
142
+ contentType: need(options['content-type'], '--content-type'),
143
+ byteSize: declared.bytes,
144
+ checksumSha256: declared.sha256,
145
+ },
146
+ idempotency_key: options['idempotency-key'] || randomUUID(),
147
+ state: 'prepared',
148
+ };
149
+ await writeJson(file, receipt, true);
150
+ }
151
+ if (digest(receipt.metadata) !== receipt.metadata_sha256)
152
+ throw new CliError(2, 'INTENT_CHANGED', 'Upload metadata changed.');
153
+ try {
154
+ let status, plan;
155
+ if (!receipt.session_id) {
156
+ const initiated = await context.client.post('/uploads', {
157
+ purpose: 'asset_private',
158
+ idempotencyKey: receipt.idempotency_key,
159
+ file: receipt.declaration,
160
+ });
161
+ receipt.session_id = initiated.sessionId;
162
+ receipt.state = initiated.state;
163
+ await writeJson(file, receipt);
164
+ status = initiated;
165
+ plan = initiated.transferPlan;
166
+ } else
167
+ status = await context.client.get(`/uploads/${id(receipt.session_id)}`);
168
+ if (status.state === 'transferring') {
169
+ const current = await fileDigest(receipt.media);
170
+ if (
171
+ current.bytes !== receipt.declaration.byteSize ||
172
+ current.sha256 !== receipt.declaration.checksumSha256
173
+ )
174
+ throw new CliError(
175
+ 2,
176
+ 'INPUT_CHANGED',
177
+ 'The upload file changed; the original session was preserved.',
178
+ );
179
+ if (!plan)
180
+ plan = (
181
+ await context.client.post(
182
+ `/uploads/${id(receipt.session_id)}/refresh`,
183
+ {},
184
+ )
185
+ ).transferPlan;
186
+ const completion = await transfer(
187
+ receipt.media,
188
+ receipt.declaration,
189
+ plan,
190
+ context,
191
+ );
192
+ receipt.completion = completion;
193
+ receipt.state = 'transferred';
194
+ await writeJson(file, receipt);
195
+ status = await context.client.post(
196
+ `/uploads/${id(receipt.session_id)}/complete`,
197
+ completion,
198
+ );
199
+ }
200
+ const deadline = Date.now() + duration(options.timeout || '10m');
201
+ while (status.state === 'uploaded') {
202
+ if (Date.now() >= deadline)
203
+ throw new CliError(
204
+ 6,
205
+ 'UPLOAD_PENDING',
206
+ 'Upload processing is still pending; resume the same receipt.',
207
+ );
208
+ await pause(Math.min(5000, deadline - Date.now()), context.signal);
209
+ status = await context.client.get(`/uploads/${id(receipt.session_id)}`);
210
+ }
211
+ receipt.state = status.state;
212
+ if (
213
+ !['ready', 'consumed'].includes(status.state) ||
214
+ !status.readyReceipt?.id
215
+ )
216
+ throw new CliError(
217
+ 4,
218
+ 'UPLOAD_NOT_READY',
219
+ `Upload ended in ${status.state}: ${status.validationFailureReason || 'no accepted media'}`,
220
+ );
221
+ receipt.ready_receipt_id = status.readyReceipt.id;
222
+ await writeJson(file, receipt);
223
+ await accepting.accept(receipt, () => writeJson(file, receipt));
224
+ receipt.state = 'adopted';
225
+ delete receipt.error;
226
+ await writeJson(file, receipt);
227
+ return accepting.result(receipt, file);
228
+ } catch (error) {
229
+ receipt.error = errorData(error);
230
+ await writeJson(file, receipt);
231
+ error.receipt = file;
232
+ throw error;
233
+ }
234
+ });
235
+ }
236
+ const assetUpload = (options, context) => ({
237
+ kind: 'asset_upload',
238
+ command: 'asset upload',
239
+ async metadata() {
240
+ const metadata = await readJson(
241
+ need(options['request-file'], '--request-file (Asset metadata JSON)'),
242
+ );
243
+ if ('receipt_id' in metadata)
244
+ throw new CliError(
245
+ 2,
246
+ 'RESERVED_FIELD',
247
+ 'The upload receipt_id is supplied by Upload, not by metadata.',
248
+ );
249
+ return metadata;
250
+ },
251
+ async accept(receipt) {
252
+ const asset = await context.client.post('/assets/uploads', {
253
+ ...receipt.metadata,
254
+ receipt_id: receipt.ready_receipt_id,
255
+ });
256
+ receipt.asset_id = asset.id;
257
+ receipt.library_entry_id = asset.library_entry_id;
258
+ },
259
+ async result(receipt, file) {
260
+ const usable = await context.client.get(`/assets/${id(receipt.asset_id)}`);
261
+ return {
262
+ asset_id: receipt.asset_id,
263
+ library_entry_id: receipt.library_entry_id,
264
+ name: usable.name,
265
+ receipt: file,
266
+ upload_session_id: receipt.session_id,
267
+ ready_receipt_id: receipt.ready_receipt_id,
268
+ };
269
+ },
270
+ });
271
+ export async function assets(action, options, context) {
272
+ if (action === 'upload')
273
+ return uploadWithReceipt(options, context, assetUpload(options, context));
274
+ if (action === 'list') {
275
+ const query = new URLSearchParams();
276
+ if (options.cursor) query.set('cursor', options.cursor);
277
+ if (options.type) query.set('type', options.type);
278
+ const result = await context.client.get(`/asset-library?${query}`);
279
+ return {
280
+ items: options.full ? result.items : result.items.map(entrySummary),
281
+ next_cursor: result.next_cursor,
282
+ };
283
+ }
284
+ if (action === 'show') {
285
+ const result = await context.client.get(
286
+ `/asset-library/${id(options.asset, '--asset (library entry ID)')}`,
287
+ );
288
+ if (options.out) {
289
+ await writeJson(options.out, result, true);
290
+ return { file: path.resolve(options.out), id: result.id };
291
+ }
292
+ return options.full ? result : entrySummary(result);
293
+ }
294
+ if (action === 'link') {
295
+ const body = await readJson(
296
+ need(
297
+ options['request-file'],
298
+ '--request-file ({creative_ids,asset_ids})',
299
+ ),
300
+ );
301
+ const endpoint = `/assets/${id(options.asset, '--asset (library entry ID)')}/creative-materials`;
302
+ await context.client.post(endpoint, body);
303
+ const rows = await context.client.get(endpoint);
304
+ const targets = rows.filter((row) => body.creative_ids.includes(row.id));
305
+ if (
306
+ targets.length !== body.creative_ids.length ||
307
+ targets.some((row) =>
308
+ body.asset_ids.some((assetId) => !row.asset_ids.includes(assetId)),
309
+ )
310
+ )
311
+ throw new CliError(
312
+ 4,
313
+ 'ASSOCIATION_READBACK_DIFFERS',
314
+ 'The requested material associations are not all present on the selected Creatives.',
315
+ );
316
+ return options.full
317
+ ? targets
318
+ : {
319
+ library_entry_id: options.asset,
320
+ creatives: targets.map((row) => ({
321
+ creative_id: row.id,
322
+ linked_asset_ids: row.asset_ids.filter((assetId) =>
323
+ body.asset_ids.includes(assetId),
324
+ ),
325
+ })),
326
+ };
327
+ }
328
+ if (action === 'unlink') {
329
+ const assetId = need(options.asset, '--asset');
330
+ const creativeId = need(options.creative, '--creative');
331
+ await context.client.request(
332
+ 'DELETE',
333
+ `/assets/${id(assetId)}/creative-materials/${id(creativeId)}`,
334
+ );
335
+ const { asset_ids } = await context.client.get(
336
+ `/assets/creative-materials/${id(creativeId)}`,
337
+ );
338
+ if (asset_ids.includes(assetId))
339
+ throw new CliError(
340
+ 4,
341
+ 'ASSOCIATION_READBACK_DIFFERS',
342
+ 'The asset is still in the Creative collection.',
343
+ );
344
+ return { asset_id: assetId, creative_id: creativeId, removed: true };
345
+ }
346
+ if (action === 'canonical') {
347
+ const assetId = need(options.asset, '--asset');
348
+ const resultId = need(options.result, '--result');
349
+ // The canonical switch is conditional on the representation read just before it.
350
+ const before = await context.client.get(`/assets/${id(assetId)}`);
351
+ await context.client.request('PATCH', `/assets/${id(assetId)}/canonical`, {
352
+ result_id: resultId,
353
+ expected_result_id: before.canonical_result?.id ?? null,
354
+ });
355
+ const after = await context.client.get(`/assets/${id(assetId)}`);
356
+ if (after.canonical_result?.id !== resultId)
357
+ throw new CliError(
358
+ 4,
359
+ 'CANONICAL_READBACK_DIFFERS',
360
+ 'The asset does not read back the requested representation.',
361
+ );
362
+ return {
363
+ asset_id: assetId,
364
+ canonical_result_id: resultId,
365
+ previous_result_id: before.canonical_result?.id ?? null,
366
+ };
367
+ }
368
+ if (action === 'update') {
369
+ const assetId = need(options.asset, '--asset');
370
+ const name = need(options.name, '--name');
371
+ await context.client.request('PATCH', `/assets/${id(assetId)}`, { name });
372
+ const after = await context.client.get(`/assets/${id(assetId)}`);
373
+ if (after.name !== name)
374
+ throw new CliError(
375
+ 4,
376
+ 'UPDATE_READBACK_DIFFERS',
377
+ 'The asset does not read back the requested name.',
378
+ );
379
+ return { asset_id: assetId, name: after.name };
380
+ }
381
+ throw new CliError(
382
+ 2,
383
+ 'UNKNOWN_COMMAND',
384
+ 'Use asset list, show, upload, link, unlink, canonical, or update.',
385
+ );
386
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,132 @@
1
+ import { hostname } from 'node:os';
2
+ import { spawn } from 'node:child_process';
3
+ import {
4
+ CliError,
5
+ saveCredential,
6
+ forgetCredential,
7
+ pause,
8
+ duration,
9
+ } from './core.mjs';
10
+
11
+ export async function auth(action, options, context) {
12
+ const { client, signal, progress } = context;
13
+ if (action === 'status') {
14
+ const account = await client.get('/identity/me');
15
+ return {
16
+ api_url: client.base,
17
+ user_id: account.user.id,
18
+ workspace_id: account.workspace.id,
19
+ workspace_name: account.workspace.name,
20
+ display_name: account.user.displayName,
21
+ };
22
+ }
23
+ if (action === 'logout') {
24
+ await client.post('/identity/logout', {});
25
+ if (!process.env.FUSE_TOKEN) await forgetCredential(client.base);
26
+ return {
27
+ revoked: true,
28
+ ...(process.env.FUSE_TOKEN
29
+ ? { note: 'The injected session was revoked; unset FUSE_TOKEN.' }
30
+ : {}),
31
+ };
32
+ }
33
+ if (action !== 'login')
34
+ throw new CliError(
35
+ 2,
36
+ 'UNKNOWN_COMMAND',
37
+ 'Use auth login, status, or logout.',
38
+ );
39
+ if (process.env.FUSE_TOKEN)
40
+ throw new CliError(
41
+ 2,
42
+ 'INJECTED_SESSION_ACTIVE',
43
+ 'Unset FUSE_TOKEN before device login; that variable takes precedence over saved credentials.',
44
+ );
45
+ const grant = await client.post(
46
+ '/identity/cli/authorizations',
47
+ { device_name: options['device-name'] || hostname() },
48
+ { anonymous: true },
49
+ );
50
+ const url = new URL(grant.verification_uri_complete);
51
+ const apiOrigin = new URL(client.base).origin;
52
+ const localHttp =
53
+ url.protocol === 'http:' &&
54
+ ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) &&
55
+ ['localhost', '127.0.0.1', '[::1]'].includes(new URL(client.base).hostname);
56
+ if (
57
+ (url.protocol !== 'https:' && !localHttp) ||
58
+ (apiOrigin === 'https://fuse.alchemint.xyz' && url.origin !== apiOrigin) ||
59
+ url.username ||
60
+ url.password ||
61
+ url.pathname !== '/cli/authorize' ||
62
+ !grant.user_code ||
63
+ url.searchParams.get('code') !== grant.user_code
64
+ )
65
+ throw new CliError(
66
+ 4,
67
+ 'INVALID_VERIFICATION_URL',
68
+ 'The server did not provide a secure authorization page.',
69
+ );
70
+ progress(
71
+ `Open ${url.href}\nCode: ${grant.user_code}\nExpires: ${grant.expires_at}`,
72
+ );
73
+ if (!options['no-browser']) {
74
+ const [command, args] =
75
+ process.platform === 'darwin'
76
+ ? ['open', [url.href]]
77
+ : process.platform === 'win32'
78
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url.href]]
79
+ : ['xdg-open', [url.href]];
80
+ const child = spawn(command, args, { stdio: 'ignore' });
81
+ child.on('error', () =>
82
+ progress('Could not open the browser. Use the authorization URL above.'),
83
+ );
84
+ }
85
+ const deadline = Math.min(
86
+ Date.parse(grant.expires_at),
87
+ Date.now() + duration(options.timeout || '10m'),
88
+ );
89
+ let interval = grant.interval_seconds;
90
+ while (Date.now() < deadline) {
91
+ await pause(Math.min(interval * 1000, deadline - Date.now()), signal);
92
+ let result;
93
+ try {
94
+ result = await client.post(
95
+ '/identity/cli/token',
96
+ { device_code: grant.device_code },
97
+ { anonymous: true },
98
+ );
99
+ } catch (error) {
100
+ if (error.http_status === 429) {
101
+ interval = error.retry_after || interval + 5;
102
+ continue;
103
+ }
104
+ throw error;
105
+ }
106
+ if (result.status === 'pending') {
107
+ interval = result.interval_seconds;
108
+ continue;
109
+ }
110
+ client.token = result.access_token;
111
+ const account = await client.get('/identity/me');
112
+ await saveCredential(
113
+ client.base,
114
+ result.access_token,
115
+ account,
116
+ result.expires_at,
117
+ );
118
+ return {
119
+ api_url: client.base,
120
+ user_id: account.user.id,
121
+ workspace_id: account.workspace.id,
122
+ workspace_name: account.workspace.name,
123
+ expires_at: result.expires_at,
124
+ session: 'independent',
125
+ };
126
+ }
127
+ throw new CliError(
128
+ 6,
129
+ 'LOGIN_TIMEOUT',
130
+ 'Authorization wait ended. Start a new login.',
131
+ );
132
+ }