Skip to content

Commit

Permalink
feat: implement create site from template feature (#3948)
Browse files Browse the repository at this point in the history
* feat: implement create site from template feature

* chore: improve experience and add some error handling

* fix: syntax error

* feat: finish implementing start with template

* chore: small improvement

* feat: add option to pass template url

* feat: generate docs

* feat: handle more generic error

* chore: implement feedback

* feat: fetch templates from github and add ci option

* chore: starting to add a test

* chore: update test

* chore: refactor

* chore: rename test file

* fix: fix bug with site name

* feat: add repo SSH url for easier CI setup

* chore: remove

* test: show how to propperly mock the github api

* feat: add tests

* feat: throw error if url passed is invalid

* chore: implement feedback

Co-authored-by: Lukas Holzer <lukas.holzer@netlify.com>
  • Loading branch information
charliegerard and lukasholzer authored Feb 7, 2022
1 parent 127a1c6 commit 3ebfea9
Show file tree
Hide file tree
Showing 9 changed files with 496 additions and 41 deletions.
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) => {
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`,
)
}
} else {
site = await api.createSiteInTeam({
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 }
}

/**
* 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

1 comment on commit 3ebfea9

@github-actions
Copy link

Choose a reason for hiding this comment

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

📊 Benchmark results

Package size: 368 MB

Please sign in to comment.