helpers.ts 18 KB

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