helpers.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. import { cancel, isCancel, spinner } from '@clack/prompts';
  2. import spawn from 'cross-spawn';
  3. import fs from 'fs-extra';
  4. import { execFile, execFileSync, execSync } from 'node:child_process';
  5. import { createWriteStream } from 'node:fs';
  6. import { platform } from 'node:os';
  7. import { Readable } from 'node:stream';
  8. import { pipeline } from 'node:stream/promises';
  9. import { promisify } from 'node:util';
  10. import path from 'path';
  11. import pc from 'picocolors';
  12. import semver from 'semver';
  13. import * as tar from 'tar';
  14. import { STOREFRONT_BRANCH, STOREFRONT_REPO, TYPESCRIPT_VERSION } from './constants';
  15. import { log } from './logger';
  16. import { CliLogLevel, DbType } from './types';
  17. /**
  18. * If project only contains files generated by GH, it’s safe.
  19. * Also, if project contains remnant error logs from a previous
  20. * installation, lets remove them now.
  21. * We also special case IJ-based products .idea because it integrates with CRA:
  22. * https://github.com/facebook/create-react-app/pull/368#issuecomment-243446094
  23. */
  24. export function isSafeToCreateProjectIn(root: string, name: string) {
  25. // These files should be allowed to remain on a failed install,
  26. // but then silently removed during the next create.
  27. const errorLogFilePatterns = ['npm-debug.log', 'yarn-error.log', 'yarn-debug.log'];
  28. const validFiles = [
  29. '.DS_Store',
  30. 'Thumbs.db',
  31. '.git',
  32. '.gitignore',
  33. '.idea',
  34. 'README.md',
  35. 'LICENSE',
  36. '.hg',
  37. '.hgignore',
  38. '.hgcheck',
  39. '.npmignore',
  40. 'mkdocs.yml',
  41. 'docs',
  42. '.travis.yml',
  43. '.gitlab-ci.yml',
  44. '.gitattributes',
  45. 'migration.ts',
  46. 'node_modules',
  47. 'package.json',
  48. 'package-lock.json',
  49. 'src',
  50. 'static',
  51. 'tsconfig.json',
  52. 'yarn.lock',
  53. ];
  54. const conflicts = fs
  55. .readdirSync(root)
  56. .filter(file => !validFiles.includes(file))
  57. // IntelliJ IDEA creates module files before CRA is launched
  58. .filter(file => !/\.iml$/.test(file))
  59. // Don't treat log files from previous installation as conflicts
  60. .filter(file => !errorLogFilePatterns.some(pattern => file.indexOf(pattern) === 0));
  61. if (conflicts.length > 0) {
  62. log(`The directory ${pc.green(name)} contains files that could conflict:`, { newline: 'after' });
  63. for (const file of conflicts) {
  64. log(` ${file}`);
  65. }
  66. log('Either try using a new directory name, or remove the files listed above.', {
  67. newline: 'before',
  68. });
  69. return false;
  70. }
  71. // Remove any remnant files from a previous installation
  72. const currentFiles = fs.readdirSync(path.join(root));
  73. currentFiles.forEach(file => {
  74. errorLogFilePatterns.forEach(errorLogFilePattern => {
  75. // This will catch `(npm-debug|yarn-error|yarn-debug).log*` files
  76. if (file.indexOf(errorLogFilePattern) === 0) {
  77. fs.removeSync(path.join(root, file));
  78. }
  79. });
  80. });
  81. return true;
  82. }
  83. export function scaffoldAlreadyExists(root: string, name: string): boolean {
  84. const scaffoldFiles = ['migration.ts', 'package.json', 'tsconfig.json', 'README.md'];
  85. const files = fs.readdirSync(root);
  86. return scaffoldFiles.every(scaffoldFile => files.includes(scaffoldFile));
  87. }
  88. export function checkNodeVersion(requiredVersion: string) {
  89. if (!semver.satisfies(process.version, requiredVersion)) {
  90. log(
  91. pc.red(
  92. `You are running Node ${process.version}.` +
  93. `Vendure requires Node ${requiredVersion} or higher.` +
  94. 'Please update your version of Node.',
  95. ),
  96. );
  97. process.exit(1);
  98. }
  99. }
  100. // Bun support should not be exposed yet, see
  101. // https://github.com/oven-sh/bun/issues/4947
  102. // https://github.com/lovell/sharp/issues/3511
  103. export function bunIsAvailable() {
  104. try {
  105. execFileSync('bun', ['--version'], { stdio: 'ignore' });
  106. return true;
  107. } catch (e: any) {
  108. return false;
  109. }
  110. }
  111. export function checkThatNpmCanReadCwd() {
  112. const cwd = process.cwd();
  113. let childOutput = null;
  114. try {
  115. // Note: intentionally using spawn over exec since
  116. // the problem doesn't reproduce otherwise.
  117. // `npm config list` is the only reliable way I could find
  118. // to reproduce the wrong path. Just printing process.cwd()
  119. // in a Node process was not enough.
  120. childOutput = spawn.sync('npm', ['config', 'list']).output.join('');
  121. } catch (err: any) {
  122. // Something went wrong spawning node.
  123. // Not great, but it means we can't do this check.
  124. // We might fail later on, but let's continue.
  125. return true;
  126. }
  127. if (typeof childOutput !== 'string') {
  128. return true;
  129. }
  130. const lines = childOutput.split('\n');
  131. // `npm config list` output includes the following line:
  132. // "; cwd = C:\path\to\current\dir" (unquoted)
  133. // I couldn't find an easier way to get it.
  134. const prefix = '; cwd = ';
  135. const line = lines.find(l => l.indexOf(prefix) === 0);
  136. if (typeof line !== 'string') {
  137. // Fail gracefully. They could remove it.
  138. return true;
  139. }
  140. const npmCWD = line.substring(prefix.length);
  141. if (npmCWD === cwd) {
  142. return true;
  143. }
  144. log(
  145. pc.red(
  146. 'Could not start an npm process in the right directory.\n\n' +
  147. `The current directory is: ${pc.bold(cwd)}\n` +
  148. `However, a newly started npm process runs in: ${pc.bold(npmCWD)}\n\n` +
  149. 'This is probably caused by a misconfigured system terminal shell.',
  150. ),
  151. );
  152. if (process.platform === 'win32') {
  153. log(
  154. pc.red('On Windows, this can usually be fixed by running:\n\n') +
  155. ` ${pc.cyan('reg')} delete "HKCU\\Software\\Microsoft\\Command Processor" /v AutoRun /f\n` +
  156. ` ${pc.cyan(
  157. 'reg',
  158. )} delete "HKLM\\Software\\Microsoft\\Command Processor" /v AutoRun /f\n\n` +
  159. pc.red('Try to run the above two lines in the terminal.\n') +
  160. pc.red(
  161. 'To learn more about this problem, read: https://blogs.msdn.microsoft.com/oldnewthing/20071121-00/?p=24433/',
  162. ),
  163. );
  164. }
  165. return false;
  166. }
  167. /**
  168. * Install packages via npm.
  169. * Based on the install function from https://github.com/facebook/create-react-app
  170. */
  171. export function installPackages(options: {
  172. dependencies: string[];
  173. isDevDependencies?: boolean;
  174. logLevel: CliLogLevel;
  175. cwd?: string;
  176. }): Promise<void> {
  177. const { dependencies, isDevDependencies = false, logLevel, cwd } = options;
  178. return new Promise((resolve, reject) => {
  179. const command = 'npm';
  180. const args = ['install', '--save', '--save-exact', '--loglevel', 'error'].concat(dependencies);
  181. if (isDevDependencies) {
  182. args.push('--save-dev');
  183. }
  184. if (logLevel === 'verbose') {
  185. args.push('--verbose');
  186. }
  187. const child = spawn(command, args, {
  188. stdio: logLevel === 'verbose' ? 'inherit' : 'ignore',
  189. cwd,
  190. });
  191. child.on('close', code => {
  192. if (code !== 0) {
  193. let message = 'An error occurred when installing dependencies.';
  194. if (logLevel === 'silent') {
  195. message += ' Try running with `--log-level verbose` to diagnose.';
  196. }
  197. reject({
  198. message,
  199. command: `${command} ${args.join(' ')}`,
  200. });
  201. return;
  202. }
  203. resolve();
  204. });
  205. });
  206. }
  207. export function getDependencies(
  208. dbType: DbType,
  209. vendurePkgVersion = '',
  210. ): { dependencies: string[]; devDependencies: string[] } {
  211. const dependencies = [
  212. `@vendure/core${vendurePkgVersion}`,
  213. `@vendure/email-plugin${vendurePkgVersion}`,
  214. `@vendure/asset-server-plugin${vendurePkgVersion}`,
  215. `@vendure/graphiql-plugin${vendurePkgVersion}`,
  216. `@vendure/dashboard${vendurePkgVersion}`,
  217. 'dotenv',
  218. dbDriverPackage(dbType),
  219. ];
  220. const devDependencies = [
  221. `@vendure/cli${vendurePkgVersion}`,
  222. 'concurrently',
  223. `typescript@${TYPESCRIPT_VERSION}`,
  224. ];
  225. return { dependencies, devDependencies };
  226. }
  227. /**
  228. * Returns the name of the npm driver package for the
  229. * selected database.
  230. */
  231. function dbDriverPackage(dbType: DbType): string {
  232. switch (dbType) {
  233. case 'mysql':
  234. case 'mariadb':
  235. return 'mysql2';
  236. case 'postgres':
  237. return 'pg';
  238. case 'sqlite':
  239. return 'better-sqlite3';
  240. default:
  241. const n: never = dbType;
  242. log(pc.red(`No driver package configured for type "${dbType as string}"`));
  243. return '';
  244. }
  245. }
  246. /**
  247. * Checks that the specified DB connection options are working (i.e. a connection can be
  248. * established) and that the named database exists.
  249. */
  250. export function checkDbConnection(options: any, root: string): Promise<true> {
  251. switch (options.type) {
  252. case 'mysql':
  253. return checkMysqlDbExists(options, root);
  254. case 'postgres':
  255. return checkPostgresDbExists(options, root);
  256. default:
  257. return Promise.resolve(true);
  258. }
  259. }
  260. async function checkMysqlDbExists(options: any, root: string): Promise<true> {
  261. // Use require.resolve to find the package, which handles npm workspace hoisting
  262. const mysqlPath = require.resolve('mysql2/promise', { paths: [root] });
  263. const mysql = await import(mysqlPath);
  264. const connectionOptions = {
  265. host: options.host,
  266. user: options.username,
  267. password: options.password,
  268. port: options.port,
  269. database: options.database,
  270. };
  271. const connection = mysql.createConnection(connectionOptions);
  272. return new Promise<boolean>((resolve, reject) => {
  273. connection.connect((err: any) => {
  274. if (err) {
  275. if (err.code === 'ER_BAD_DB_ERROR') {
  276. throwDatabaseDoesNotExist(options.database);
  277. }
  278. throwConnectionError(err);
  279. }
  280. resolve(true);
  281. });
  282. }).then(() => {
  283. return new Promise((resolve, reject) => {
  284. connection.end((err: any) => {
  285. resolve(true);
  286. });
  287. });
  288. });
  289. }
  290. async function checkPostgresDbExists(options: any, root: string): Promise<true> {
  291. // Use require.resolve to find the package, which handles npm workspace hoisting
  292. const pgPath = require.resolve('pg', { paths: [root] });
  293. const { Client } = await import(pgPath);
  294. const connectionOptions = {
  295. host: options.host,
  296. user: options.username,
  297. password: options.password,
  298. port: options.port,
  299. database: options.database,
  300. schema: options.schema,
  301. ssl: options.ssl,
  302. };
  303. const client = new Client(connectionOptions);
  304. try {
  305. await client.connect();
  306. const schema = await client.query(
  307. `SELECT schema_name
  308. FROM information_schema.schemata
  309. WHERE schema_name = '${options.schema as string}'`,
  310. );
  311. if (schema.rows.length === 0) {
  312. throw new Error('NO_SCHEMA');
  313. }
  314. } catch (e: any) {
  315. if (e.code === '3D000') {
  316. throwDatabaseDoesNotExist(options.database);
  317. } else if (e.message === 'NO_SCHEMA') {
  318. throwDatabaseSchemaDoesNotExist(options.database, options.schema);
  319. } else if (e.code === '28000') {
  320. throwSSLConnectionError(e, options.ssl);
  321. }
  322. throwConnectionError(e);
  323. await client.end();
  324. throw e;
  325. }
  326. await client.end();
  327. return true;
  328. }
  329. /**
  330. * Check to see if Docker is installed and running.
  331. * If not, attempt to start it.
  332. * If that is not possible, return false.
  333. *
  334. * Refs:
  335. * - https://stackoverflow.com/a/48843074/772859
  336. */
  337. export async function isDockerAvailable(): Promise<{ result: 'not-found' | 'not-running' | 'running' }> {
  338. const dockerSpinner = spinner();
  339. function isDaemonRunning(): boolean {
  340. try {
  341. execFileSync('docker', ['stats', '--no-stream'], { stdio: 'ignore' });
  342. return true;
  343. } catch (e: any) {
  344. return false;
  345. }
  346. }
  347. dockerSpinner.start('Checking for Docker');
  348. try {
  349. execFileSync('docker', ['-v'], { stdio: 'ignore' });
  350. dockerSpinner.message('Docker was found!');
  351. } catch (e: any) {
  352. dockerSpinner.stop('Docker was not found on this machine. We will use SQLite for the database.');
  353. return { result: 'not-found' };
  354. }
  355. // Now we need to check if the docker daemon is running
  356. const isRunning = isDaemonRunning();
  357. if (isRunning) {
  358. dockerSpinner.stop('Docker is running');
  359. return { result: 'running' };
  360. }
  361. dockerSpinner.message('Docker daemon is not running. Attempting to start');
  362. // detect the current OS
  363. const currentPlatform = platform();
  364. try {
  365. if (currentPlatform === 'win32') {
  366. // https://stackoverflow.com/a/44182489/772859
  367. execSync('"C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe"', { stdio: 'ignore' });
  368. } else if (currentPlatform === 'darwin') {
  369. execSync('open -a Docker', { stdio: 'ignore' });
  370. } else {
  371. execSync('systemctl start docker', { stdio: 'ignore' });
  372. }
  373. } catch (e: any) {
  374. dockerSpinner.stop('Could not start Docker.');
  375. log(e.message, { level: 'verbose' });
  376. return { result: 'not-running' };
  377. }
  378. // Verify that the daemon is now running
  379. let attempts = 1;
  380. do {
  381. log(`Checking for Docker daemon... (attempt ${attempts})`, { level: 'verbose' });
  382. if (isDaemonRunning()) {
  383. log(`Docker daemon is now running (after ${attempts} attempts).`, { level: 'verbose' });
  384. dockerSpinner.stop('Docker is running');
  385. return { result: 'running' };
  386. }
  387. await new Promise(resolve => setTimeout(resolve, 50));
  388. attempts++;
  389. } while (attempts < 100);
  390. dockerSpinner.stop('Docker daemon could not be started');
  391. return { result: 'not-running' };
  392. }
  393. export async function startPostgresDatabase(root: string): Promise<boolean> {
  394. // Now we need to run the postgres database via Docker
  395. let containerName: string | undefined;
  396. const postgresContainerSpinner = spinner();
  397. postgresContainerSpinner.start('Starting PostgreSQL database');
  398. try {
  399. const result = await promisify(execFile)(`docker`, [
  400. `compose`,
  401. `-f`,
  402. path.join(root, 'docker-compose.yml'),
  403. `up`,
  404. `-d`,
  405. `postgres_db`,
  406. ]);
  407. containerName = result.stderr.match(/Container\s+(.+-postgres_db[^ ]*)/)?.[1];
  408. if (!containerName) {
  409. // guess the container name based on the directory name
  410. containerName = path.basename(root).replace(/[^a-z0-9]/gi, '') + '-postgres_db-1';
  411. postgresContainerSpinner.message(
  412. 'Could not find container name. Guessing it is: ' + containerName,
  413. );
  414. log(pc.red('Could not find container name. Guessing it is: ' + containerName), {
  415. newline: 'before',
  416. level: 'verbose',
  417. });
  418. } else {
  419. log(pc.green(`Started PostgreSQL database in container "${containerName}"`), {
  420. newline: 'before',
  421. level: 'verbose',
  422. });
  423. }
  424. } catch (e: any) {
  425. log(pc.red(`Failed to start PostgreSQL database: ${e.message as string}`));
  426. postgresContainerSpinner.stop('Failed to start PostgreSQL database');
  427. return false;
  428. }
  429. postgresContainerSpinner.message(`Waiting for PostgreSQL database to be ready...`);
  430. let attempts = 1;
  431. let isReady = false;
  432. do {
  433. // We now need to ensure that the database is ready to accept connections
  434. try {
  435. const result = execFileSync(`docker`, [`exec`, `-i`, containerName, `pg_isready`]);
  436. isReady = result?.toString().includes('accepting connections');
  437. if (!isReady) {
  438. log(pc.yellow(`PostgreSQL database not yet ready. Attempt ${attempts}...`), {
  439. level: 'verbose',
  440. });
  441. }
  442. } catch (e: any) {
  443. // ignore
  444. log('is_ready error:' + (e.message as string), { level: 'verbose', newline: 'before' });
  445. }
  446. await new Promise(resolve => setTimeout(resolve, 50));
  447. attempts++;
  448. } while (!isReady && attempts < 100);
  449. postgresContainerSpinner.stop('PostgreSQL database is ready');
  450. return true;
  451. }
  452. function throwConnectionError(err: any) {
  453. throw new Error(
  454. 'Could not connect to the database. ' +
  455. `Please check the connection settings in your Vendure config.\n[${
  456. (err.message || err.toString()) as string
  457. }]`,
  458. );
  459. }
  460. function throwSSLConnectionError(err: any, sslEnabled?: any) {
  461. throw new Error(
  462. 'Could not connect to the database. ' +
  463. (sslEnabled === undefined
  464. ? 'Is your server requiring an SSL connection?'
  465. : 'Are you sure your server supports SSL?') +
  466. `Please check the connection settings in your Vendure config.\n[${
  467. (err.message || err.toString()) as string
  468. }]`,
  469. );
  470. }
  471. function throwDatabaseDoesNotExist(name: string) {
  472. throw new Error(`Database "${name}" does not exist. Please create the database and then try again.`);
  473. }
  474. function throwDatabaseSchemaDoesNotExist(dbName: string, schemaName: string) {
  475. throw new Error(
  476. `Schema "${dbName}.${schemaName}" does not exist. Please create the schema "${schemaName}" and then try again.`,
  477. );
  478. }
  479. export function isServerPortInUse(port: number): Promise<boolean> {
  480. // eslint-disable-next-line @typescript-eslint/no-var-requires
  481. const tcpPortUsed = require('tcp-port-used');
  482. try {
  483. return tcpPortUsed.check(port);
  484. } catch (e: any) {
  485. log(pc.yellow(`Warning: could not determine whether port ${port} is available`));
  486. return Promise.resolve(false);
  487. }
  488. }
  489. /**
  490. * Checks if the response from a Clack prompt was a cancellation symbol, and if so,
  491. * ends the interactive process.
  492. */
  493. export function checkCancel<T>(value: T | symbol): value is T {
  494. if (isCancel(value)) {
  495. cancel('Setup cancelled.');
  496. process.exit(0);
  497. }
  498. return true;
  499. }
  500. export function cleanUpDockerResources(name: string) {
  501. try {
  502. execSync(`docker stop $(docker ps -a -q --filter "label=io.vendure.create.name=${name}")`, {
  503. stdio: 'ignore',
  504. });
  505. execSync(`docker rm $(docker ps -a -q --filter "label=io.vendure.create.name=${name}")`, {
  506. stdio: 'ignore',
  507. });
  508. execSync(`docker volume rm $(docker volume ls --filter "label=io.vendure.create.name=${name}" -q)`, {
  509. stdio: 'ignore',
  510. });
  511. } catch (e) {
  512. log(pc.yellow(`Could not clean up Docker resources`), { level: 'verbose' });
  513. }
  514. }
  515. export function resolvePackageRootDir(packageName: string, rootDir: string) {
  516. let packageEntryPath: string;
  517. try {
  518. packageEntryPath = require.resolve(packageName, { paths: [rootDir] });
  519. } catch {
  520. log(`Falling back to direct node_modules lookup for ${packageName}`);
  521. const fallbackPath = path.join(process.cwd(), 'node_modules', packageName);
  522. if (fs.existsSync(fallbackPath)) {
  523. return fallbackPath;
  524. }
  525. throw new Error(
  526. `Cannot resolve package "${packageName}" (checked in ${fallbackPath}). Is it installed?`,
  527. );
  528. }
  529. const target = packageName.split('/').pop();
  530. let dir = path.dirname(packageEntryPath);
  531. const root = path.parse(dir).root;
  532. while (path.basename(dir) !== target) {
  533. const next = path.dirname(dir);
  534. if (next === dir || dir === root) {
  535. throw new Error(`Could not locate package root for "${packageName}" from "${packageEntryPath}".`);
  536. }
  537. dir = next;
  538. }
  539. return dir;
  540. }
  541. /**
  542. * Downloads the Next.js storefront starter from GitHub and extracts it to the target directory.
  543. * Uses the GitHub API tarball endpoint to avoid requiring git.
  544. */
  545. export async function downloadAndExtractStorefront(targetDir: string): Promise<void> {
  546. const tarballUrl = `https://api.github.com/repos/${STOREFRONT_REPO}/tarball/${STOREFRONT_BRANCH}`;
  547. const tempTarPath = path.join(targetDir, '..', 'storefront-temp.tar.gz');
  548. try {
  549. // Fetch the tarball from GitHub
  550. const response = await fetch(tarballUrl, {
  551. headers: {
  552. Accept: 'application/vnd.github+json',
  553. 'User-Agent': 'vendure-create',
  554. },
  555. });
  556. if (!response.ok) {
  557. throw new Error(`Failed to download storefront: ${response.status} ${response.statusText}`);
  558. }
  559. // Save the tarball to a temp file
  560. const fileStream = createWriteStream(tempTarPath);
  561. // Convert the web ReadableStream to a Node.js Readable stream
  562. const nodeReadable = Readable.fromWeb(response.body as import('stream/web').ReadableStream);
  563. await pipeline(nodeReadable, fileStream);
  564. // Create target directory
  565. await fs.ensureDir(targetDir);
  566. // Extract the tarball
  567. await tar.extract({
  568. file: tempTarPath,
  569. cwd: targetDir,
  570. strip: 1, // Remove the top-level directory from the archive
  571. });
  572. // Clean up temp file
  573. await fs.remove(tempTarPath);
  574. } catch (error) {
  575. // Clean up on error
  576. await fs.remove(tempTarPath).catch(() => {
  577. // eslint-disable-next-line
  578. console.error(error);
  579. });
  580. throw error;
  581. }
  582. }
  583. /**
  584. * Finds an available port starting from the given port.
  585. * Returns the first available port within the specified range.
  586. */
  587. export async function findAvailablePort(startPort: number, range: number = 20): Promise<number> {
  588. let port = startPort;
  589. while (await isServerPortInUse(port)) {
  590. port++;
  591. if (port > startPort + range) {
  592. throw new Error(`Could not find an available port between ${startPort} and ${startPort + range}`);
  593. }
  594. }
  595. return port;
  596. }