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
base: develop
Are you sure you want to change the base?
Changes from all commits
2999aa2
9b7e6c8
d8c8469
c0e0937
c365273
65adfca
0cd2845
f7e0b73
2b5467d
7ef536d
b238d95
9607635
2661b6f
03e8f68
801d966
46912d9
7d744ed
807bd3b
a579865
b463790
961d3f7
a9a7d33
9f7496f
a2185c8
e8698aa
3c5c4de
74d03b4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
node_modules/ | ||
.eslintrc.js | ||
dist/ | ||
bin/ |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
module.exports = { | ||
root: true, | ||
extends: ['custom/back/typescript'], | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# Cloud CLI | ||
|
||
This package includes the `cloud` CLI to manage Strapi projects on the cloud. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
#!/usr/bin/env node | ||
|
||
'use strict'; | ||
|
||
const { runStrapiCloudCommand } = require('../dist/bin'); | ||
|
||
runStrapiCloudCommand(process.argv); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 }; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import { env } from '@strapi/utils'; | ||
|
||
export const apiConfig = { | ||
apiBaseUrl: env('STRAPI_CLI_CLOUD_API', 'https://cli.cloud.strapi.io'), | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We store a |
||
if (!checkDirectoryExists(storagePath)) { | ||
fs.mkdirSync(storagePath, { recursive: true }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 😆 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
What about There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we make all those file manipulation async instead ? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 😆 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 😅 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
); | ||
return; | ||
case 400: | ||
logger.error('Invalid input. Please check your inputs and try again.'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should you propose using the |
||
); | ||
} | ||
|
||
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); | ||
} | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import action from './action'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; |
There was a problem hiding this comment.
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
🤔There was a problem hiding this comment.
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 callSTRAPI_DEBUG_
the debugging prefix it won't cause any issues.