Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): cloud cli commands (v4) #20119

Open
wants to merge 27 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2999aa2
feat(cloud-cli): add cloud commands
nathan-pichon Apr 15, 2024
9b7e6c8
fix(security): use sha512 instead of md5
nathan-pichon Apr 25, 2024
d8c8469
chore(cloud-cli): moving files from utils to services
nathan-pichon Apr 25, 2024
c0e0937
chore(cloud-cli): add help on login command
nathan-pichon Apr 26, 2024
c365273
fix(cloud-cli): typing
nathan-pichon Apr 26, 2024
65adfca
fix(cloud-cli): update return types and data properties
nathan-pichon Apr 26, 2024
0cd2845
fix(cloud-cli): update compress to use relative path
nathan-pichon Apr 26, 2024
f7e0b73
fix(cloud-cli): build logs
nathan-pichon Apr 26, 2024
2b5467d
fix(cloud-cli): use name property of project instead of displayName
nathan-pichon Apr 29, 2024
7ef536d
fix(cloud-cli): create-strapi-app quickstart should ask for cloud pro…
nathan-pichon Apr 29, 2024
b238d95
fix: typo login
gonbaum May 2, 2024
9607635
feat: display logged account email
gonbaum May 3, 2024
2661b6f
feat: config constants
gonbaum May 3, 2024
03e8f68
fix(cloud-cli): rollback
nathan-pichon May 3, 2024
801d966
chore(cli-logger): use predefined objects for silent options
nathan-pichon May 7, 2024
46912d9
fix(cli): update mock for silent spinner log
nathan-pichon May 7, 2024
7d744ed
fix(cloud-cli): update build cli function + debug & silent options
nathan-pichon May 7, 2024
807bd3b
fix(cloud-cli): on deploy, spinner should fail when timeout occurs
nathan-pichon May 7, 2024
a579865
chore(cloud-cli): update how cloud cli commands are imported in strap…
nathan-pichon May 7, 2024
b463790
fix: logout logs
gonbaum May 13, 2024
961d3f7
chore(cloud-cli): update log when failing to get build logs
nathan-pichon May 13, 2024
a9a7d33
feat(cloud-cli): add tracking events for cloud
nathan-pichon May 23, 2024
9f7496f
feat: dynamic maxsize
gonbaum May 23, 2024
a2185c8
feat(cloud-cli): add spinner on project creation
nathan-pichon May 24, 2024
e8698aa
feat: add safe-stringify to logger
gonbaum May 24, 2024
3c5c4de
fix: logger library and default max value
gonbaum May 24, 2024
74d03b4
chore(cli): bump release version
gonbaum May 24, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cli/cloud/.eslintignore
@@ -0,0 +1,4 @@
node_modules/
.eslintrc.js
dist/
bin/
4 changes: 4 additions & 0 deletions packages/cli/cloud/.eslintrc.js
@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ['custom/back/typescript'],
};
22 changes: 22 additions & 0 deletions 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.
3 changes: 3 additions & 0 deletions packages/cli/cloud/README.md
@@ -0,0 +1,3 @@
# Cloud CLI

This package includes the `cloud` CLI to manage Strapi projects on the cloud.
7 changes: 7 additions & 0 deletions packages/cli/cloud/bin/index.js
@@ -0,0 +1,7 @@
#!/usr/bin/env node

'use strict';

const { runStrapiCloudCommand } = require('../dist/bin');

runStrapiCloudCommand(process.argv);
78 changes: 78 additions & 0 deletions packages/cli/cloud/package.json
@@ -0,0 +1,78 @@
{
"name": "@strapi/cloud-cli",
"version": "4.24.3",
"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/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.3",
"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",
"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.3",
"tsconfig": "4.24.3"
},
"engines": {
"node": ">=18.0.0 <=20.x.x",
"npm": ">=6.0.0"
}
}
20 changes: 20 additions & 0 deletions 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',
});
34 changes: 34 additions & 0 deletions 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 };
5 changes: 5 additions & 0 deletions packages/cli/cloud/src/config/api.ts
@@ -0,0 +1,5 @@
import { env } from '@strapi/utils';

export const apiConfig = {
apiBaseUrl: env('STRAPI_CLI_CLOUD_API', 'https://cli.cloud.strapi.io'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

side note: libraries should avoid using env vars and get that as an input param unless it's a debug env var

Considering this will be just a way to test we can live with it. @innerdvations wdyt of a specific prefix for all hardcoded env var ? STRAPI_DEBUG 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds fine to me. STRAPI_ will already be reserved for us, so if we want to call STRAPI_DEBUG_ the debugging prefix it won't cause any issues.

};
60 changes: 60 additions & 0 deletions packages/cli/cloud/src/config/local.ts
@@ -0,0 +1,60 @@
import path from 'path';
import os from 'os';
import fs from 'fs';
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;
};

function checkDirectoryExists(directoryPath: string) {
try {
return fs.lstatSync(directoryPath).isDirectory();
} catch (e) {
return false;
}
}

