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: implement create site from template feature #3948

Merged
merged 23 commits into from
Feb 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1889a4b
feat: implement create site from template feature
charliegerard Jan 4, 2022
d3a6e33
chore: improve experience and add some error handling
charliegerard Jan 13, 2022
2a055b2
fix: syntax error
charliegerard Jan 13, 2022
f65ca3a
feat: finish implementing start with template
charliegerard Jan 26, 2022
64f0afa
chore: small improvement
charliegerard Jan 26, 2022
9901dc0
feat: add option to pass template url
charliegerard Jan 31, 2022
659dca0
feat: generate docs
charliegerard Jan 31, 2022
541782c
feat: handle more generic error
charliegerard Jan 31, 2022
c44df7f
chore: implement feedback
charliegerard Feb 1, 2022
3783d32
feat: fetch templates from github and add ci option
charliegerard Feb 1, 2022
d4e0521
chore: starting to add a test
charliegerard Feb 3, 2022
37e8139
chore: update test
charliegerard Feb 3, 2022
f5e5799
chore: refactor
charliegerard Feb 3, 2022
1204098
chore: rename test file
charliegerard Feb 3, 2022
5fe7ad8
fix: fix bug with site name
charliegerard Feb 3, 2022
a8bc3df
feat: add repo SSH url for easier CI setup
charliegerard Feb 4, 2022
786f1e2
chore: remove
charliegerard Feb 4, 2022
72425d0
Merge branch 'main' into cg/addCreateSitesFromTemplateFeature
lukasholzer Feb 4, 2022
aee0ff5
test: show how to propperly mock the github api
lukasholzer Feb 4, 2022
3fd796c
feat: add tests
charliegerard Feb 5, 2022
e80ef0f
feat: throw error if url passed is invalid
charliegerard Feb 5, 2022
c8607a8
chore: implement feedback
charliegerard Feb 7, 2022
26666e4
Merge branch 'main' into cg/addCreateSitesFromTemplateFeature
lukasholzer Feb 7, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ Handle various site operations
| Subcommand | description |
|:--------------------------- |:-----|
| [`sites:create`](/docs/commands/sites.md#sitescreate) | Create an empty site (advanced) |
| [`sites:create-template`](/docs/commands/sites.md#sitescreate-template) | (Beta) Create a site from a starter template |
| [`sites:delete`](/docs/commands/sites.md#sitesdelete) | Delete a site |
| [`sites:list`](/docs/commands/sites.md#siteslist) | List all sites you have access to |

Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ Handle various site operations
| Subcommand | description |
|:--------------------------- |:-----|
| [`sites:create`](/docs/commands/sites.md#sitescreate) | Create an empty site (advanced) |
| [`sites:create-template`](/docs/commands/sites.md#sitescreate-template) | (Beta) Create a site from a starter template |
| [`sites:delete`](/docs/commands/sites.md#sitesdelete) | Delete a site |
| [`sites:list`](/docs/commands/sites.md#siteslist) | List all sites you have access to |

Expand Down
1 change: 1 addition & 0 deletions docs/commands/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ Handle various site operations
| Subcommand | description |
|:--------------------------- |:-----|
| [`sites:create`](/docs/commands/sites.md#sitescreate) | Create an empty site (advanced) |
| [`sites:create-template`](/docs/commands/sites.md#sitescreate-template) | (Beta) Create a site from a starter template |
| [`sites:delete`](/docs/commands/sites.md#sitesdelete) | Delete a site |
| [`sites:list`](/docs/commands/sites.md#siteslist) | List all sites you have access to |

Expand Down
23 changes: 23 additions & 0 deletions docs/commands/sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ netlify sites
| Subcommand | description |
|:--------------------------- |:-----|
| [`sites:create`](/docs/commands/sites.md#sitescreate) | Create an empty site (advanced) |
| [`sites:create-template`](/docs/commands/sites.md#sitescreate-template) | (Beta) Create a site from a starter template |
| [`sites:delete`](/docs/commands/sites.md#sitesdelete) | Delete a site |
| [`sites:list`](/docs/commands/sites.md#siteslist) | List all sites you have access to |

Expand Down Expand Up @@ -58,6 +59,28 @@ netlify sites:create
- `httpProxy` (*string*) - Proxy server address to route requests through.
- `httpProxyCertificateFilename` (*string*) - Certificate file to use when connecting using a proxy server

---
## `sites:create-template`

(Beta) Create a site from a starter template
Create a site from a starter template.

**Usage**

```bash
netlify sites:create-template
```

**Flags**

- `account-slug` (*string*) - account slug to create the site under
- `name` (*string*) - name of site
- `url` (*string*) - template url
- `with-ci` (*boolean*) - initialize CI hooks during site creation
- `debug` (*boolean*) - Print debugging information
- `httpProxy` (*string*) - Proxy server address to route requests through.
- `httpProxyCertificateFilename` (*string*) - Certificate file to use when connecting using a proxy server

---
## `sites:delete`

Expand Down
212 changes: 212 additions & 0 deletions src/commands/sites/sites-create-template.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// @ts-check

const inquirer = require('inquirer')
const pick = require('lodash/pick')
const prettyjson = require('prettyjson')

const { chalk, error, getRepoData, log, logJson, track, warn } = require('../../utils')
const { configureRepo } = require('../../utils/init/config')
const { getGitHubToken } = require('../../utils/init/config-github')
const { createRepo, getTemplatesFromGitHub } = require('../../utils/sites/utils')

const { getSiteNameInput } = require('./sites-create')

const fetchTemplates = async (token) => {
const templatesFromGithubOrg = await getTemplatesFromGitHub(token)

return templatesFromGithubOrg
.filter((repo) => !repo.archived && !repo.private && !repo.disabled)
.map((template) => ({
name: template.name,
sourceCodeUrl: template.html_url,
slug: template.full_name,
}))
}

/**
* The sites:create-template command
* @param {import('commander').OptionValues} options
* @param {import('../base-command').BaseCommand} command
*/
const sitesCreateTemplate = async (options, command) => {
const { api } = command.netlify

await command.authenticate()

const { globalConfig } = command.netlify
const ghToken = await getGitHubToken({ globalConfig })

let { url: templateUrl } = options

if (templateUrl) {
const urlFromOptions = new URL(templateUrl)
templateUrl = { templateName: urlFromOptions.pathname.slice(1) }
} else {
const templates = await fetchTemplates(ghToken)

log(`Choose one of our starter templates. Netlify will create a new repo for this template in your GitHub account.`)

templateUrl = await inquirer.prompt([
{
type: 'list',
name: 'templateName',
message: 'Template:',
choices: templates.map((template) => ({
value: template.slug,
name: template.name,
})),
},
])
}

const accounts = await api.listAccountsForUser()

let { accountSlug } = options

if (!accountSlug) {
const { accountSlug: accountSlugInput } = await inquirer.prompt([
{
type: 'list',
name: 'accountSlug',
message: 'Team:',
choices: accounts.map((account) => ({
value: account.slug,
name: account.name,
})),
},
])
accountSlug = accountSlugInput
}

const { name: nameFlag } = options
let user
let site

// Allow the user to reenter site name if selected one isn't available
const inputSiteName = async (name) => {
charliegerard marked this conversation as resolved.
Show resolved Hide resolved
const { name: inputName, siteSuggestion } = await getSiteNameInput(name, user, api)

try {
const siteName = inputName ? inputName.trim() : siteSuggestion

// Create new repo from template
const repoResp = await createRepo(templateUrl, ghToken, siteName)

if (repoResp.errors) {
if (repoResp.errors[0].includes('Name already exists on this account')) {
warn(
`Oh no! We found already a repository with this name. It seems you have already created a template with the name ${templateUrl.templateName}. Please try to run the command again and provide a different name.`,
)
await inputSiteName()
} else {
throw new Error(
`Oops! Seems like something went wrong trying to create the repository. We're getting the following error: '${repoResp.errors[0]}'. You can try to re-run this command again or open an issue in our repository: https://github.com/netlify/cli/issues`,
)
}
charliegerard marked this conversation as resolved.
Show resolved Hide resolved
} else {
site = await api.createSiteInTeam({
Copy link
Contributor

Choose a reason for hiding this comment

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

This will create the site, but does not set up the CI part. Meaning a commit to the repo won't trigger a build on Netlify.
See

if (options.withCi) {
if we want the behavior to configure GitHub as well

accountSlug,
body: {
repo: {
provider: 'github',
repo: repoResp.full_name,
private: repoResp.private,
branch: repoResp.default_branch,
},
name: siteName,
},
})
}
} catch (error_) {
if (error_.status === 422 || error_.message === 'Duplicate repo') {
warn(
`${name}.netlify.app already exists or a repository named ${name} already exists on this account. Please try a different slug.`,
)
await inputSiteName()
} else {
error(`createSiteInTeam error: ${error_.status}: ${error_.message}`)
}
}
}

await inputSiteName(nameFlag)

log()
log(chalk.greenBright.bold.underline(`Site Created`))
log()

const siteUrl = site.ssl_url || site.url
log(
prettyjson.render({
'Admin URL': site.admin_url,
URL: siteUrl,
'Site ID': site.id,
'Repo URL': site.build_settings.repo_url,
}),
)

track('sites_createdFromTemplate', {
siteId: site.id,
adminUrl: site.admin_url,
siteUrl,
})

if (options.withCi) {
log('Configuring CI')
const repoData = await getRepoData()
await configureRepo({ command, siteId: site.id, repoData, manual: options.manual })
}

if (options.json) {
logJson(
pick(site, [
'id',
'state',
'plan',
'name',
'custom_domain',
'domain_aliases',
'url',
'ssl_url',
'admin_url',
'screenshot_url',
'created_at',
'updated_at',
'user_id',
'ssl',
'force_ssl',
'managed_dns',
'deploy_url',
'account_name',
'account_slug',
'git_provider',
'deploy_hook',
'capabilities',
'id_domain',
]),
)
}

return site
}

/**
* Creates the `netlify sites:create-template` command
* @param {import('../base-command').BaseCommand} program
* @returns
*/
const createSitesFromTemplateCommand = (program) =>
program
.command('sites:create-template')
.description(
`(Beta) Create a site from a starter template
Create a site from a starter template.`,
)
.option('-n, --name [name]', 'name of site')
.option('-u, --url [url]', 'template url')
.option('-a, --account-slug [slug]', 'account slug to create the site under')
.option('-c, --with-ci', 'initialize CI hooks during site creation')
.addHelpText('after', `(Beta) Create a site from starter template.`)
.action(sitesCreateTemplate)

module.exports = { createSitesFromTemplateCommand, fetchTemplates }
89 changes: 48 additions & 41 deletions src/commands/sites/sites-create.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,49 @@ const { link } = require('../link')

const SITE_NAME_SUGGESTION_SUFFIX_LENGTH = 5

const getSiteNameInput = async (name, user, api) => {
let siteSuggestion
if (!user) user = await api.getCurrentUser()

if (!name) {
let { slug } = user
let suffix = ''

// If the user doesn't have a slug, we'll compute one. Because `full_name` is not guaranteed to be unique, we
// append a short randomly-generated ID to reduce the likelihood of a conflict.
if (!slug) {
slug = slugify(user.full_name || user.email)
suffix = `-${uuidv4().slice(0, SITE_NAME_SUGGESTION_SUFFIX_LENGTH)}`
}

const suggestions = [
`super-cool-site-by-${slug}${suffix}`,
`the-awesome-${slug}-site${suffix}`,
`${slug}-makes-great-sites${suffix}`,
`netlify-thinks-${slug}-is-great${suffix}`,
`the-great-${slug}-site${suffix}`,
`isnt-${slug}-awesome${suffix}`,
]
siteSuggestion = sample(suggestions)

console.log(
`Choose a unique site name (e.g. ${siteSuggestion}.netlify.app) or leave it blank for a random name. You can update the site name later.`,
)
const { name: nameInput } = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Site name (optional):',
filter: (val) => (val === '' ? undefined : val),
validate: (input) => /^[a-zA-Z\d-]+$/.test(input) || 'Only alphanumeric characters and hyphens are allowed',
},
])
name = nameInput
}

return { name, siteSuggestion }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These changes are only to be able to extract the piece of code that is reused in the sites-create-template.js file to avoid duplication, the logic isn't changed

/**
* The sites:create command
* @param {import('commander').OptionValues} options
Expand Down Expand Up @@ -47,47 +90,11 @@ const sitesCreate = async (options, command) => {

// Allow the user to reenter site name if selected one isn't available
const inputSiteName = async (name) => {
if (!user) user = await api.getCurrentUser()

if (!name) {
let { slug } = user
let suffix = ''

// If the user doesn't have a slug, we'll compute one. Because `full_name` is not guaranteed to be unique, we
// append a short randomly-generated ID to reduce the likelihood of a conflict.
if (!slug) {
slug = slugify(user.full_name || user.email)
suffix = `-${uuidv4().slice(0, SITE_NAME_SUGGESTION_SUFFIX_LENGTH)}`
}

const suggestions = [
`super-cool-site-by-${slug}${suffix}`,
`the-awesome-${slug}-site${suffix}`,
`${slug}-makes-great-sites${suffix}`,
`netlify-thinks-${slug}-is-great${suffix}`,
`the-great-${slug}-site${suffix}`,
`isnt-${slug}-awesome${suffix}`,
]
const siteSuggestion = sample(suggestions)

console.log(
`Choose a unique site name (e.g. ${siteSuggestion}.netlify.app) or leave it blank for a random name. You can update the site name later.`,
)
const { name: nameInput } = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Site name (optional):',
filter: (val) => (val === '' ? undefined : val),
validate: (input) => /^[a-zA-Z\d-]+$/.test(input) || 'Only alphanumeric characters and hyphens are allowed',
},
])
name = nameInput
}
const { name: siteName } = await getSiteNameInput(name, user, api)

const body = {}
if (typeof name === 'string') {
body.name = name.trim()
if (typeof siteName === 'string') {
body.name = siteName.trim()
}
try {
site = await api.createSiteInTeam({
Expand All @@ -96,7 +103,7 @@ const sitesCreate = async (options, command) => {
})
} catch (error_) {
if (error_.status === 422) {
warn(`${name}.netlify.app already exists. Please try a different slug.`)
warn(`${siteName}.netlify.app already exists. Please try a different slug.`)
await inputSiteName()
} else {
error(`createSiteInTeam error: ${error_.status}: ${error_.message}`)
Expand Down Expand Up @@ -191,4 +198,4 @@ Create a blank site that isn't associated with any git remote. Will link the sit
)
.action(sitesCreate)

module.exports = { createSitesCreateCommand, sitesCreate }
module.exports = { createSitesCreateCommand, sitesCreate, getSiteNameInput }
Loading