diff --git a/packages/cli/cloud/.eslintignore b/packages/cli/cloud/.eslintignore new file mode 100644 index 0000000000..3933753720 --- /dev/null +++ b/packages/cli/cloud/.eslintignore @@ -0,0 +1,4 @@ +node_modules/ +.eslintrc.js +dist/ +bin/ diff --git a/packages/cli/cloud/.eslintrc.js b/packages/cli/cloud/.eslintrc.js new file mode 100644 index 0000000000..a165f57061 --- /dev/null +++ b/packages/cli/cloud/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ['custom/back/typescript'], +}; diff --git a/packages/cli/cloud/LICENSE b/packages/cli/cloud/LICENSE new file mode 100644 index 0000000000..db018546b5 --- /dev/null +++ b/packages/cli/cloud/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2015-present Strapi Solutions SAS + +Portions of the Strapi software are licensed as follows: + +* All software that resides under an "ee/" directory (the β€œEE Software”), if that directory exists, is licensed under the license defined in "ee/LICENSE". + +* All software outside of the above-mentioned directories or restrictions above is available under the "MIT Expat" license as set forth below. + +MIT Expat License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cli/cloud/README.md b/packages/cli/cloud/README.md new file mode 100644 index 0000000000..7175098087 --- /dev/null +++ b/packages/cli/cloud/README.md @@ -0,0 +1,3 @@ +# Cloud CLI + +This package includes the `cloud` CLI to manage Strapi projects on the cloud. diff --git a/packages/cli/cloud/bin/index.js b/packages/cli/cloud/bin/index.js new file mode 100755 index 0000000000..a88ceaa195 --- /dev/null +++ b/packages/cli/cloud/bin/index.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +'use strict'; + +const { runStrapiCloudCommand } = require('../dist/bin'); + +runStrapiCloudCommand(process.argv); diff --git a/packages/cli/cloud/package.json b/packages/cli/cloud/package.json new file mode 100644 index 0000000000..9c6ce24e04 --- /dev/null +++ b/packages/cli/cloud/package.json @@ -0,0 +1,79 @@ +{ + "name": "@strapi/cloud-cli", + "version": "4.24.5", + "description": "Commands to interact with the Strapi Cloud", + "keywords": [ + "strapi", + "cloud", + "cli" + ], + "homepage": "https://strapi.io", + "bugs": { + "url": "https://github.com/strapi/strapi/issues" + }, + "repository": { + "type": "git", + "url": "git://github.com/strapi/strapi.git" + }, + "license": "SEE LICENSE IN LICENSE", + "author": { + "name": "Strapi Solutions SAS", + "email": "hi@strapi.io", + "url": "https://strapi.io" + }, + "maintainers": [ + { + "name": "Strapi Solutions SAS", + "email": "hi@strapi.io", + "url": "https://strapi.io" + } + ], + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "source": "./src/index.ts", + "types": "./dist/src/index.d.ts", + "bin": "./bin/index.js", + "files": [ + "./dist", + "./bin" + ], + "scripts": { + "build": "pack-up build", + "clean": "run -T rimraf ./dist", + "lint": "run -T eslint .", + "watch": "pack-up watch" + }, + "dependencies": { + "@strapi/utils": "4.24.5", + "axios": "1.6.0", + "chalk": "4.1.2", + "cli-progress": "3.12.0", + "commander": "8.3.0", + "eventsource": "2.0.2", + "fast-safe-stringify": "2.1.1", + "fs-extra": "10.0.0", + "inquirer": "8.2.5", + "jsonwebtoken": "9.0.0", + "jwks-rsa": "3.1.0", + "lodash": "4.17.21", + "minimatch": "9.0.3", + "open": "8.4.0", + "ora": "5.4.1", + "pkg-up": "3.1.0", + "tar": "6.1.13", + "xdg-app-paths": "8.3.0", + "yup": "0.32.9" + }, + "devDependencies": { + "@strapi/pack-up": "4.23.0", + "@types/cli-progress": "3.11.5", + "@types/eventsource": "1.1.15", + "@types/lodash": "^4.14.191", + "eslint-config-custom": "4.24.5", + "tsconfig": "4.24.5" + }, + "engines": { + "node": ">=18.0.0 <=20.x.x", + "npm": ">=6.0.0" + } +} diff --git a/packages/cli/cloud/packup.config.ts b/packages/cli/cloud/packup.config.ts new file mode 100644 index 0000000000..d676ac372c --- /dev/null +++ b/packages/cli/cloud/packup.config.ts @@ -0,0 +1,20 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineConfig } from '@strapi/pack-up'; + +export default defineConfig({ + bundles: [ + { + source: './src/index.ts', + import: './dist/index.js', + require: './dist/index.js', + types: './dist/index.d.ts', + runtime: 'node', + }, + { + source: './src/bin.ts', + require: './dist/bin.js', + runtime: 'node', + }, + ], + dist: './dist', +}); diff --git a/packages/cli/cloud/src/bin.ts b/packages/cli/cloud/src/bin.ts new file mode 100644 index 0000000000..d064fe48d9 --- /dev/null +++ b/packages/cli/cloud/src/bin.ts @@ -0,0 +1,34 @@ +import { Command } from 'commander'; +import { createLogger } from './services'; +import { CLIContext } from './types'; +import { buildStrapiCloudCommands } from './index'; + +function loadStrapiCloudCommand(argv = process.argv, command = new Command()) { + // Initial program setup + command.storeOptionsAsProperties(false).allowUnknownOption(true); + + // Help command + command.helpOption('-h, --help', 'Display help for command'); + command.addHelpCommand('help [command]', 'Display help for command'); + + const cwd = process.cwd(); + + const hasDebug = argv.includes('--debug'); + const hasSilent = argv.includes('--silent'); + + const logger = createLogger({ debug: hasDebug, silent: hasSilent, timestamp: false }); + + const ctx = { + cwd, + logger, + } satisfies CLIContext; + + buildStrapiCloudCommands({ command, ctx, argv }); +} + +function runStrapiCloudCommand(argv = process.argv, command = new Command()) { + loadStrapiCloudCommand(argv, command); + command.parse(argv); +} + +export { runStrapiCloudCommand }; diff --git a/packages/cli/cloud/src/config/api.ts b/packages/cli/cloud/src/config/api.ts new file mode 100644 index 0000000000..018ca578d1 --- /dev/null +++ b/packages/cli/cloud/src/config/api.ts @@ -0,0 +1,6 @@ +import { env } from '@strapi/utils'; + +export const apiConfig = { + apiBaseUrl: env('STRAPI_CLI_CLOUD_API', 'https://cloud-cli-api.strapi.io'), + dashboardBaseUrl: env('STRAPI_CLI_CLOUD_DASHBOARD', 'https://cloud.strapi.io'), +}; diff --git a/packages/cli/cloud/src/config/local.ts b/packages/cli/cloud/src/config/local.ts new file mode 100644 index 0000000000..5ccb524210 --- /dev/null +++ b/packages/cli/cloud/src/config/local.ts @@ -0,0 +1,57 @@ +import path from 'path'; +import os from 'os'; +import fse from 'fs-extra'; +import XDGAppPaths from 'xdg-app-paths'; + +const APP_FOLDER_NAME = 'com.strapi.cli'; + +export const CONFIG_FILENAME = 'config.json'; + +export type LocalConfig = { + token?: string; + deviceId?: string; +}; + +async function checkDirectoryExists(directoryPath: string) { + try { + const fsStat = await fse.lstat(directoryPath); + return fsStat.isDirectory(); + } catch (e) { + return false; + } +} + +// Determine storage path based on the operating system +export async function getTmpStoragePath() { + const storagePath = path.join(os.tmpdir(), APP_FOLDER_NAME); + await fse.ensureDir(storagePath); + return storagePath; +} + +async function getConfigPath() { + const configDirs = XDGAppPaths(APP_FOLDER_NAME).configDirs(); + const configPath = configDirs.find(checkDirectoryExists); + + if (!configPath) { + await fse.ensureDir(configDirs[0]); + return configDirs[0]; + } + return configPath; +} + +export async function getLocalConfig(): Promise { + const configPath = await getConfigPath(); + const configFilePath = path.join(configPath, CONFIG_FILENAME); + await fse.ensureFile(configFilePath); + try { + return await fse.readJSON(configFilePath, { encoding: 'utf8', throws: true }); + } catch (e) { + return {}; + } +} + +export async function saveLocalConfig(data: LocalConfig) { + const configPath = await getConfigPath(); + const configFilePath = path.join(configPath, CONFIG_FILENAME); + await fse.writeJson(configFilePath, data, { encoding: 'utf8', spaces: 2, mode: 0o600 }); +} diff --git a/packages/cli/cloud/src/create-project/action.ts b/packages/cli/cloud/src/create-project/action.ts new file mode 100644 index 0000000000..61253b13a0 --- /dev/null +++ b/packages/cli/cloud/src/create-project/action.ts @@ -0,0 +1,73 @@ +import inquirer from 'inquirer'; +import { AxiosError } from 'axios'; +import { defaults } from 'lodash/fp'; +import type { CLIContext, ProjectAnswers, ProjectInput } from '../types'; +import { tokenServiceFactory, cloudApiFactory, local } from '../services'; + +async function handleError(ctx: CLIContext, error: Error) { + const tokenService = await tokenServiceFactory(ctx); + const { logger } = ctx; + + logger.debug(error); + if (error instanceof AxiosError) { + const errorMessage = typeof error.response?.data === 'string' ? error.response.data : null; + switch (error.response?.status) { + case 401: + logger.error('Your session has expired. Please log in again.'); + await tokenService.eraseToken(); + return; + case 403: + logger.error( + errorMessage || + 'You do not have permission to create a project. Please contact support for assistance.' + ); + return; + case 400: + logger.error(errorMessage || 'Invalid input. Please check your inputs and try again.'); + return; + case 503: + logger.error( + 'Strapi Cloud project creation is currently unavailable. Please try again later.' + ); + return; + default: + if (errorMessage) { + logger.error(errorMessage); + return; + } + break; + } + } + logger.error( + 'We encountered an issue while creating your project. Please try again in a moment. If the problem persists, contact support for assistance.' + ); +} + +export default async (ctx: CLIContext) => { + const { logger } = ctx; + const { getValidToken } = await tokenServiceFactory(ctx); + + const token = await getValidToken(); + if (!token) { + return; + } + const cloudApi = await cloudApiFactory(token); + const { data: config } = await cloudApi.config(); + const { questions, defaults: defaultValues } = config.projectCreation; + + const projectAnswersDefaulted = defaults(defaultValues); + const projectAnswers = await inquirer.prompt(questions); + + const projectInput: ProjectInput = projectAnswersDefaulted(projectAnswers); + + const spinner = logger.spinner('Setting up your project...').start(); + try { + const { data } = await cloudApi.createProject(projectInput); + await local.save({ project: data }); + spinner.succeed('Project created successfully!'); + return data; + } catch (e: Error | unknown) { + spinner.fail('Failed to create project on Strapi Cloud.'); + await handleError(ctx, e as Error); + } +}; diff --git a/packages/cli/cloud/src/create-project/command.ts b/packages/cli/cloud/src/create-project/command.ts new file mode 100644 index 0000000000..d374a558b7 --- /dev/null +++ b/packages/cli/cloud/src/create-project/command.ts @@ -0,0 +1,17 @@ +import { type StrapiCloudCommand } from '../types'; +import { runAction } from '../utils/helpers'; +import action from './action'; + +/** + * `$ create project in Strapi cloud` + */ +const command: StrapiCloudCommand = ({ command, ctx }) => { + command + .command('cloud:create-project') + .description('Create a Strapi Cloud project') + .option('-d, --debug', 'Enable debugging mode with verbose logs') + .option('-s, --silent', "Don't log anything") + .action(() => runAction('cloud:create-project', action)(ctx)); +}; + +export default command; diff --git a/packages/cli/cloud/src/create-project/index.ts b/packages/cli/cloud/src/create-project/index.ts new file mode 100644 index 0000000000..4b8e5bc83b --- /dev/null +++ b/packages/cli/cloud/src/create-project/index.ts @@ -0,0 +1,12 @@ +import action from './action'; +import command from './command'; +import type { StrapiCloudCommandInfo } from '../types'; + +export { action, command }; + +export default { + name: 'create-project', + description: 'Create a new project', + action, + command, +} as StrapiCloudCommandInfo; diff --git a/packages/cli/cloud/src/deploy-project/action.ts b/packages/cli/cloud/src/deploy-project/action.ts new file mode 100644 index 0000000000..5bbe48cadf --- /dev/null +++ b/packages/cli/cloud/src/deploy-project/action.ts @@ -0,0 +1,196 @@ +import fse from 'fs-extra'; +import path from 'path'; +import chalk from 'chalk'; +import { AxiosError } from 'axios'; +import * as crypto from 'node:crypto'; +import { apiConfig } from '../config/api'; +import { compressFilesToTar } from '../utils/compress-files'; +import createProjectAction from '../create-project/action'; +import type { CLIContext, ProjectInfos } from '../types'; +import { getTmpStoragePath } from '../config/local'; +import { cloudApiFactory, tokenServiceFactory, local } from '../services'; +import { notificationServiceFactory } from '../services/notification'; +import { loadPkg } from '../utils/pkg'; +import { buildLogsServiceFactory } from '../services/build-logs'; + +type PackageJson = { + name: string; + strapi?: { + uuid: string; + }; +}; + +async function upload( + ctx: CLIContext, + project: ProjectInfos, + token: string, + maxProjectFileSize: number +) { + const cloudApi = await cloudApiFactory(token); + // * Upload project + try { + const storagePath = await getTmpStoragePath(); + const projectFolder = path.resolve(process.cwd()); + const packageJson = (await loadPkg(ctx)) as PackageJson; + + if (!packageJson) { + ctx.logger.error( + 'Unable to deploy the project. Please make sure the package.json file is correctly formatted.' + ); + return; + } + + ctx.logger.log('πŸ“¦ Compressing project...'); + // hash packageJson.name to avoid conflicts + const hashname = crypto.createHash('sha512').update(packageJson.name).digest('hex'); + const compressedFilename = `${hashname}.tar.gz`; + try { + ctx.logger.debug( + 'Compression parameters\n', + `Storage path: ${storagePath}\n`, + `Project folder: ${projectFolder}\n`, + `Compressed filename: ${compressedFilename}` + ); + await compressFilesToTar(storagePath, projectFolder, compressedFilename); + ctx.logger.log('πŸ“¦ Project compressed successfully!'); + } catch (e: unknown) { + ctx.logger.error( + '⚠️ Project compression failed. Try again later or check for large/incompatible files.' + ); + ctx.logger.debug(e); + process.exit(1); + } + + const tarFilePath = path.resolve(storagePath, compressedFilename); + const fileStats = await fse.stat(tarFilePath); + + if (fileStats.size > maxProjectFileSize) { + ctx.logger.log( + 'Unable to proceed: Your project is too big to be transferred, please use a git repo instead.' + ); + try { + await fse.remove(tarFilePath); + } catch (e: any) { + ctx.logger.log('Unable to remove file: ', tarFilePath); + ctx.logger.debug(e); + } + return; + } + + ctx.logger.info('πŸš€ Uploading project...'); + const progressBar = ctx.logger.progressBar(100, 'Upload Progress'); + + try { + const { data } = await cloudApi.deploy( + { filePath: tarFilePath, project }, + { + onUploadProgress(progressEvent) { + const total = progressEvent.total || fileStats.size; + const percentage = Math.round((progressEvent.loaded * 100) / total); + progressBar.update(percentage); + }, + } + ); + + progressBar.update(100); + progressBar.stop(); + ctx.logger.success('✨ Upload finished!'); + return data.build_id; + } catch (e: any) { + progressBar.stop(); + if (e instanceof AxiosError && e.response?.data) { + if (e.response.status === 404) { + ctx.logger.error( + `The project does not exist. Remove the ${local.LOCAL_SAVE_FILENAME} file and try again.` + ); + } else { + ctx.logger.error(e.response.data); + } + } else { + ctx.logger.error('An error occurred while deploying the project. Please try again later.'); + } + + ctx.logger.debug(e); + } finally { + await fse.remove(tarFilePath); + } + process.exit(0); + } catch (e: any) { + ctx.logger.error('An error occurred while deploying the project. Please try again later.'); + ctx.logger.debug(e); + process.exit(1); + } +} + +async function getProject(ctx: CLIContext) { + const { project } = await local.retrieve(); + if (!project) { + try { + return await createProjectAction(ctx); + } catch (e: any) { + ctx.logger.error('An error occurred while deploying the project. Please try again later.'); + ctx.logger.debug(e); + process.exit(1); + } + } + return project; +} + +export default async (ctx: CLIContext) => { + const { getValidToken } = await tokenServiceFactory(ctx); + const cloudApiService = await cloudApiFactory(); + const token = await getValidToken(); + + if (!token) { + return; + } + + const project = await getProject(ctx); + + if (!project) { + return; + } + + try { + await cloudApiService.track('willDeployWithCLI', { projectInternalName: project.name }); + } catch (e) { + ctx.logger.debug('Failed to track willDeploy', e); + } + + const notificationService = notificationServiceFactory(ctx); + const buildLogsService = buildLogsServiceFactory(ctx); + + const { data: cliConfig } = await cloudApiService.config(); + + let maxSize: number = parseInt(cliConfig.maxProjectFileSize, 10); + if (Number.isNaN(maxSize)) { + ctx.logger.debug( + 'An error occurred while parsing the maxProjectFileSize. Using default value.' + ); + maxSize = 100000000; + } + + const buildId = await upload(ctx, project, token, maxSize); + + if (!buildId) { + return; + } + + try { + notificationService(`${apiConfig.apiBaseUrl}/notifications`, token, cliConfig); + await buildLogsService(`${apiConfig.apiBaseUrl}/v1/logs/${buildId}`, token, cliConfig); + + ctx.logger.log( + 'Visit the following URL for deployment logs. Your deployment will be available here shortly.' + ); + ctx.logger.log( + chalk.underline(`${apiConfig.dashboardBaseUrl}/projects/${project.name}/deployments`) + ); + } catch (e: Error | unknown) { + if (e instanceof Error) { + ctx.logger.error(e.message); + } else { + throw e; + } + } +}; diff --git a/packages/cli/cloud/src/deploy-project/command.ts b/packages/cli/cloud/src/deploy-project/command.ts new file mode 100644 index 0000000000..aa919ed97a --- /dev/null +++ b/packages/cli/cloud/src/deploy-project/command.ts @@ -0,0 +1,18 @@ +import { type StrapiCloudCommand } from '../types'; +import { runAction } from '../utils/helpers'; +import action from './action'; + +/** + * `$ deploy project to the cloud` + */ +const command: StrapiCloudCommand = ({ command, ctx }) => { + command + .command('cloud:deploy') + .alias('deploy') + .description('Deploy a Strapi Cloud project') + .option('-d, --debug', 'Enable debugging mode with verbose logs') + .option('-s, --silent', "Don't log anything") + .action(() => runAction('deploy', action)(ctx)); +}; + +export default command; diff --git a/packages/cli/cloud/src/deploy-project/index.ts b/packages/cli/cloud/src/deploy-project/index.ts new file mode 100644 index 0000000000..91df9ab4ae --- /dev/null +++ b/packages/cli/cloud/src/deploy-project/index.ts @@ -0,0 +1,12 @@ +import action from './action'; +import command from './command'; +import type { StrapiCloudCommandInfo } from '../types'; + +export { action, command }; + +export default { + name: 'deploy-project', + description: 'Deploy a Strapi Cloud project', + action, + command, +} as StrapiCloudCommandInfo; diff --git a/packages/cli/cloud/src/index.ts b/packages/cli/cloud/src/index.ts new file mode 100644 index 0000000000..eb76017005 --- /dev/null +++ b/packages/cli/cloud/src/index.ts @@ -0,0 +1,52 @@ +import { Command } from 'commander'; +import crypto from 'crypto'; +import deployProject from './deploy-project'; +import login from './login'; +import logout from './logout'; +import createProject from './create-project'; +import { CLIContext } from './types'; +import { getLocalConfig, saveLocalConfig } from './config/local'; + +export const cli = { + deployProject, + login, + logout, + createProject, +}; + +const cloudCommands = [deployProject, login, logout]; + +async function initCloudCLIConfig() { + const localConfig = await getLocalConfig(); + + if (!localConfig.deviceId) { + localConfig.deviceId = crypto.randomUUID(); + } + + await saveLocalConfig(localConfig); +} + +export async function buildStrapiCloudCommands({ + command, + ctx, + argv, +}: { + command: Command; + ctx: CLIContext; + argv: string[]; +}) { + await initCloudCLIConfig(); + // Load all commands + for (const cloudCommand of cloudCommands) { + try { + // Add this command to the Commander command object + await cloudCommand.command({ command, ctx, argv }); + } catch (e) { + console.error(`Failed to load command ${cloudCommand.name}`, e); + } + } +} + +export * as services from './services'; + +export * from './types'; diff --git a/packages/cli/cloud/src/login/action.ts b/packages/cli/cloud/src/login/action.ts new file mode 100644 index 0000000000..dcc5555f73 --- /dev/null +++ b/packages/cli/cloud/src/login/action.ts @@ -0,0 +1,187 @@ +import axios, { AxiosResponse, AxiosError } from 'axios'; +import chalk from 'chalk'; +import { tokenServiceFactory, cloudApiFactory } from '../services'; +import type { CloudCliConfig, CLIContext } from '../types'; +import { apiConfig } from '../config/api'; + +const openModule = import('open'); + +export default async (ctx: CLIContext): Promise => { + const { logger } = ctx; + const tokenService = await tokenServiceFactory(ctx); + const existingToken = await tokenService.retrieveToken(); + const cloudApiService = await cloudApiFactory(existingToken || undefined); + + const trackFailedLogin = async () => { + try { + await cloudApiService.track('didNotLogin', { loginMethod: 'cli' }); + } catch (e) { + logger.debug('Failed to track failed login', e); + } + }; + + if (existingToken) { + const isTokenValid = await tokenService.isTokenValid(existingToken); + if (isTokenValid) { + try { + const userInfo = await cloudApiService.getUserInfo(); + const { email } = userInfo.data.data; + if (email) { + logger.log(`You are already logged into your account (${email}).`); + } else { + logger.log('You are already logged in.'); + } + logger.log( + 'To access your dashboard, please copy and paste the following URL into your web browser:' + ); + logger.log(chalk.underline(`${apiConfig.dashboardBaseUrl}/projects`)); + return true; + } catch (e) { + logger.debug('Failed to fetch user info', e); + // If the token is invalid and request failed, we should proceed with the login process + } + } + } + + let cliConfig: CloudCliConfig; + try { + logger.info('πŸ”Œ Connecting to the Strapi Cloud API...'); + const config = await cloudApiService.config(); + cliConfig = config.data; + } catch (e: unknown) { + logger.error('πŸ₯² Oops! Something went wrong while logging you in. Please try again.'); + logger.debug(e); + return false; + } + + try { + await cloudApiService.track('willLoginAttempt', {}); + } catch (e) { + logger.debug('Failed to track login attempt', e); + } + + logger.debug('πŸ” Creating device authentication request...', { + client_id: cliConfig.clientId, + scope: cliConfig.scope, + audience: cliConfig.audience, + }); + const deviceAuthResponse = (await axios + .post(cliConfig.deviceCodeAuthUrl, { + client_id: cliConfig.clientId, + scope: cliConfig.scope, + audience: cliConfig.audience, + }) + .catch((e: AxiosError) => { + logger.error('There was an issue with the authentication process. Please try again.'); + if (e.message) { + logger.debug(e.message, e); + } else { + logger.debug(e); + } + })) as AxiosResponse; + + openModule.then((open) => { + open.default(deviceAuthResponse.data.verification_uri_complete).catch((e: Error) => { + logger.error('We encountered an issue opening the browser. Please try again later.'); + logger.debug(e.message, e); + }); + }); + + logger.log('If a browser tab does not open automatically, please follow the next steps:'); + logger.log( + `1. Open this url in your device: ${deviceAuthResponse.data.verification_uri_complete}` + ); + logger.log( + `2. Enter the following code: ${deviceAuthResponse.data.user_code} and confirm to login.\n` + ); + + const tokenPayload = { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceAuthResponse.data.device_code, + client_id: cliConfig.clientId, + }; + + let isAuthenticated = false; + + const authenticate = async () => { + const spinner = logger.spinner('Waiting for authentication'); + spinner.start(); + const spinnerFail = () => spinner.fail('Authentication failed!'); + + while (!isAuthenticated) { + try { + const tokenResponse = await axios.post(cliConfig.tokenUrl, tokenPayload); + const authTokenData = tokenResponse.data; + + if (tokenResponse.status === 200) { + // Token validation + try { + logger.debug('πŸ” Validating token...'); + await tokenService.validateToken(authTokenData.id_token, cliConfig.jwksUrl); + logger.debug('πŸ” Token validation successful!'); + } catch (e: any) { + logger.debug(e); + spinnerFail(); + throw new Error('Unable to proceed: Token validation failed'); + } + + logger.debug('πŸ” Fetching user information...'); + const cloudApiServiceWithToken = await cloudApiFactory(authTokenData.access_token); + // Call to get user info to create the user in DB if not exists + await cloudApiServiceWithToken.getUserInfo(); + logger.debug('πŸ” User information fetched successfully!'); + + try { + logger.debug('πŸ“ Saving login information...'); + await tokenService.saveToken(authTokenData.access_token); + logger.debug('πŸ“ Login information saved successfully!'); + isAuthenticated = true; + } catch (e) { + logger.error( + 'There was a problem saving your login information. Please try logging in again.' + ); + logger.debug(e); + spinnerFail(); + return false; + } + } + } catch (e: any) { + if (e.message === 'Unable to proceed: Token validation failed') { + logger.error( + 'There seems to be a problem with your login information. Please try logging in again.' + ); + spinnerFail(); + await trackFailedLogin(); + return false; + } + if ( + e.response?.data.error && + !['authorization_pending', 'slow_down'].includes(e!.response.data.error) + ) { + logger.debug(e); + spinnerFail(); + await trackFailedLogin(); + return false; + } + // Await interval before retrying + await new Promise((resolve) => { + setTimeout(resolve, deviceAuthResponse.data.interval * 1000); + }); + } + } + spinner.succeed('Authentication successful!'); + logger.log('You are now logged into Strapi Cloud.'); + logger.log( + 'To access your dashboard, please copy and paste the following URL into your web browser:' + ); + logger.log(chalk.underline(`${apiConfig.dashboardBaseUrl}/projects`)); + try { + await cloudApiService.track('didLogin', { loginMethod: 'cli' }); + } catch (e) { + logger.debug('Failed to track login', e); + } + }; + + await authenticate(); + return isAuthenticated; +}; diff --git a/packages/cli/cloud/src/login/command.ts b/packages/cli/cloud/src/login/command.ts new file mode 100644 index 0000000000..81f0a69e1b --- /dev/null +++ b/packages/cli/cloud/src/login/command.ts @@ -0,0 +1,22 @@ +import type { StrapiCloudCommand } from '../types'; +import { runAction } from '../utils/helpers'; +import action from './action'; + +/** + * `$ cloud device flow login` + */ +const command: StrapiCloudCommand = ({ command, ctx }) => { + command + .command('cloud:login') + .alias('login') + .description('Strapi Cloud Login') + .addHelpText( + 'after', + '\nAfter running this command, you will be prompted to enter your authentication information.' + ) + .option('-d, --debug', 'Enable debugging mode with verbose logs') + .option('-s, --silent', "Don't log anything") + .action(() => runAction('login', action)(ctx)); +}; + +export default command; diff --git a/packages/cli/cloud/src/login/index.ts b/packages/cli/cloud/src/login/index.ts new file mode 100644 index 0000000000..ee2c46650a --- /dev/null +++ b/packages/cli/cloud/src/login/index.ts @@ -0,0 +1,12 @@ +import action from './action'; +import command from './command'; +import type { StrapiCloudCommandInfo } from '../types'; + +export { action, command }; + +export default { + name: 'login', + description: 'Strapi Cloud Login', + action, + command, +} as StrapiCloudCommandInfo; diff --git a/packages/cli/cloud/src/logout/action.ts b/packages/cli/cloud/src/logout/action.ts new file mode 100644 index 0000000000..b387e0ca0c --- /dev/null +++ b/packages/cli/cloud/src/logout/action.ts @@ -0,0 +1,29 @@ +import type { CLIContext } from '../types'; +import { tokenServiceFactory, cloudApiFactory } from '../services'; + +export default async (ctx: CLIContext) => { + const { logger } = ctx; + const { retrieveToken, eraseToken } = await tokenServiceFactory(ctx); + + const token = await retrieveToken(); + if (!token) { + logger.log("You're already logged out."); + return; + } + const cloudApiService = await cloudApiFactory(token); + try { + // we might want also to perform extra actions like logging out from the auth0 tenant + await eraseToken(); + logger.log( + 'πŸ”Œ You have been logged out from the CLI. If you are on a shared computer, please make sure to log out from the Strapi Cloud Dashboard as well.' + ); + } catch (e) { + logger.error('πŸ₯² Oops! Something went wrong while logging you out. Please try again.'); + logger.debug(e); + } + try { + await cloudApiService.track('didLogout', { loginMethod: 'cli' }); + } catch (e) { + logger.debug('Failed to track logout event', e); + } +}; diff --git a/packages/cli/cloud/src/logout/command.ts b/packages/cli/cloud/src/logout/command.ts new file mode 100644 index 0000000000..d6aa1a693f --- /dev/null +++ b/packages/cli/cloud/src/logout/command.ts @@ -0,0 +1,18 @@ +import type { StrapiCloudCommand } from '../types'; +import { runAction } from '../utils/helpers'; +import action from './action'; + +/** + * `$ cloud device flow logout` + */ +const command: StrapiCloudCommand = ({ command, ctx }) => { + command + .command('cloud:logout') + .alias('logout') + .description('Strapi Cloud Logout') + .option('-d, --debug', 'Enable debugging mode with verbose logs') + .option('-s, --silent', "Don't log anything") + .action(() => runAction('logout', action)(ctx)); +}; + +export default command; diff --git a/packages/cli/cloud/src/logout/index.ts b/packages/cli/cloud/src/logout/index.ts new file mode 100644 index 0000000000..70308703aa --- /dev/null +++ b/packages/cli/cloud/src/logout/index.ts @@ -0,0 +1,11 @@ +import action from './action'; +import command from './command'; + +export { action, command }; + +export default { + name: 'logout', + description: 'Strapi Cloud Logout', + action, + command, +}; diff --git a/packages/cli/cloud/src/services/build-logs.ts b/packages/cli/cloud/src/services/build-logs.ts new file mode 100644 index 0000000000..922e54127c --- /dev/null +++ b/packages/cli/cloud/src/services/build-logs.ts @@ -0,0 +1,75 @@ +import EventSource from 'eventsource'; +import { CLIContext, type CloudCliConfig } from '../types'; + +const buildLogsServiceFactory = ({ logger }: CLIContext) => { + return async (url: string, token: string, cliConfig: CloudCliConfig) => { + const CONN_TIMEOUT = Number(cliConfig.buildLogsConnectionTimeout); + const MAX_RETRIES = Number(cliConfig.buildLogsMaxRetries); + + return new Promise((resolve, reject) => { + let timeoutId: NodeJS.Timeout | null = null; + let retries = 0; + + const connect = (url: string) => { + const spinner = logger.spinner('Connecting to server to get build logs'); + spinner.start(); + const es = new EventSource(`${url}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const clearExistingTimeout = () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + + const resetTimeout = () => { + clearExistingTimeout(); + timeoutId = setTimeout(() => { + if (spinner.isSpinning) { + spinner.fail( + 'We were unable to connect to the server to get build logs at this time. This could be due to a temporary issue.' + ); + } + es.close(); + reject(new Error('Connection timed out')); + }, CONN_TIMEOUT); + }; + + es.onopen = resetTimeout; + + es.addEventListener('finished', (event) => { + const data = JSON.parse(event.data); + logger.log(data.msg); + es.close(); + clearExistingTimeout(); + resolve(null); + }); + + es.addEventListener('log', (event) => { + if (spinner.isSpinning) { + spinner.succeed(); + } + resetTimeout(); + const data = JSON.parse(event.data); + logger.log(data.msg); + }); + + es.onerror = async () => { + retries += 1; + if (retries > MAX_RETRIES) { + spinner.fail('We were unable to connect to the server to get build logs at this time.'); + es.close(); + reject(new Error('Max retries reached')); + } + }; + }; + + connect(url); + }); + }; +}; + +export { buildLogsServiceFactory }; diff --git a/packages/cli/cloud/src/services/cli-api.ts b/packages/cli/cloud/src/services/cli-api.ts new file mode 100644 index 0000000000..68e6e5c308 --- /dev/null +++ b/packages/cli/cloud/src/services/cli-api.ts @@ -0,0 +1,129 @@ +import axios, { type AxiosResponse } from 'axios'; +import fse from 'fs-extra'; +import os from 'os'; +import { apiConfig } from '../config/api'; +import type { CloudCliConfig } from '../types'; +import { getLocalConfig } from '../config/local'; + +import packageJson from '../../package.json'; + +export const VERSION = 'v1'; + +export type ProjectInfos = { + name: string; + nodeVersion: string; + region: string; + plan?: string; + url?: string; +}; +export type ProjectInput = Omit; + +export type DeployResponse = { + build_id: string; + image: string; +}; + +export type TrackPayload = Record; + +export interface CloudApiService { + deploy( + deployInput: { + filePath: string; + project: { name: string }; + }, + { + onUploadProgress, + }: { + onUploadProgress: (progressEvent: { loaded: number; total?: number }) => void; + } + ): Promise>; + + createProject(projectInput: ProjectInput): Promise<{ + data: ProjectInfos; + status: number; + }>; + + getUserInfo(): Promise; + + config(): Promise>; + + listProjects(): Promise>; + + track(event: string, payload?: TrackPayload): Promise>; +} + +export async function cloudApiFactory(token?: string): Promise { + const localConfig = await getLocalConfig(); + const customHeaders = { + 'x-device-id': localConfig.deviceId, + 'x-app-version': packageJson.version, + 'x-os-name': os.type(), + 'x-os-version': os.version(), + 'x-language': Intl.DateTimeFormat().resolvedOptions().locale, + 'x-node-version': process.versions.node, + }; + const axiosCloudAPI = axios.create({ + baseURL: `${apiConfig.apiBaseUrl}/${VERSION}`, + headers: { + 'Content-Type': 'application/json', + ...customHeaders, + }, + }); + + if (token) { + axiosCloudAPI.defaults.headers.Authorization = `Bearer ${token}`; + } + + return { + deploy({ filePath, project }, { onUploadProgress }) { + return axiosCloudAPI.post( + `/deploy/${project.name}`, + { file: fse.createReadStream(filePath) }, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + onUploadProgress, + } + ); + }, + + async createProject({ name, nodeVersion, region, plan }) { + const response = await axiosCloudAPI.post('/project', { + projectName: name, + region, + nodeVersion, + plan, + }); + + return { + data: { + id: response.data.id, + name: response.data.name, + nodeVersion: response.data.nodeVersion, + region: response.data.region, + }, + status: response.status, + }; + }, + + getUserInfo() { + return axiosCloudAPI.get('/user'); + }, + + config(): Promise> { + return axiosCloudAPI.get('/config'); + }, + + listProjects() { + return axiosCloudAPI.get('/projects'); + }, + + track(event, payload = {}) { + return axiosCloudAPI.post('/track', { + event, + payload, + }); + }, + }; +} diff --git a/packages/cli/cloud/src/services/index.ts b/packages/cli/cloud/src/services/index.ts new file mode 100644 index 0000000000..24e9edc020 --- /dev/null +++ b/packages/cli/cloud/src/services/index.ts @@ -0,0 +1,4 @@ +export { cloudApiFactory } from './cli-api'; +export * as local from './strapi-info-save'; +export { tokenServiceFactory } from './token'; +export { createLogger } from './logger'; diff --git a/packages/cli/cloud/src/services/logger.ts b/packages/cli/cloud/src/services/logger.ts new file mode 100644 index 0000000000..25d459ca63 --- /dev/null +++ b/packages/cli/cloud/src/services/logger.ts @@ -0,0 +1,168 @@ +import chalk from 'chalk'; +import stringify from 'fast-safe-stringify'; + +import ora from 'ora'; +import * as cliProgress from 'cli-progress'; + +export interface LoggerOptions { + silent?: boolean; + debug?: boolean; + timestamp?: boolean; +} + +export interface Logger { + warnings: number; + errors: number; + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + success: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + log: (...args: unknown[]) => void; + spinner: (text: string) => Pick; + progressBar: ( + totalSize: number, + text: string + ) => Pick; +} + +const stringifyArg = (arg: unknown) => { + return typeof arg === 'object' ? stringify(arg) : arg; +}; + +const createLogger = (options: LoggerOptions = {}): Logger => { + const { silent = false, debug = false, timestamp = true } = options; + + const state = { errors: 0, warning: 0 }; + + return { + get warnings() { + return state.warning; + }, + + get errors() { + return state.errors; + }, + + async debug(...args) { + if (silent || !debug) { + return; + } + + console.log( + chalk.cyan(`[DEBUG]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), + ...args.map(stringifyArg) + ); + }, + + info(...args) { + if (silent) { + return; + } + + console.info( + chalk.blue(`[INFO]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), + ...args.map(stringifyArg) + ); + }, + + log(...args) { + if (silent) { + return; + } + + console.info( + chalk.blue(`${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), + ...args.map(stringifyArg) + ); + }, + + success(...args) { + if (silent) { + return; + } + + console.info( + chalk.green(`[SUCCESS]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), + ...args.map(stringifyArg) + ); + }, + + warn(...args) { + state.warning += 1; + + if (silent) { + return; + } + + console.warn( + chalk.yellow(`[WARN]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), + ...args.map(stringifyArg) + ); + }, + + error(...args) { + state.errors += 1; + + if (silent) { + return; + } + + console.error( + chalk.red(`[ERROR]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), + ...args.map(stringifyArg) + ); + }, + + // @ts-expect-error – returning a subpart of ora is fine because the types tell us what is what. + spinner(text: string) { + if (silent) { + return { + succeed() { + return this; + }, + fail() { + return this; + }, + start() { + return this; + }, + text: '', + isSpinning: false, + }; + } + + return ora(text); + }, + + progressBar(totalSize: number, text: string) { + if (silent) { + return { + start() { + return this; + }, + stop() { + return this; + }, + update() { + return this; + }, + }; + } + + const progressBar = new cliProgress.SingleBar({ + format: `${text ? `${text} |` : ''}${chalk.green('{bar}')}| {percentage}%`, + barCompleteChar: '\u2588', + barIncompleteChar: '\u2591', + hideCursor: true, + forceRedraw: true, + }); + + progressBar.start(totalSize, 0); + + return progressBar; + }, + }; +}; + +export { createLogger }; diff --git a/packages/cli/cloud/src/services/notification.ts b/packages/cli/cloud/src/services/notification.ts new file mode 100644 index 0000000000..6567f06049 --- /dev/null +++ b/packages/cli/cloud/src/services/notification.ts @@ -0,0 +1,47 @@ +import EventSource from 'eventsource'; +import type { CLIContext, CloudCliConfig } from '../types'; + +type Event = { + type: string; + data: string; + lastEventId: string; + origin: string; +}; + +export function notificationServiceFactory({ logger }: CLIContext) { + return (url: string, token: string, cliConfig: CloudCliConfig) => { + const CONN_TIMEOUT = Number(cliConfig.notificationsConnectionTimeout); + + const es = new EventSource(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + let timeoutId: NodeJS.Timeout; + + const resetTimeout = () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + logger.log( + 'We were unable to connect to the server at this time. This could be due to a temporary issue. Please try again in a moment.' + ); + es.close(); + }, CONN_TIMEOUT); // 5 minutes + }; + + es.onopen = resetTimeout; + es.onmessage = (event: Event) => { + resetTimeout(); + const data = JSON.parse(event.data); + + if (data.message) { + logger.log(data.message); + } + + // Close connection when a specific event is received + if (data.event === 'deploymentFinished' || data.event === 'deploymentFailed') { + es.close(); + } + }; + }; +} diff --git a/packages/cli/cloud/src/services/strapi-info-save.ts b/packages/cli/cloud/src/services/strapi-info-save.ts new file mode 100644 index 0000000000..253fdaf7d2 --- /dev/null +++ b/packages/cli/cloud/src/services/strapi-info-save.ts @@ -0,0 +1,30 @@ +import fse from 'fs-extra'; +import path from 'path'; +import type { ProjectInfos } from './cli-api'; + +export const LOCAL_SAVE_FILENAME = '.strapi-cloud.json'; + +export type LocalSave = { + project?: ProjectInfos; +}; + +export async function save(data: LocalSave, { directoryPath }: { directoryPath?: string } = {}) { + const alreadyInFileData = await retrieve({ directoryPath }); + const storedData = { ...alreadyInFileData, ...data }; + const pathToFile = path.join(directoryPath || process.cwd(), LOCAL_SAVE_FILENAME); + // Ensure the directory exists + await fse.ensureDir(path.dirname(pathToFile)); + await fse.writeJson(pathToFile, storedData, { encoding: 'utf8' }); +} + +export async function retrieve({ + directoryPath, +}: { directoryPath?: string } = {}): Promise { + const pathToFile = path.join(directoryPath || process.cwd(), LOCAL_SAVE_FILENAME); + const pathExists = await fse.pathExists(pathToFile); + if (!pathExists) { + return {}; + } + + return fse.readJSON(pathToFile, { encoding: 'utf8' }); +} diff --git a/packages/cli/cloud/src/services/token.ts b/packages/cli/cloud/src/services/token.ts new file mode 100644 index 0000000000..54447676f3 --- /dev/null +++ b/packages/cli/cloud/src/services/token.ts @@ -0,0 +1,146 @@ +import jwksClient, { type JwksClient, type SigningKey } from 'jwks-rsa'; +import type { JwtHeader, VerifyErrors } from 'jsonwebtoken'; +import jwt from 'jsonwebtoken'; +import { getLocalConfig, saveLocalConfig } from '../config/local'; +import type { CloudCliConfig, CLIContext } from '../types'; +import { cloudApiFactory } from './cli-api'; + +let cliConfig: CloudCliConfig; + +interface DecodedToken { + [key: string]: any; +} + +export async function tokenServiceFactory({ logger }: { logger: CLIContext['logger'] }) { + const cloudApiService = await cloudApiFactory(); + + async function saveToken(str: string) { + const appConfig = await getLocalConfig(); + + if (!appConfig) { + logger.error('There was a problem saving your token. Please try again.'); + return; + } + + appConfig.token = str; + + try { + await saveLocalConfig(appConfig); + } catch (e: Error | unknown) { + logger.debug(e); + logger.error('There was a problem saving your token. Please try again.'); + } + } + + async function retrieveToken() { + const appConfig = await getLocalConfig(); + if (appConfig.token) { + // check if token is still valid + if (await isTokenValid(appConfig.token)) { + return appConfig.token; + } + } + return undefined; + } + + async function validateToken(idToken: string, jwksUrl: string): Promise { + const client: JwksClient = jwksClient({ + jwksUri: jwksUrl, + }); + + // Get the Key from the JWKS using the token header's Key ID (kid) + const getKey = (header: JwtHeader, callback: (e: Error | null, key?: string) => void) => { + client.getSigningKey(header.kid, (e: Error | null, key?: SigningKey) => { + if (e) { + callback(e); + } else if (key) { + const publicKey = 'publicKey' in key ? key.publicKey : key.rsaPublicKey; + callback(null, publicKey); + } else { + callback(new Error('Key not found')); + } + }); + }; + + // Decode the JWT token to get the header and payload + const decodedToken = jwt.decode(idToken, { complete: true }) as DecodedToken; + if (!decodedToken) { + if (typeof idToken === 'undefined' || idToken === '') { + logger.warn('You need to be logged in to use this feature. Please log in and try again.'); + } else { + logger.error( + 'There seems to be a problem with your login information. Please try logging in again.' + ); + } + } + + // Verify the JWT token signature using the JWKS Key + return new Promise((resolve, reject) => { + jwt.verify(idToken, getKey, (err: VerifyErrors | null) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + async function isTokenValid(token: string) { + try { + const config = await cloudApiService.config(); + + cliConfig = config.data; + if (token) { + await validateToken(token, cliConfig.jwksUrl); + return true; + } + return false; + } catch (e) { + logger.debug(e); + return false; + } + } + + async function eraseToken() { + const appConfig = await getLocalConfig(); + if (!appConfig) { + return; + } + + delete appConfig.token; + + try { + await saveLocalConfig(appConfig); + } catch (e: Error | unknown) { + logger.debug(e); + logger.error( + 'There was an issue removing your login information. Please try logging out again.' + ); + throw e; + } + } + + async function getValidToken() { + const token = await retrieveToken(); + if (!token) { + logger.log('No token found. Please login first.'); + return null; + } + + if (!(await isTokenValid(token))) { + logger.log('Unable to proceed: Token is expired or not valid. Please login again.'); + return null; + } + return token; + } + + return { + saveToken, + retrieveToken, + validateToken, + isTokenValid, + eraseToken, + getValidToken, + }; +} diff --git a/packages/cli/cloud/src/types.ts b/packages/cli/cloud/src/types.ts new file mode 100644 index 0000000000..7023e66543 --- /dev/null +++ b/packages/cli/cloud/src/types.ts @@ -0,0 +1,49 @@ +import type { Command } from 'commander'; +import type { DistinctQuestion } from 'inquirer'; +import { Logger } from './services/logger'; + +export type ProjectAnswers = { + name: string; + nodeVersion: string; + region: string; + plan: string; +}; + +export type CloudCliConfig = { + clientId: string; + baseUrl: string; + deviceCodeAuthUrl: string; + audience: string; + scope: string; + tokenUrl: string; + jwksUrl: string; + projectCreation: { + questions: ReadonlyArray>; + defaults: Partial; + introText: string; + }; + buildLogsConnectionTimeout: string; + buildLogsMaxRetries: string; + notificationsConnectionTimeout: string; + maxProjectFileSize: string; +}; + +export interface CLIContext { + cwd: string; + logger: Logger; +} + +export type StrapiCloudCommand = (params: { + command: Command; + argv: string[]; + ctx: CLIContext; +}) => void | Promise; + +export type StrapiCloudCommandInfo = { + name: string; + description: string; + command: StrapiCloudCommand; + action: (ctx: CLIContext) => Promise; +}; + +export type * from './services/cli-api'; diff --git a/packages/cli/cloud/src/utils/compress-files.ts b/packages/cli/cloud/src/utils/compress-files.ts new file mode 100644 index 0000000000..a08592951e --- /dev/null +++ b/packages/cli/cloud/src/utils/compress-files.ts @@ -0,0 +1,89 @@ +// TODO Migrate to fs-extra +import * as fs from 'fs'; +import * as tar from 'tar'; +import * as path from 'path'; +import { minimatch } from 'minimatch'; + +const IGNORED_PATTERNS = [ + '**/.git/**', + '**/node_modules/**', + '**/build/**', + '**/dist/**', + '**/.cache/**', + '**/.circleci/**', + '**/.github/**', + '**/.gitignore', + '**/.gitkeep', + '**/.gitlab-ci.yml', + '**/.idea/**', + '**/.vscode/**', +]; + +const getFiles = ( + dirPath: string, + ignorePatterns: string[] = [], + arrayOfFiles: string[] = [], + subfolder: string = '' +): string[] => { + const entries = fs.readdirSync(path.join(dirPath, subfolder)); + entries.forEach((entry) => { + const entryPathFromRoot = path.join(subfolder, entry); + const entryPath = path.relative(dirPath, entryPathFromRoot); + const isIgnored = isIgnoredFile(dirPath, entryPathFromRoot, ignorePatterns); + if (isIgnored) { + return; + } + if (fs.statSync(entryPath).isDirectory()) { + getFiles(dirPath, ignorePatterns, arrayOfFiles, entryPathFromRoot); + } else { + arrayOfFiles.push(entryPath); + } + }); + return arrayOfFiles; +}; + +const isIgnoredFile = (folderPath: string, file: string, ignorePatterns: string[]): boolean => { + ignorePatterns.push(...IGNORED_PATTERNS); + const relativeFilePath = path.join(folderPath, file); + let isIgnored = false; + for (const pattern of ignorePatterns) { + if (pattern.startsWith('!')) { + if (minimatch(relativeFilePath, pattern.slice(1), { matchBase: true, dot: true })) { + return false; + } + } else if (minimatch(relativeFilePath, pattern, { matchBase: true, dot: true })) { + if (path.basename(file) !== '.gitkeep') { + isIgnored = true; + } + } + } + return isIgnored; +}; + +const readGitignore = (folderPath: string): string[] => { + const gitignorePath = path.resolve(folderPath, '.gitignore'); + if (!fs.existsSync(gitignorePath)) return []; + const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8'); + return gitignoreContent + .split(/\r?\n/) + .filter((line) => Boolean(line.trim()) && !line.startsWith('#')); +}; + +const compressFilesToTar = async ( + storagePath: string, + folderToCompress: string, + filename: string +): Promise => { + const ignorePatterns = readGitignore(folderToCompress); + const filesToCompress = getFiles(folderToCompress, ignorePatterns); + + return tar.c( + { + gzip: true, + file: path.resolve(storagePath, filename), + }, + filesToCompress + ); +}; + +export { compressFilesToTar, isIgnoredFile }; diff --git a/packages/cli/cloud/src/utils/helpers.ts b/packages/cli/cloud/src/utils/helpers.ts new file mode 100644 index 0000000000..2fb4042599 --- /dev/null +++ b/packages/cli/cloud/src/utils/helpers.ts @@ -0,0 +1,45 @@ +import chalk from 'chalk'; +import { has } from 'lodash/fp'; + +// TODO: Remove duplicated code by extracting to a shared package + +const assertCwdContainsStrapiProject = (name: string) => { + const logErrorAndExit = () => { + console.log( + `You need to run ${chalk.yellow( + `strapi ${name}` + )} in a Strapi project. Make sure you are in the right directory.` + ); + process.exit(1); + }; + + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const pkgJSON = require(`${process.cwd()}/package.json`); + if ( + !has('dependencies.@strapi/strapi', pkgJSON) && + !has('devDependencies.@strapi/strapi', pkgJSON) + ) { + logErrorAndExit(); + } + } catch (err) { + logErrorAndExit(); + } +}; + +const runAction = + (name: string, action: (...args: any[]) => Promise) => + (...args: unknown[]) => { + assertCwdContainsStrapiProject(name); + + Promise.resolve() + .then(() => { + return action(...args); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); + }; + +export { runAction }; diff --git a/packages/cli/cloud/src/utils/pkg.ts b/packages/cli/cloud/src/utils/pkg.ts new file mode 100644 index 0000000000..55e8384309 --- /dev/null +++ b/packages/cli/cloud/src/utils/pkg.ts @@ -0,0 +1,128 @@ +// TODO Migrate to fs-extra +import fs from 'fs/promises'; +import os from 'os'; +import pkgUp from 'pkg-up'; +import * as yup from 'yup'; +import chalk from 'chalk'; +import { Logger } from '../services/logger'; + +interface Export { + types?: string; + source: string; + module?: string; + import?: string; + require?: string; + default: string; +} + +const packageJsonSchema = yup.object({ + name: yup.string().required(), + exports: yup.lazy((value) => + yup + .object( + typeof value === 'object' + ? Object.entries(value).reduce((acc, [key, value]) => { + if (typeof value === 'object') { + acc[key] = yup + .object({ + types: yup.string().optional(), + source: yup.string().required(), + module: yup.string().optional(), + import: yup.string().required(), + require: yup.string().required(), + default: yup.string().required(), + }) + .noUnknown(true); + } else { + acc[key] = yup + .string() + .matches(/^\.\/.*\.json$/) + .required(); + } + + return acc; + }, {} as Record | yup.SchemaOf>) + : undefined + ) + .optional() + ), +}); + +type PackageJson = yup.Asserts; + +/** + * @description being a task to load the package.json starting from the current working directory + * using a shallow find for the package.json and `fs` to read the file. If no package.json is found, + * the process will throw with an appropriate error message. + */ +const loadPkg = async ({ cwd, logger }: { cwd: string; logger: Logger }): Promise => { + const pkgPath = await pkgUp({ cwd }); + + if (!pkgPath) { + throw new Error('Could not find a package.json in the current directory'); + } + + const buffer = await fs.readFile(pkgPath); + + const pkg = JSON.parse(buffer.toString()); + + logger.debug('Loaded package.json:', os.EOL, pkg); + + return pkg; +}; + +/** + * @description validate the package.json against a standardised schema using `yup`. + * If the validation fails, the process will throw with an appropriate error message. + */ +const validatePkg = async ({ pkg }: { pkg: object }): Promise => { + try { + const validatedPkg = await packageJsonSchema.validate(pkg, { + strict: true, + }); + + return validatedPkg; + } catch (err) { + if (err instanceof yup.ValidationError) { + switch (err.type) { + case 'required': + if (err.path) { + throw new Error( + `'${err.path}' in 'package.json' is required as type '${chalk.magenta( + yup.reach(packageJsonSchema, err.path).type + )}'` + ); + } + break; + /** + * This will only be thrown if there are keys in the export map + * that we don't expect so we can therefore make some assumptions + */ + case 'noUnknown': + if (err.path && err.params && 'unknown' in err.params) { + throw new Error( + `'${err.path}' in 'package.json' contains the unknown key ${chalk.magenta( + err.params.unknown + )}, for compatability only the following keys are allowed: ${chalk.magenta( + "['types', 'source', 'import', 'require', 'default']" + )}` + ); + } + break; + default: + if (err.path && err.params && 'type' in err.params && 'value' in err.params) { + throw new Error( + `'${err.path}' in 'package.json' must be of type '${chalk.magenta( + err.params.type + )}' (recieved '${chalk.magenta(typeof err.params.value)}')` + ); + } + } + } + + throw err; + } +}; + +export type { PackageJson, Export }; +export { loadPkg, validatePkg }; diff --git a/packages/cli/cloud/src/utils/tests/compress-files.test.ts b/packages/cli/cloud/src/utils/tests/compress-files.test.ts new file mode 100644 index 0000000000..3023e72131 --- /dev/null +++ b/packages/cli/cloud/src/utils/tests/compress-files.test.ts @@ -0,0 +1,37 @@ +import path from 'path'; +import os from 'os'; +import { isIgnoredFile } from '../compress-files'; + +describe('isIgnoredFile', () => { + const folderPath = os.tmpdir(); // We are using the system's directory path for simulating a real path + it('should correctly handle various ignore patterns', () => { + const allFiles = [ + path.join(folderPath, 'file1.txt'), + path.join(folderPath, 'file2.txt'), + path.join(folderPath, 'node_modules', 'file3.js'), + path.join(folderPath, '.git', 'file4.js'), + path.join(folderPath, 'dist', 'file5.js'), + path.join(folderPath, 'public', 'uploads', '.gitkeep'), + path.join(folderPath, 'src', 'secret', 'file6.js'), + path.join(folderPath, 'src', 'secret', 'keep.me'), + path.join(folderPath, 'test', 'file7.test.ts'), + ]; + const ignorePatterns = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '!public/uploads/.gitkeep', + '!**/*.test.ts', + '**/src/secret/**', + '!**/src/secret/keep.me', + ]; + const result = allFiles.filter((file) => !isIgnoredFile(folderPath, file, ignorePatterns)); + expect(result).toEqual([ + path.join(folderPath, 'file1.txt'), + path.join(folderPath, 'file2.txt'), + path.join(folderPath, 'public', 'uploads', '.gitkeep'), + path.join(folderPath, 'src', 'secret', 'keep.me'), + path.join(folderPath, 'test', 'file7.test.ts'), + ]); + }); +}); diff --git a/packages/cli/cloud/tsconfig.build.json b/packages/cli/cloud/tsconfig.build.json new file mode 100644 index 0000000000..c80c96eb9b --- /dev/null +++ b/packages/cli/cloud/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "**/__tests__/**"] +} diff --git a/packages/cli/cloud/tsconfig.eslint.json b/packages/cli/cloud/tsconfig.eslint.json new file mode 100644 index 0000000000..fc8520e737 --- /dev/null +++ b/packages/cli/cloud/tsconfig.eslint.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/packages/cli/cloud/tsconfig.json b/packages/cli/cloud/tsconfig.json new file mode 100644 index 0000000000..327ffb9a92 --- /dev/null +++ b/packages/cli/cloud/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/base.json", + "include": ["src", "packup.config.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/cli/create-strapi-app/package.json b/packages/cli/create-strapi-app/package.json index 2e35de35d0..4e9d3601e2 100644 --- a/packages/cli/create-strapi-app/package.json +++ b/packages/cli/create-strapi-app/package.json @@ -43,7 +43,9 @@ "watch": "pack-up watch" }, "dependencies": { + "@strapi/cloud-cli": "4.24.5", "@strapi/generate-new": "4.24.5", + "chalk": "4.1.2", "commander": "8.3.0", "inquirer": "8.2.5" }, diff --git a/packages/cli/create-strapi-app/src/cloud.ts b/packages/cli/create-strapi-app/src/cloud.ts new file mode 100644 index 0000000000..287f3446e6 --- /dev/null +++ b/packages/cli/create-strapi-app/src/cloud.ts @@ -0,0 +1,107 @@ +import inquirer from 'inquirer'; +import { resolve } from 'node:path'; +import { cli as cloudCli, services as cloudServices } from '@strapi/cloud-cli'; +import parseToChalk from './utils/parse-to-chalk'; + +interface CloudError { + response: { + status: number; + data: string | object; + }; +} + +function assertCloudError(e: unknown): asserts e is CloudError { + if ((e as CloudError).response === undefined) { + throw Error('Expected CloudError'); + } +} + +export async function handleCloudProject(projectName: string): Promise { + const logger = cloudServices.createLogger({ + silent: false, + debug: process.argv.includes('--debug'), + timestamp: false, + }); + let cloudApiService = await cloudServices.cloudApiFactory(); + const defaultErrorMessage = + 'An error occurred while trying to interact with Strapi Cloud. Use strapi deploy command once the project is generated.'; + + try { + const { data: config } = await cloudApiService.config(); + logger.log(parseToChalk(config.projectCreation.introText)); + } catch (e: unknown) { + logger.debug(e); + logger.error(defaultErrorMessage); + return; + } + const { userChoice } = await inquirer.prompt<{ userChoice: string }>([ + { + type: 'list', + name: 'userChoice', + message: `Please log in or sign up.`, + choices: ['Login/Sign up', 'Skip'], + }, + ]); + + if (userChoice !== 'Skip') { + const cliContext = { + logger, + cwd: process.cwd(), + }; + const projectCreationSpinner = logger.spinner('Creating project on Strapi Cloud'); + + try { + const tokenService = await cloudServices.tokenServiceFactory(cliContext); + const loginSuccess = await cloudCli.login.action(cliContext); + if (!loginSuccess) { + return; + } + logger.debug('Retrieving token'); + const token = await tokenService.retrieveToken(); + + cloudApiService = await cloudServices.cloudApiFactory(token); + + logger.debug('Retrieving config'); + const { data: config } = await cloudApiService.config(); + logger.debug('config', config); + const defaultProjectValues = config.projectCreation?.defaults || {}; + logger.debug('default project values', defaultProjectValues); + projectCreationSpinner.start(); + const { data: project } = await cloudApiService.createProject({ + nodeVersion: process.versions?.node?.slice(1, 3) || '20', + region: 'NYC', + plan: 'trial', + ...defaultProjectValues, + name: projectName, + }); + projectCreationSpinner.succeed('Project created on Strapi Cloud'); + const projectPath = resolve(projectName); + logger.debug(project, projectPath); + await cloudServices.local.save({ project }, { directoryPath: projectPath }); + } catch (e: Error | CloudError | unknown) { + logger.debug(e); + try { + assertCloudError(e); + if (e.response.status === 403) { + const message = + typeof e.response.data === 'string' + ? e.response.data + : 'We are sorry, but we are not able to create a Strapi Cloud project for you at the moment.'; + if (projectCreationSpinner.isSpinning) { + projectCreationSpinner.fail(message); + } else { + logger.warn(message); + } + return; + } + } catch (e) { + /* empty */ + } + if (projectCreationSpinner.isSpinning) { + projectCreationSpinner.fail(defaultErrorMessage); + } else { + logger.error(defaultErrorMessage); + } + } + } +} diff --git a/packages/cli/create-strapi-app/src/create-strapi-app.ts b/packages/cli/create-strapi-app/src/create-strapi-app.ts index 28deb9defa..3b31c70058 100644 --- a/packages/cli/create-strapi-app/src/create-strapi-app.ts +++ b/packages/cli/create-strapi-app/src/create-strapi-app.ts @@ -1,9 +1,15 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import commander from 'commander'; -import { checkInstallPath, generateNewApp } from '@strapi/generate-new'; +import { + checkInstallPath, + checkRequirements, + generateNewApp, + type NewOptions, +} from '@strapi/generate-new'; import promptUser from './utils/prompt-user'; import type { Program } from './types'; +import { handleCloudProject } from './cloud'; const packageJson = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8')); @@ -27,6 +33,7 @@ command .option('--use-npm', 'Force usage of npm instead of yarn to create the project') .option('--debug', 'Display database connection error') .option('--quickstart', 'Quickstart app creation') + .option('--skip-cloud', 'Skip cloud login and project creation') .option('--dbclient ', 'Database client') .option('--dbhost ', 'Database host') .option('--dbport ', 'Database port') @@ -44,12 +51,20 @@ command }) .parse(process.argv); -function generateApp(projectName: string, options: unknown) { +async function generateApp( + projectName: string, + options: Partial & { skipCloud?: boolean | undefined } +) { if (!projectName) { console.error('Please specify the of your project when using --quickstart'); process.exit(1); } + if (!options.skipCloud) { + checkRequirements(); + await handleCloudProject(projectName); + } + return generateNewApp(projectName, options).then(() => { if (process.platform === 'win32') { process.exit(0); @@ -106,5 +121,5 @@ async function initProject(projectName: string, programArgs: Program) { ...options, }; - return generateApp(directory, generateStrapiAppOptions); + await generateApp(directory, generateStrapiAppOptions); } diff --git a/packages/cli/create-strapi-app/src/types.ts b/packages/cli/create-strapi-app/src/types.ts index 73149299d0..a2b32e8015 100644 --- a/packages/cli/create-strapi-app/src/types.ts +++ b/packages/cli/create-strapi-app/src/types.ts @@ -3,6 +3,7 @@ export interface Program { useNpm?: boolean; debug?: boolean; quickstart?: boolean; + skipCloud?: boolean; dbclient?: string; dbhost?: string; dbport?: string; diff --git a/packages/cli/create-strapi-app/src/utils/parse-to-chalk.ts b/packages/cli/create-strapi-app/src/utils/parse-to-chalk.ts new file mode 100644 index 0000000000..a785ca93f0 --- /dev/null +++ b/packages/cli/create-strapi-app/src/utils/parse-to-chalk.ts @@ -0,0 +1,24 @@ +import chalk from 'chalk'; + +// TODO: move styles to API + +const supportedStyles = { + magentaBright: chalk.magentaBright, + blueBright: chalk.blueBright, + yellowBright: chalk.yellowBright, + green: chalk.green, + red: chalk.red, + bold: chalk.bold, + italic: chalk.italic, +}; + +export default function parseToChalk(template: string) { + let result = template; + + for (const [color, chalkFunction] of Object.entries(supportedStyles)) { + const regex = new RegExp(`{${color}}(.*?){/${color}}`, 'g'); + result = result.replace(regex, (_, p1) => chalkFunction(p1.trim())); + } + + return result; +} diff --git a/packages/core/strapi/package.json b/packages/core/strapi/package.json index 0e3e39eb32..0f138442d3 100644 --- a/packages/core/strapi/package.json +++ b/packages/core/strapi/package.json @@ -114,6 +114,7 @@ "@koa/cors": "5.0.0", "@koa/router": "10.1.1", "@strapi/admin": "4.24.5", + "@strapi/cloud-cli": "4.24.5", "@strapi/content-releases": "4.24.5", "@strapi/data-transfer": "4.24.5", "@strapi/database": "4.24.5", @@ -133,6 +134,7 @@ "boxen": "5.1.2", "chalk": "4.1.2", "ci-info": "3.8.0", + "cli-progress": "3.12.0", "cli-table3": "0.6.2", "commander": "8.3.0", "concurrently": "8.2.2", diff --git a/packages/core/strapi/src/commands/actions/plugin/init/action.ts b/packages/core/strapi/src/commands/actions/plugin/init/action.ts index 2c1efd2206..c2b35257b5 100644 --- a/packages/core/strapi/src/commands/actions/plugin/init/action.ts +++ b/packages/core/strapi/src/commands/actions/plugin/init/action.ts @@ -119,7 +119,7 @@ const PLUGIN_TEMPLATE = defineTemplate(async ({ logger, gitConfig, packagePath } name: 'repo', type: 'text', message: 'git url', - validate(v) { + validate(v: string) { if (!v) { return true; } @@ -140,7 +140,7 @@ const PLUGIN_TEMPLATE = defineTemplate(async ({ logger, gitConfig, packagePath } type: 'text', message: 'plugin name', initial: () => repo?.name ?? '', - validate(v) { + validate(v: string) { if (!v) { return 'package name is required'; } @@ -181,7 +181,7 @@ const PLUGIN_TEMPLATE = defineTemplate(async ({ logger, gitConfig, packagePath } type: 'text', message: 'plugin license', initial: 'MIT', - validate(v) { + validate(v: string) { if (!v) { return 'license is required'; } diff --git a/packages/core/strapi/src/commands/index.ts b/packages/core/strapi/src/commands/index.ts index ec4d0bfef1..175003f72f 100644 --- a/packages/core/strapi/src/commands/index.ts +++ b/packages/core/strapi/src/commands/index.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { buildStrapiCloudCommands } from '@strapi/cloud-cli'; import createAdminUser from './actions/admin/create-user/command'; import resetAdminUserPassword from './actions/admin/reset-user-password/command'; @@ -62,6 +63,10 @@ const strapiCommands = { uninstallCommand, versionCommand, watchAdminCommand, + /** + * Cloud + */ + buildStrapiCloudCommands, /** * Plugins */ @@ -114,14 +119,14 @@ const buildStrapiCommand = async (argv: string[], command = new Command()) => { } satisfies CLIContext; // Load all commands - keys.forEach((name) => { + for (const name of keys) { try { // Add this command to the Commander command object - strapiCommands[name]({ command, argv, ctx }); + await strapiCommands[name]({ command, argv, ctx }); } catch (e) { console.error(`Failed to load command ${name}`, e); } - }); + } return command; }; diff --git a/packages/core/strapi/src/commands/utils/logger.ts b/packages/core/strapi/src/commands/utils/logger.ts index ddfee2cde3..690fb4ae32 100644 --- a/packages/core/strapi/src/commands/utils/logger.ts +++ b/packages/core/strapi/src/commands/utils/logger.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; -import ora from 'ora'; +import ora, { Ora } from 'ora'; +import * as cliProgress from 'cli-progress'; export interface LoggerOptions { silent?: boolean; @@ -12,12 +13,43 @@ export interface Logger { errors: number; debug: (...args: unknown[]) => void; info: (...args: unknown[]) => void; + success: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; log: (...args: unknown[]) => void; - spinner: (text: string) => Pick; + spinner: (text: string) => Pick; + progressBar: ( + totalSize: number, + text: string + ) => Pick; } +const silentSpinner = { + succeed() { + return this; + }, + fail() { + return this; + }, + start() { + return this; + }, + text: '', + isSpinning: false, +} as Ora; + +const silentProgressBar = { + start() { + return this; + }, + stop() { + return this; + }, + update() { + return this; + }, +} as unknown as cliProgress.SingleBar; + const createLogger = (options: LoggerOptions = {}): Logger => { const { silent = false, debug = false, timestamp = true } = options; @@ -62,6 +94,17 @@ const createLogger = (options: LoggerOptions = {}): Logger => { console.info(chalk.blue(`${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), ...args); }, + success(...args) { + if (silent) { + return; + } + + console.info( + chalk.green(`[SUCCESS]${timestamp ? `\t[${new Date().toISOString()}]` : ''}`), + ...args + ); + }, + warn(...args) { state.warning += 1; @@ -88,25 +131,31 @@ const createLogger = (options: LoggerOptions = {}): Logger => { ); }, - // @ts-expect-error – returning a subpart of ora is fine because the types tell us what is what. spinner(text: string) { if (silent) { - return { - succeed() { - return this; - }, - fail() { - return this; - }, - start() { - return this; - }, - text: '', - }; + return silentSpinner; } return ora(text); }, + + progressBar(totalSize: number, text: string) { + if (silent) { + return silentProgressBar; + } + + const progressBar = new cliProgress.SingleBar({ + format: `${text ? `${text} |` : ''}${chalk.green('{bar}')}| {percentage}%`, + barCompleteChar: '\u2588', + barIncompleteChar: '\u2591', + hideCursor: true, + forceRedraw: true, + }); + + progressBar.start(totalSize, 0); + + return progressBar; + }, }; }; diff --git a/packages/generators/app/src/create-project.ts b/packages/generators/app/src/create-project.ts index a62290c09a..2b96c687f6 100644 --- a/packages/generators/app/src/create-project.ts +++ b/packages/generators/app/src/create-project.ts @@ -220,6 +220,9 @@ export default async function createProject( console.log(` ${cmd} build`); console.log(' Build Strapi admin panel.'); console.log(); + console.log(` ${cmd} deploy`); + console.log(' Deploy Strapi project.'); + console.log(); console.log(` ${cmd} strapi`); console.log(` Display all available commands.`); console.log(); diff --git a/packages/generators/app/src/index.ts b/packages/generators/app/src/index.ts index 9c2ba1c224..e2d0c99374 100644 --- a/packages/generators/app/src/index.ts +++ b/packages/generators/app/src/index.ts @@ -13,6 +13,9 @@ import machineID from './utils/machine-id'; import type { Scope, NewOptions } from './types'; export { default as checkInstallPath } from './utils/check-install-path'; +export { default as checkRequirements } from './utils/check-requirements'; + +export type { NewOptions } from './types'; const packageJson = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8')); diff --git a/packages/generators/app/src/resources/dot-files/common/gitignore b/packages/generators/app/src/resources/dot-files/common/gitignore index 2121d2e4f4..91e0eb7ad7 100644 --- a/packages/generators/app/src/resources/dot-files/common/gitignore +++ b/packages/generators/app/src/resources/dot-files/common/gitignore @@ -112,3 +112,4 @@ exports dist build .strapi-updater.json +.strapi-cloud.json diff --git a/packages/generators/app/src/resources/json/common/package.json.ts b/packages/generators/app/src/resources/json/common/package.json.ts index 9f5396a2b6..67b931b7c2 100644 --- a/packages/generators/app/src/resources/json/common/package.json.ts +++ b/packages/generators/app/src/resources/json/common/package.json.ts @@ -31,6 +31,7 @@ export default (opts: Opts) => { start: 'strapi start', build: 'strapi build', strapi: 'strapi', + deploy: 'strapi deploy', }, devDependencies: {}, dependencies: { diff --git a/yarn.lock b/yarn.lock index 3885e38b65..894266b7d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7940,6 +7940,40 @@ __metadata: languageName: unknown linkType: soft +"@strapi/cloud-cli@npm:4.24.5, @strapi/cloud-cli@workspace:packages/cli/cloud": + version: 0.0.0-use.local + resolution: "@strapi/cloud-cli@workspace:packages/cli/cloud" + dependencies: + "@strapi/pack-up": "npm:4.23.0" + "@strapi/utils": "npm:4.24.5" + "@types/cli-progress": "npm:3.11.5" + "@types/eventsource": "npm:1.1.15" + "@types/lodash": "npm:^4.14.191" + axios: "npm:1.6.0" + chalk: "npm:4.1.2" + cli-progress: "npm:3.12.0" + commander: "npm:8.3.0" + eslint-config-custom: "npm:4.24.5" + eventsource: "npm:2.0.2" + fast-safe-stringify: "npm:2.1.1" + fs-extra: "npm:10.0.0" + inquirer: "npm:8.2.5" + jsonwebtoken: "npm:9.0.0" + jwks-rsa: "npm:3.1.0" + lodash: "npm:4.17.21" + minimatch: "npm:9.0.3" + open: "npm:8.4.0" + ora: "npm:5.4.1" + pkg-up: "npm:3.1.0" + tar: "npm:6.1.13" + tsconfig: "npm:4.24.5" + xdg-app-paths: "npm:8.3.0" + yup: "npm:0.32.9" + bin: + cloud-cli: ./bin/index.js + languageName: unknown + linkType: soft + "@strapi/content-releases@npm:4.24.5, @strapi/content-releases@workspace:packages/core/content-releases": version: 0.0.0-use.local resolution: "@strapi/content-releases@workspace:packages/core/content-releases" @@ -8774,6 +8808,7 @@ __metadata: "@koa/cors": "npm:5.0.0" "@koa/router": "npm:10.1.1" "@strapi/admin": "npm:4.24.5" + "@strapi/cloud-cli": "npm:4.24.5" "@strapi/content-releases": "npm:4.24.5" "@strapi/data-transfer": "npm:4.24.5" "@strapi/database": "npm:4.24.5" @@ -8809,6 +8844,7 @@ __metadata: boxen: "npm:5.1.2" chalk: "npm:4.1.2" ci-info: "npm:3.8.0" + cli-progress: "npm:3.12.0" cli-table3: "npm:0.6.2" commander: "npm:8.3.0" concurrently: "npm:8.2.2" @@ -9494,6 +9530,15 @@ __metadata: languageName: node linkType: hard +"@types/cli-progress@npm:3.11.5": + version: 3.11.5 + resolution: "@types/cli-progress@npm:3.11.5" + dependencies: + "@types/node": "npm:*" + checksum: cb19187637b0a9b92219eab8d3d42250f1773328c24cb265d1bc677e3017f512e95e834e4846bcf0964efc232a13f86f7ef01843be804daa5433cc655c375bb3 + languageName: node + linkType: hard + "@types/codemirror5@npm:@types/codemirror@^5.60.15": version: 5.60.15 resolution: "@types/codemirror@npm:5.60.15" @@ -9646,6 +9691,13 @@ __metadata: languageName: node linkType: hard +"@types/eventsource@npm:1.1.15": + version: 1.1.15 + resolution: "@types/eventsource@npm:1.1.15" + checksum: 52e024f5aebfd6bc166f2162d6e408cf788886007e571519c75f8c3623feaa3c5a74681fd3a128de6d21b28ef88dd683421264f10d5c98728959b99b1229b85e + languageName: node + linkType: hard + "@types/express-serve-static-core@npm:^4.17.33": version: 4.17.35 resolution: "@types/express-serve-static-core@npm:4.17.35" @@ -9670,6 +9722,18 @@ __metadata: languageName: node linkType: hard +"@types/express@npm:^4.17.17": + version: 4.17.21 + resolution: "@types/express@npm:4.17.21" + dependencies: + "@types/body-parser": "npm:*" + "@types/express-serve-static-core": "npm:^4.17.33" + "@types/qs": "npm:*" + "@types/serve-static": "npm:*" + checksum: 7a6d26cf6f43d3151caf4fec66ea11c9d23166e4f3102edfe45a94170654a54ea08cf3103d26b3928d7ebcc24162c90488e33986b7e3a5f8941225edd5eb18c7 + languageName: node + linkType: hard + "@types/find-cache-dir@npm:^3.2.1": version: 3.2.1 resolution: "@types/find-cache-dir@npm:3.2.1" @@ -9917,6 +9981,15 @@ __metadata: languageName: node linkType: hard +"@types/jsonwebtoken@npm:^9.0.2": + version: 9.0.6 + resolution: "@types/jsonwebtoken@npm:9.0.6" + dependencies: + "@types/node": "npm:*" + checksum: 1f2145222f62da1b3dbfc586160c4f9685782a671f4a4f4a72151c773945fe25807fd88ed1c270536b76f49053ed932c5dbf714ea0ed77f785665abb75beef05 + languageName: node + linkType: hard + "@types/keygrip@npm:*": version: 1.0.2 resolution: "@types/keygrip@npm:1.0.2" @@ -13485,6 +13558,15 @@ __metadata: languageName: node linkType: hard +"cli-progress@npm:3.12.0": + version: 3.12.0 + resolution: "cli-progress@npm:3.12.0" + dependencies: + string-width: "npm:^4.2.3" + checksum: a6a549919a7461f5e798b18a4a19f83154bab145d3ec73d7f3463a8db8e311388c545ace1105557760a058cc4999b7f28c9d8d24d9783ee2912befb32544d4b8 + languageName: node + linkType: hard + "cli-spinners@npm:2.6.1": version: 2.6.1 resolution: "cli-spinners@npm:2.6.1" @@ -14378,8 +14460,10 @@ __metadata: version: 0.0.0-use.local resolution: "create-strapi-app@workspace:packages/cli/create-strapi-app" dependencies: + "@strapi/cloud-cli": "npm:4.24.5" "@strapi/generate-new": "npm:4.24.5" "@strapi/pack-up": "npm:4.23.0" + chalk: "npm:4.1.2" commander: "npm:8.3.0" eslint-config-custom: "npm:4.24.5" inquirer: "npm:8.2.5" @@ -16780,6 +16864,13 @@ __metadata: languageName: node linkType: hard +"eventsource@npm:2.0.2": + version: 2.0.2 + resolution: "eventsource@npm:2.0.2" + checksum: e1c4c3664cebf9efdd55c90818ef847099f298bf521768d479cf22d8a681e666b3042de85327711ba6a8414ac6a04c70d2aeb4f405bba8239a8c36e06a019374 + languageName: node + linkType: hard + "execa@npm:5.0.0": version: 5.0.0 resolution: "execa@npm:5.0.0" @@ -17107,7 +17198,7 @@ __metadata: languageName: node linkType: hard -"fast-safe-stringify@npm:^2.1.1": +"fast-safe-stringify@npm:2.1.1, fast-safe-stringify@npm:^2.1.1": version: 2.1.1 resolution: "fast-safe-stringify@npm:2.1.1" checksum: dc1f063c2c6ac9533aee14d406441f86783a8984b2ca09b19c2fe281f9ff59d315298bc7bc22fd1f83d26fe19ef2f20e2ddb68e96b15040292e555c5ced0c1e4 @@ -17733,6 +17824,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:*, fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 4c1ade961ded57cdbfbb5cac5106ec17bc8bccd62e16343c569a0ceeca83b9dfef87550b4dc5cbb89642da412b20c5071f304c8c464b80415446e8e155a038c0 + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:2.3.2, fsevents@npm:^2.3.2, fsevents@npm:~2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" @@ -17743,12 +17844,11 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:~2.3.3": +"fsevents@patch:fsevents@npm%3A*#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 - resolution: "fsevents@npm:2.3.3" + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: node-gyp: "npm:latest" - checksum: 4c1ade961ded57cdbfbb5cac5106ec17bc8bccd62e16343c569a0ceeca83b9dfef87550b4dc5cbb89642da412b20c5071f304c8c464b80415446e8e155a038c0 conditions: os=darwin languageName: node linkType: hard @@ -17762,15 +17862,6 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": - version: 2.3.3 - resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" - dependencies: - node-gyp: "npm:latest" - conditions: os=darwin - languageName: node - linkType: hard - "function-bind@npm:^1.1.1": version: 1.1.1 resolution: "function-bind@npm:1.1.1" @@ -20803,6 +20894,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^4.14.6": + version: 4.15.5 + resolution: "jose@npm:4.15.5" + checksum: 17944fcc0d9afa07387eef23127c30ecfcc77eafddc4b4f1a349a8eee0536bee9b08ecd745406eaa0af65d531f738b94d2467976479cbfe8b3b60f8fc8082b8d + languageName: node + linkType: hard + "joycon@npm:^3.0.1": version: 3.1.1 resolution: "joycon@npm:3.1.1" @@ -21167,6 +21265,20 @@ __metadata: languageName: node linkType: hard +"jwks-rsa@npm:3.1.0": + version: 3.1.0 + resolution: "jwks-rsa@npm:3.1.0" + dependencies: + "@types/express": "npm:^4.17.17" + "@types/jsonwebtoken": "npm:^9.0.2" + debug: "npm:^4.3.4" + jose: "npm:^4.14.6" + limiter: "npm:^1.1.5" + lru-memoizer: "npm:^2.2.0" + checksum: 004883b3f2c9b12d3dd364acd6be3198343b1ca89fd51c9bc03473a2555282ebb4c374cd391847bbd46eaab19ac19a2e518787683707444c0506fcf7ac4cae97 + languageName: node + linkType: hard + "jws@npm:^3.2.2": version: 3.2.2 resolution: "jws@npm:3.2.2" @@ -21739,6 +21851,13 @@ __metadata: languageName: node linkType: hard +"limiter@npm:^1.1.5": + version: 1.1.5 + resolution: "limiter@npm:1.1.5" + checksum: fa96e9912cf33ec36387e41a09694ccac7aaa8b86e1121333c30a3dfdf6265c849c980abd5f1689021bbab9aadca9d6df58d8db6ce5b999c26dd8cefe94168a9 + languageName: node + linkType: hard + "lines-and-columns@npm:^1.1.6": version: 1.2.4 resolution: "lines-and-columns@npm:1.2.4" @@ -21888,6 +22007,13 @@ __metadata: languageName: node linkType: hard +"lodash.clonedeep@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.clonedeep@npm:4.5.0" + checksum: 957ed243f84ba6791d4992d5c222ffffca339a3b79dbe81d2eaf0c90504160b500641c5a0f56e27630030b18b8e971ea10b44f928a977d5ced3c8948841b555f + languageName: node + linkType: hard + "lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8" @@ -22075,6 +22201,15 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:6.0.0, lru-cache@npm:^6.0.0": + version: 6.0.0 + resolution: "lru-cache@npm:6.0.0" + dependencies: + yallist: "npm:^4.0.0" + checksum: fc1fe2ee205f7c8855fa0f34c1ab0bcf14b6229e35579ec1fd1079f31d6fc8ef8eb6fd17f2f4d99788d7e339f50e047555551ebd5e434dda503696e7c6591825 + languageName: node + linkType: hard + "lru-cache@npm:^4.0.1": version: 4.1.5 resolution: "lru-cache@npm:4.1.5" @@ -22094,15 +22229,6 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^6.0.0": - version: 6.0.0 - resolution: "lru-cache@npm:6.0.0" - dependencies: - yallist: "npm:^4.0.0" - checksum: fc1fe2ee205f7c8855fa0f34c1ab0bcf14b6229e35579ec1fd1079f31d6fc8ef8eb6fd17f2f4d99788d7e339f50e047555551ebd5e434dda503696e7c6591825 - languageName: node - linkType: hard - "lru-cache@npm:^7.10.1, lru-cache@npm:^7.14.1, lru-cache@npm:^7.4.4, lru-cache@npm:^7.5.1, lru-cache@npm:^7.7.1": version: 7.18.3 resolution: "lru-cache@npm:7.18.3" @@ -22124,6 +22250,16 @@ __metadata: languageName: node linkType: hard +"lru-memoizer@npm:^2.2.0": + version: 2.3.0 + resolution: "lru-memoizer@npm:2.3.0" + dependencies: + lodash.clonedeep: "npm:^4.5.0" + lru-cache: "npm:6.0.0" + checksum: 1c00afc28640a2f02116c5907be0543647ad51084c43c3cecc1198efdfb5d3693caad948590f61bce3fc8c9f52ec8f567a64273a947535c2391ee41b675cc5e4 + languageName: node + linkType: hard + "lru_map@npm:^0.3.3": version: 0.3.3 resolution: "lru_map@npm:0.3.3" @@ -23165,6 +23301,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:9.0.3": + version: 9.0.3 + resolution: "minimatch@npm:9.0.3" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: c81b47d28153e77521877649f4bab48348d10938df9e8147a58111fe00ef89559a2938de9f6632910c4f7bf7bb5cd81191a546167e58d357f0cfb1e18cecc1c5 + languageName: node + linkType: hard + "minimatch@npm:^3.0.2, minimatch@npm:^3.0.3, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -24811,6 +24956,18 @@ __metadata: languageName: node linkType: hard +"os-paths@npm:^7.4.0": + version: 7.4.0 + resolution: "os-paths@npm:7.4.0" + dependencies: + fsevents: "npm:*" + dependenciesMeta: + fsevents: + optional: true + checksum: dc362e76cbe20fa39a19005df7bc8b68a7ccf145b64d2f5aa976ee09e2f8a355d811954df7ffc9668ff083dcddffd23bb0de33e968d49eca15c234bff2fd33d7 + languageName: node + linkType: hard + "os-tmpdir@npm:~1.0.2": version: 1.0.2 resolution: "os-tmpdir@npm:1.0.2" @@ -31452,6 +31609,19 @@ __metadata: languageName: node linkType: hard +"xdg-app-paths@npm:8.3.0": + version: 8.3.0 + resolution: "xdg-app-paths@npm:8.3.0" + dependencies: + fsevents: "npm:*" + xdg-portable: "npm:^10.6.0" + dependenciesMeta: + fsevents: + optional: true + checksum: 9309fde27cf175d52ed119f53dbd6a2c7afde5f2b7170d381c579f10536e7101ea8076bd5da627209c7b6ba23c73386da7d8f26055400ce4debb284d636f9f9d + languageName: node + linkType: hard + "xdg-basedir@npm:^4.0.0": version: 4.0.0 resolution: "xdg-basedir@npm:4.0.0" @@ -31459,6 +31629,19 @@ __metadata: languageName: node linkType: hard +"xdg-portable@npm:^10.6.0": + version: 10.6.0 + resolution: "xdg-portable@npm:10.6.0" + dependencies: + fsevents: "npm:*" + os-paths: "npm:^7.4.0" + dependenciesMeta: + fsevents: + optional: true + checksum: c73463ff4329de4874322dd843d70ba12311de02bcc65cae6cbd58a24c56ad402e15561b5a7ee0aa6ba26bb0ec440d316adc475abee1bbdf82755f3588fc83f1 + languageName: node + linkType: hard + "xml-name-validator@npm:^4.0.0": version: 4.0.0 resolution: "xml-name-validator@npm:4.0.0"