// Determine storage path based on the operating system
export function getTmpStoragePath() {
const storagePath = path.join(os.tmpdir(), APP_FOLDER_NAME);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use xdgapppaths for this too, to avoid putting user project files into tmp? Some users could have sensitive information in their code (even though they shouldn't)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We store a .tar.gz file in the tmp folder the time to upload it, this file should be deleted after the upload and we don't want the user to keep it in their storage system as it can take space for nothing. the TMP folder is regularly emptied, and the XDG paths aren't. 😅

if (!checkDirectoryExists(storagePath)) {
fs.mkdirSync(storagePath, { recursive: true });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we use fs-extra everywhere in the codebase you could use the ensure fn directly

}
return storagePath;
}

function getConfigPath() {
const configDirs = XDGAppPaths(APP_FOLDER_NAME).configDirs();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the mvp I think it would be great to switch to os keychain support with something like keytar to store the token

But for this, a user on a system can only be logged in to one strapi cloud project. Could we append the strapi cloud project name or something so that different logins can be used for different projects the user might be working with?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what would be the advantage ? we are not storing a pwd but just a temp access token 🤔

side note, Also this CLI could be used to automate deployment so we need to support tokens via the CLI in the CI.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure about the keychain support, as I think it would ask for the user's permission. By using XDGAppPaths we are mimicking Vercel for example.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would allow the user control the security level on the token and keeps it encrypted.

For example, I think most systems would allow users to configure their keychain to ask for verification before the token is accessed (so, someone sitting down at their system couldn't deploy without re-entering password/fingerprint/whatever). But I think what we're doing here is still secure enough for most cases.

I do think a separation by project would be extremely helpful though, otherwise someone with multiple clients on Strapi cloud would have to log in and out between each project, or accidentally attempt to deploy a project with the wrong account (though I assume it wouldn't work).

Alternatively, a login manager to switch between them, but that's a huge feature in itself 😆

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

side note, Also this CLI could be used to automate deployment so we need to support tokens via the CLI in the CI.

What about strapi deploy --config=custom/path/to/file ? Or support for an env var somewhere?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah not sure I see the point for that extra layer of security as it's short lived tokens & we could ask for the user to relogin to the cloud or sth like that if we really wanted to.

const configPath = configDirs.find(checkDirectoryExists);

if (!configPath) {
fs.mkdirSync(configDirs[0], { recursive: true });
return configDirs[0];
}
return configPath;
}

export function getLocalConfig(): LocalConfig {
const configPath = getConfigPath();
const configFilePath = path.join(configPath, CONFIG_FILENAME);
if (!fs.existsSync(configFilePath)) {
return {};
}
try {
return JSON.parse(fs.readFileSync(configFilePath, 'utf8'));
} catch (e) {
return {};
}
}

export function saveLocalConfig(data: LocalConfig) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make all those file manipulation async instead ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's going to be difficult to use fs in async mode, as we don't have top-level await and I've just needed to create and store a deviceId in the configuration file for use during event tracking.

const configPath = getConfigPath();
const configFilePath = path.join(configPath, CONFIG_FILENAME);
fs.writeFileSync(configFilePath, JSON.stringify(data), { encoding: 'utf8', mode: 0o600 });
}
63 changes: 63 additions & 0 deletions packages/cli/cloud/src/create-project/action.ts
@@ -0,0 +1,63 @@
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';

function handleError(ctx: CLIContext, error: Error) {
const tokenService = tokenServiceFactory(ctx);
const { logger } = ctx;

logger.debug(error);
if (error instanceof AxiosError) {
switch (error.response?.status) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's 5XX should we handle it to show it's our problem not theres? at the moment it's not clear imo

case 401:
logger.error('Your session has expired. Please log in again.');
tokenService.eraseToken();
return;
case 403:
logger.error(
error.response.data ||
'You do not have permission to create a project. Please contact support for assistance.'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what cases this can happen, but it there any additional information that can be provided on the cloud api side to reduce support requests in some cases? This feels like it could be a painful thing for support 😆

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, users can use - - debug flag when running the command and send the logs to support. It should be sufficient to troubleshoot. At least for the first version 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in what case would you get a forbidden considering we do not really have an authorization system ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you try to create a project with the free trial but you have already used it, API will throw a 403.
In the response.data we should have the appropriate message:
'Unfortunately, you have already used your free trial for Strapi Cloud. Please contact our support team for more information.'

);
return;
case 400:
logger.error('Invalid input. Please check your inputs and try again.');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can improve this error message, it's really vague. Do you not return a good error message from the API you can utilise?

return;
default:
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.'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should you propose using the debug flag to find more info?

);
}

export default async (ctx: CLIContext) => {
const { logger } = ctx;
const { getValidToken } = tokenServiceFactory(ctx);

const token = await getValidToken();
if (!token) {
return;
}
const cloudApi = cloudApiFactory(token);
const { data: config } = await cloudApi.config();
const { questions, defaults: defaultValues } = config.projectCreation;

const projectAnswersDefaulted = defaults(defaultValues);
const projectAnswers = await inquirer.prompt<ProjectAnswers>(questions);

const projectInput: ProjectInput = projectAnswersDefaulted(projectAnswers);

const spinner = logger.spinner('Setting up your project...').start();
try {
const { data } = await cloudApi.createProject(projectInput);
local.save({ project: data });
spinner.succeed('Project created successfully!');
return data;
} catch (e: Error | unknown) {
spinner.fail('Failed to create project on Strapi Cloud.');
handleError(ctx, e as Error);
}
};
17 changes: 17 additions & 0 deletions 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 }) => {
return 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;
12 changes: 12 additions & 0 deletions packages/cli/cloud/src/create-project/index.ts
@@ -0,0 +1,12 @@
import action from './action';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this isn't used anywhere apparently, you can delete ?

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;