@yunsoft/yuncms 0.1.2 → 0.1.5
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 +16 -3
- package/package.json +3 -3
- package/src/backup-command.js +72 -0
- package/src/backup-integrity.js +118 -0
- package/src/cli.js +24 -7
- package/src/command-options.js +57 -0
- package/src/database-backup.js +336 -0
- package/src/database-reset.js +44 -0
- package/src/maintenance-lock.js +104 -0
- package/src/process-runner.js +184 -0
- package/src/project-backup.js +564 -0
- package/src/restore-command.js +75 -0
- package/src/runtime-probe.js +165 -0
- package/src/service-state.js +37 -0
- package/src/start-command.js +4 -0
- package/src/update-command.js +282 -0
- package/src/update-lock.js +68 -0
- package/src/update-preflight.js +418 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
lstat,
|
|
4
|
+
mkdtemp,
|
|
5
|
+
readFile,
|
|
6
|
+
readdir,
|
|
7
|
+
rm,
|
|
8
|
+
statfs,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from 'node:fs/promises';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
closeDatabasePool,
|
|
16
|
+
createDatabasePool,
|
|
17
|
+
loadConfig,
|
|
18
|
+
pingDatabase,
|
|
19
|
+
readAppliedMigrations,
|
|
20
|
+
readMigrationAttempts,
|
|
21
|
+
} from '@yunsoft/yuncms-core';
|
|
22
|
+
|
|
23
|
+
import { runCapturedProcess } from './process-runner.js';
|
|
24
|
+
import { isLocalYunCmsReachable } from './service-state.js';
|
|
25
|
+
|
|
26
|
+
const MIN_FREE_HEADROOM_BYTES = 256 * 1024 * 1024;
|
|
27
|
+
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
28
|
+
const DEPENDENCY_SECTIONS = Object.freeze(['dependencies', 'devDependencies', 'optionalDependencies']);
|
|
29
|
+
|
|
30
|
+
function updateError(code, message) {
|
|
31
|
+
const error = new Error(message);
|
|
32
|
+
error.code = code;
|
|
33
|
+
return error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readJson(path, code) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error?.code === 'ENOENT' || error instanceof SyntaxError) {
|
|
41
|
+
throw updateError(code, `Missing or invalid JSON file: ${path}`);
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function projectDependencySection(packageJson) {
|
|
48
|
+
const matches = DEPENDENCY_SECTIONS.filter((key) => packageJson?.[key]?.['@yunsoft/yuncms']);
|
|
49
|
+
if (matches.length > 1) {
|
|
50
|
+
const error = updateError(
|
|
51
|
+
'UPDATE_PROJECT_DEPENDENCY_AMBIGUOUS',
|
|
52
|
+
`@yunsoft/yuncms is declared in multiple dependency sections: ${matches.join(', ')}`,
|
|
53
|
+
);
|
|
54
|
+
error.dependencySections = matches;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
return matches[0] ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseSemanticVersion(version) {
|
|
61
|
+
if (typeof version !== 'string') return null;
|
|
62
|
+
const match = SEMVER_PATTERN.exec(version.trim());
|
|
63
|
+
if (!match) return null;
|
|
64
|
+
return {
|
|
65
|
+
raw: version.trim(),
|
|
66
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
67
|
+
prerelease: match[4] ? match[4].split('.') : [],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function readProjectPackageState(cwd = process.cwd()) {
|
|
72
|
+
const projectPackagePath = resolve(cwd, 'package.json');
|
|
73
|
+
const project = await readJson(projectPackagePath, 'UPDATE_PROJECT_PACKAGE_REQUIRED');
|
|
74
|
+
const dependencySection = projectDependencySection(project);
|
|
75
|
+
if (!dependencySection) {
|
|
76
|
+
throw updateError(
|
|
77
|
+
'UPDATE_PROJECT_PACKAGE_REQUIRED',
|
|
78
|
+
'Project package.json must declare @yunsoft/yuncms before managed updates can run',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const installedPath = resolve(cwd, 'node_modules', '@yunsoft', 'yuncms', 'package.json');
|
|
83
|
+
const installed = await readJson(installedPath, 'UPDATE_INSTALLED_PACKAGE_REQUIRED');
|
|
84
|
+
if (!installed.version) {
|
|
85
|
+
throw updateError('UPDATE_INSTALLED_PACKAGE_REQUIRED', 'Installed @yunsoft/yuncms version is missing');
|
|
86
|
+
}
|
|
87
|
+
const currentVersion = String(installed.version);
|
|
88
|
+
if (!parseSemanticVersion(currentVersion)) {
|
|
89
|
+
throw updateError(
|
|
90
|
+
'UPDATE_INSTALLED_VERSION_INVALID',
|
|
91
|
+
`Installed @yunsoft/yuncms has an invalid semantic version: ${currentVersion}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
project,
|
|
97
|
+
projectPackagePath,
|
|
98
|
+
installedPackagePath: installedPath,
|
|
99
|
+
currentVersion,
|
|
100
|
+
dependencySection,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseResolvedVersion(stdout) {
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(stdout);
|
|
108
|
+
} catch {
|
|
109
|
+
parsed = stdout.replace(/^"|"$/g, '').trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const candidates = Array.isArray(parsed) ? parsed : [parsed];
|
|
113
|
+
if (
|
|
114
|
+
candidates.length === 0
|
|
115
|
+
|| candidates.some((version) => typeof version !== 'string' || !parseSemanticVersion(version))
|
|
116
|
+
) {
|
|
117
|
+
throw updateError('UPDATE_TARGET_VERSION_INVALID', `npm returned an invalid YunCMS version: ${stdout}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return candidates.reduce((best, version) => (
|
|
121
|
+
best === null || compareVersions(version, best) > 0 ? version : best
|
|
122
|
+
), null);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function resolveTargetVersion(specifier = 'latest', {
|
|
126
|
+
cwd = process.cwd(),
|
|
127
|
+
env = process.env,
|
|
128
|
+
runProcess = runCapturedProcess,
|
|
129
|
+
} = {}) {
|
|
130
|
+
const result = await runProcess(
|
|
131
|
+
'npm',
|
|
132
|
+
['view', `@yunsoft/yuncms@${specifier}`, 'version', '--json'],
|
|
133
|
+
{ cwd, env },
|
|
134
|
+
);
|
|
135
|
+
return parseResolvedVersion(result.stdout);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function comparePrerelease(left, right) {
|
|
139
|
+
if (left.length === 0 && right.length === 0) return 0;
|
|
140
|
+
if (left.length === 0) return 1;
|
|
141
|
+
if (right.length === 0) return -1;
|
|
142
|
+
|
|
143
|
+
const length = Math.max(left.length, right.length);
|
|
144
|
+
for (let index = 0; index < length; index += 1) {
|
|
145
|
+
if (left[index] === undefined) return -1;
|
|
146
|
+
if (right[index] === undefined) return 1;
|
|
147
|
+
if (left[index] === right[index]) continue;
|
|
148
|
+
|
|
149
|
+
const leftNumeric = /^\d+$/.test(left[index]);
|
|
150
|
+
const rightNumeric = /^\d+$/.test(right[index]);
|
|
151
|
+
if (leftNumeric && rightNumeric) {
|
|
152
|
+
const leftNumber = Number(left[index]);
|
|
153
|
+
const rightNumber = Number(right[index]);
|
|
154
|
+
return leftNumber < rightNumber ? -1 : 1;
|
|
155
|
+
}
|
|
156
|
+
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
|
157
|
+
return left[index] < right[index] ? -1 : 1;
|
|
158
|
+
}
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function compareVersions(left, right) {
|
|
163
|
+
const a = parseSemanticVersion(left);
|
|
164
|
+
const b = parseSemanticVersion(right);
|
|
165
|
+
if (!a || !b) {
|
|
166
|
+
const error = updateError(
|
|
167
|
+
'UPDATE_VERSION_INVALID',
|
|
168
|
+
`Cannot compare invalid semantic versions: ${left} and ${right}`,
|
|
169
|
+
);
|
|
170
|
+
error.left = left;
|
|
171
|
+
error.right = right;
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
for (let index = 0; index < 3; index += 1) {
|
|
175
|
+
if (a.core[index] !== b.core[index]) return a.core[index] < b.core[index] ? -1 : 1;
|
|
176
|
+
}
|
|
177
|
+
return comparePrerelease(a.prerelease, b.prerelease);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function analyzeMigrationHistory(appliedMigrations, targetMigrations) {
|
|
181
|
+
const applied = Array.isArray(appliedMigrations) ? appliedMigrations : [];
|
|
182
|
+
const target = Array.isArray(targetMigrations) ? targetMigrations : [];
|
|
183
|
+
const appliedSet = new Set(applied);
|
|
184
|
+
const targetSet = new Set(target);
|
|
185
|
+
const unknownAppliedMigrations = applied.filter((id) => !targetSet.has(id));
|
|
186
|
+
const pendingMigrations = target.filter((id) => !appliedSet.has(id));
|
|
187
|
+
const migrationHistoryGap = [];
|
|
188
|
+
let sawMissing = false;
|
|
189
|
+
|
|
190
|
+
for (const id of target) {
|
|
191
|
+
if (!appliedSet.has(id)) {
|
|
192
|
+
sawMissing = true;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (sawMissing) migrationHistoryGap.push(id);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
pendingMigrations,
|
|
200
|
+
unknownAppliedMigrations,
|
|
201
|
+
migrationHistoryGap,
|
|
202
|
+
compatible: unknownAppliedMigrations.length === 0 && migrationHistoryGap.length === 0,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function findIncompleteMigrationAttempts(appliedMigrations, attempts) {
|
|
207
|
+
const applied = new Set(Array.isArray(appliedMigrations) ? appliedMigrations : []);
|
|
208
|
+
return (Array.isArray(attempts) ? attempts : [])
|
|
209
|
+
.filter((attempt) => attempt && !applied.has(attempt.migration_id))
|
|
210
|
+
.map((attempt) => ({ ...attempt }));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function inspectTargetMigrations(targetVersion, {
|
|
214
|
+
env,
|
|
215
|
+
runProcess,
|
|
216
|
+
} = {}) {
|
|
217
|
+
const directory = await mkdtemp(join(tmpdir(), 'yuncms-update-'));
|
|
218
|
+
try {
|
|
219
|
+
await writeFile(
|
|
220
|
+
join(directory, 'package.json'),
|
|
221
|
+
`${JSON.stringify({ private: true }, null, 2)}\n`,
|
|
222
|
+
'utf8',
|
|
223
|
+
);
|
|
224
|
+
await runProcess(
|
|
225
|
+
'npm',
|
|
226
|
+
[
|
|
227
|
+
'install',
|
|
228
|
+
'--ignore-scripts',
|
|
229
|
+
'--no-audit',
|
|
230
|
+
'--no-fund',
|
|
231
|
+
'--package-lock=false',
|
|
232
|
+
'--save-exact',
|
|
233
|
+
`@yunsoft/yuncms@${targetVersion}`,
|
|
234
|
+
],
|
|
235
|
+
{ cwd: directory, env },
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const script = [
|
|
239
|
+
"import { REQUIRED_CORE_MIGRATION_IDS } from '@yunsoft/yuncms-core';",
|
|
240
|
+
'process.stdout.write(JSON.stringify(REQUIRED_CORE_MIGRATION_IDS));',
|
|
241
|
+
].join(' ');
|
|
242
|
+
const result = await runProcess(
|
|
243
|
+
process.execPath,
|
|
244
|
+
['--input-type=module', '-e', script],
|
|
245
|
+
{ cwd: directory, env },
|
|
246
|
+
);
|
|
247
|
+
const migrations = JSON.parse(result.stdout);
|
|
248
|
+
if (
|
|
249
|
+
!Array.isArray(migrations)
|
|
250
|
+
|| migrations.some((id) => typeof id !== 'string' || !id)
|
|
251
|
+
|| new Set(migrations).size !== migrations.length
|
|
252
|
+
) {
|
|
253
|
+
throw updateError('UPDATE_TARGET_MIGRATIONS_INVALID', 'Target package exposed an invalid migration list');
|
|
254
|
+
}
|
|
255
|
+
return migrations;
|
|
256
|
+
} finally {
|
|
257
|
+
await rm(directory, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function readExistingMigrationAttempts(pool) {
|
|
262
|
+
try {
|
|
263
|
+
return await readMigrationAttempts(pool);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (error?.code === 'ER_NO_SUCH_TABLE') return [];
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function collectDatabaseState(config, {
|
|
271
|
+
createPool = createDatabasePool,
|
|
272
|
+
closePool = closeDatabasePool,
|
|
273
|
+
} = {}) {
|
|
274
|
+
const pool = createPool(config.database);
|
|
275
|
+
try {
|
|
276
|
+
if (!(await pingDatabase(pool))) {
|
|
277
|
+
throw updateError('DATABASE_UNAVAILABLE', 'Database connectivity check failed');
|
|
278
|
+
}
|
|
279
|
+
const applied = [...await readAppliedMigrations(pool)].sort();
|
|
280
|
+
const attempts = await readExistingMigrationAttempts(pool);
|
|
281
|
+
const [rows] = await pool.query(
|
|
282
|
+
`SELECT COALESCE(SUM(data_length + index_length), 0) AS bytes
|
|
283
|
+
FROM information_schema.tables
|
|
284
|
+
WHERE table_schema = ?`,
|
|
285
|
+
[config.database.database],
|
|
286
|
+
);
|
|
287
|
+
return {
|
|
288
|
+
appliedMigrations: applied,
|
|
289
|
+
migrationAttempts: attempts,
|
|
290
|
+
incompleteMigrationAttempts: findIncompleteMigrationAttempts(applied, attempts),
|
|
291
|
+
estimatedBytes: Number(rows?.[0]?.bytes ?? 0),
|
|
292
|
+
};
|
|
293
|
+
} finally {
|
|
294
|
+
await closePool(pool);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function diskState(cwd) {
|
|
299
|
+
const info = await statfs(cwd);
|
|
300
|
+
return {
|
|
301
|
+
freeBytes: Number(info.bavail) * Number(info.bsize),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function pathSize(path) {
|
|
306
|
+
let info;
|
|
307
|
+
try {
|
|
308
|
+
info = await lstat(path);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error?.code === 'ENOENT') return 0;
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (info.isSymbolicLink()) return info.size;
|
|
315
|
+
if (info.isFile()) return info.size;
|
|
316
|
+
if (!info.isDirectory()) return 0;
|
|
317
|
+
|
|
318
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
319
|
+
let bytes = 0;
|
|
320
|
+
for (const entry of entries) {
|
|
321
|
+
bytes += await pathSize(join(path, entry.name));
|
|
322
|
+
}
|
|
323
|
+
return bytes;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function collectLocalBackupBytes(cwd, config) {
|
|
327
|
+
const localFilesPath = isAbsolute(config.storage.localRoot)
|
|
328
|
+
? config.storage.localRoot
|
|
329
|
+
: resolve(cwd, config.storage.localRoot);
|
|
330
|
+
const paths = [
|
|
331
|
+
localFilesPath,
|
|
332
|
+
resolve(cwd, 'extensions'),
|
|
333
|
+
resolve(cwd, '.env'),
|
|
334
|
+
resolve(cwd, 'package.json'),
|
|
335
|
+
resolve(cwd, 'package-lock.json'),
|
|
336
|
+
];
|
|
337
|
+
let bytes = 0;
|
|
338
|
+
for (const path of paths) bytes += await pathSize(path);
|
|
339
|
+
return bytes;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function assertRequiredTools({ cwd, env, runProcess }) {
|
|
343
|
+
await Promise.all([
|
|
344
|
+
runProcess('npm', ['--version'], { cwd, env }),
|
|
345
|
+
runProcess('mysqldump', ['--version'], { cwd, env }),
|
|
346
|
+
runProcess('mysql', ['--version'], { cwd, env }),
|
|
347
|
+
]);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function collectUpdatePreflight({
|
|
351
|
+
cwd = process.cwd(),
|
|
352
|
+
env = process.env,
|
|
353
|
+
target = 'latest',
|
|
354
|
+
allowUnverifiedS3 = false,
|
|
355
|
+
runProcess = runCapturedProcess,
|
|
356
|
+
fetchFn = globalThis.fetch,
|
|
357
|
+
inspectMigrations = inspectTargetMigrations,
|
|
358
|
+
} = {}) {
|
|
359
|
+
await access(cwd);
|
|
360
|
+
const config = loadConfig(env);
|
|
361
|
+
const packageState = await readProjectPackageState(cwd);
|
|
362
|
+
await assertRequiredTools({ cwd, env, runProcess });
|
|
363
|
+
const targetVersion = await resolveTargetVersion(target, { cwd, env, runProcess });
|
|
364
|
+
const database = await collectDatabaseState(config);
|
|
365
|
+
const targetMigrations = await inspectMigrations(targetVersion, { env, runProcess });
|
|
366
|
+
const running = await isLocalYunCmsReachable({
|
|
367
|
+
host: config.server.host,
|
|
368
|
+
port: config.server.port,
|
|
369
|
+
fetchFn,
|
|
370
|
+
});
|
|
371
|
+
const [disk, localBackupBytes] = await Promise.all([
|
|
372
|
+
diskState(cwd),
|
|
373
|
+
collectLocalBackupBytes(cwd, config),
|
|
374
|
+
]);
|
|
375
|
+
|
|
376
|
+
const migrationHistory = analyzeMigrationHistory(database.appliedMigrations, targetMigrations);
|
|
377
|
+
const minimumFreeBytes = database.estimatedBytes + localBackupBytes + MIN_FREE_HEADROOM_BYTES;
|
|
378
|
+
const blockers = [];
|
|
379
|
+
|
|
380
|
+
if (running) blockers.push('UPDATE_APPLICATION_RUNNING');
|
|
381
|
+
if (database.incompleteMigrationAttempts.length > 0) blockers.push('UPDATE_MIGRATION_RECOVERY_REQUIRED');
|
|
382
|
+
if (config.storage.s3.bucket && !allowUnverifiedS3) blockers.push('UPDATE_S3_BACKUP_UNVERIFIED');
|
|
383
|
+
if (disk.freeBytes < minimumFreeBytes) blockers.push('UPDATE_DISK_SPACE_INSUFFICIENT');
|
|
384
|
+
if (!migrationHistory.compatible) blockers.push('UPDATE_MIGRATION_HISTORY_INCOMPATIBLE');
|
|
385
|
+
if (compareVersions(targetVersion, packageState.currentVersion) < 0) blockers.push('UPDATE_DOWNGRADE_FORBIDDEN');
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
currentVersion: packageState.currentVersion,
|
|
389
|
+
targetVersion,
|
|
390
|
+
dependencySection: packageState.dependencySection,
|
|
391
|
+
upToDate: targetVersion === packageState.currentVersion,
|
|
392
|
+
running,
|
|
393
|
+
databaseBytes: database.estimatedBytes,
|
|
394
|
+
localBackupBytes,
|
|
395
|
+
freeDiskBytes: disk.freeBytes,
|
|
396
|
+
minimumFreeBytes,
|
|
397
|
+
appliedMigrations: database.appliedMigrations,
|
|
398
|
+
targetMigrations,
|
|
399
|
+
pendingMigrations: migrationHistory.pendingMigrations,
|
|
400
|
+
unknownAppliedMigrations: migrationHistory.unknownAppliedMigrations,
|
|
401
|
+
migrationHistoryGap: migrationHistory.migrationHistoryGap,
|
|
402
|
+
incompleteMigrationAttempts: database.incompleteMigrationAttempts,
|
|
403
|
+
s3Configured: Boolean(config.storage.s3.bucket),
|
|
404
|
+
s3Bucket: config.storage.s3.bucket || null,
|
|
405
|
+
blockers,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function assertUpdatePreflightReady(report) {
|
|
410
|
+
if (report.blockers.length === 0) return true;
|
|
411
|
+
const error = updateError(
|
|
412
|
+
'UPDATE_PREFLIGHT_FAILED',
|
|
413
|
+
`Update preflight failed: ${report.blockers.join(', ')}`,
|
|
414
|
+
);
|
|
415
|
+
error.blockers = [...report.blockers];
|
|
416
|
+
error.report = report;
|
|
417
|
+
throw error;
|
|
418
|
+
}
|