From 89be8a63f7d100a74524f2b19eafdfb2fbc6588d Mon Sep 17 00:00:00 2001 From: Ben Hancock Date: Wed, 20 Nov 2024 09:57:59 -0500 Subject: [PATCH] feat(sites-create-template): auto-link cloned repo to the created netlify site (#6914) Co-authored-by: Dylan Spyer Co-authored-by: Daniel Lew <51924260+DanielSLew@users.noreply.github.com> --- src/commands/sites/sites-create-template.ts | 56 +++++++++- src/utils/sites/utils.ts | 9 ++ .../sites/sites-create-template.test.ts | 105 +++++++++++++++++- 3 files changed, 167 insertions(+), 3 deletions(-) diff --git a/src/commands/sites/sites-create-template.ts b/src/commands/sites/sites-create-template.ts index aad249cb708..dadc1dea2e3 100644 --- a/src/commands/sites/sites-create-template.ts +++ b/src/commands/sites/sites-create-template.ts @@ -3,6 +3,8 @@ import inquirer from 'inquirer' import pick from 'lodash/pick.js' import { render } from 'prettyjson' import { v4 as uuid } from 'uuid' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { chalk, @@ -20,7 +22,7 @@ import getRepoData from '../../utils/get-repo-data.js' import { getGitHubToken } from '../../utils/init/config-github.js' import { configureRepo } from '../../utils/init/config.js' import { deployedSiteExists, getGitHubLink, getTemplateName } from '../../utils/sites/create-template.js' -import { createRepo, validateTemplate } from '../../utils/sites/utils.js' +import { callLinkSite, createRepo, validateTemplate } from '../../utils/sites/utils.js' import { track } from '../../utils/telemetry/index.js' import { Account, SiteInfo } from '../../utils/types.js' import BaseCommand from '../base-command.js' @@ -190,6 +192,58 @@ export const sitesCreateTemplate = async (repository: string, options: OptionVal } log(`🚀 Repository cloned successfully. You can find it under the ${chalk.magenta(repoResp.name)} folder`) + + const { linkConfirm } = await inquirer.prompt({ + type: 'confirm', + name: 'linkConfirm', + message: `Do you want to link the cloned directory to the site?`, + default: true, + }) + + if (linkConfirm) { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + + const cliPath = path.resolve(__dirname, '../../../bin/run.js') + + let stdout + if (repoResp.name) { + stdout = await callLinkSite(cliPath, repoResp.name, '\n') + } else { + error() + return + } + + const linkedSiteUrlRegex = /Site url:\s+(\S+)/ + const lineMatch = linkedSiteUrlRegex.exec(stdout) + const urlMatch = lineMatch ? lineMatch[1] : undefined + if (urlMatch) { + log(`\nDirectory ${chalk.cyanBright(repoResp.name)} linked to site ${chalk.cyanBright(urlMatch)}\n`) + log( + `${chalk.cyanBright.bold('cd', repoResp.name)} to use other netlify cli commands in the cloned directory.\n`, + ) + } else { + const linkedSiteMatch = /Site already linked to\s+(\S+)/.exec(stdout) + const linkedSiteNameMatch = linkedSiteMatch ? linkedSiteMatch[1] : undefined + if (linkedSiteNameMatch) { + log(`\nThis directory appears to be linked to ${chalk.cyanBright(linkedSiteNameMatch)}`) + log('This can happen if you cloned the template into a subdirectory of an existing Netlify project.') + log( + `You may need to move the ${chalk.cyanBright( + repoResp.name, + )} directory out of its parent directory and then re-run the ${chalk.cyanBright( + 'link', + )} command manually\n`, + ) + } else { + log('A problem occurred linking the site') + log('You can try again manually by running:') + log(chalk.cyanBright(`cd ${repoResp.name} && netlify link\n`)) + } + } + } else { + log('To link the cloned directory manually, run:') + log(chalk.cyanBright(`cd ${repoResp.name} && netlify link\n`)) + } } if (options.withCi) { diff --git a/src/utils/sites/utils.ts b/src/utils/sites/utils.ts index 4f9a7c7218d..44b6d1efaaa 100644 --- a/src/utils/sites/utils.ts +++ b/src/utils/sites/utils.ts @@ -1,4 +1,5 @@ import fetch from 'node-fetch' +import execa from 'execa' import { log, GitHubRepoResponse, error } from '../command-helpers.js' import { GitHubRepo, Template } from '../types.js' @@ -68,3 +69,11 @@ export const createRepo = async ( return data as GitHubRepoResponse } + +export const callLinkSite = async (cliPath: string, repoName: string, input: string) => { + const { stdout } = await execa(cliPath, ['link'], { + input, + cwd: repoName, + }) + return stdout +} diff --git a/tests/integration/commands/sites/sites-create-template.test.ts b/tests/integration/commands/sites/sites-create-template.test.ts index 17906c75be2..8c3a93c5922 100644 --- a/tests/integration/commands/sites/sites-create-template.test.ts +++ b/tests/integration/commands/sites/sites-create-template.test.ts @@ -6,8 +6,14 @@ import { beforeEach, afterEach, describe, expect, test, vi, afterAll } from 'vit import BaseCommand from '../../../../src/commands/base-command.ts' import { createSitesFromTemplateCommand } from '../../../../src/commands/sites/sites.ts' import { deployedSiteExists, fetchTemplates, getTemplateName } from '../../../../src/utils/sites/create-template.ts' -import { getTemplatesFromGitHub, validateTemplate, createRepo } from '../../../../src/utils/sites/utils.ts' +import { + getTemplatesFromGitHub, + validateTemplate, + createRepo, + callLinkSite, +} from '../../../../src/utils/sites/utils.ts' import { getEnvironmentVariables, withMockApi } from '../../utils/mock-api.js' +import { chalk } from '../../../../src/utils/command-helpers.ts' vi.mock('../../../../src/utils/init/config-github.ts') vi.mock('../../../../src/utils/sites/utils.ts') @@ -49,13 +55,16 @@ describe('sites:create-template', () => { vi .fn() .mockImplementationOnce(() => Promise.resolve({ accountSlug: 'test-account' })) - .mockImplementationOnce(() => Promise.resolve({ name: 'test-name' })), + .mockImplementationOnce(() => Promise.resolve({ name: 'test-name' })) + .mockImplementationOnce(() => Promise.resolve({ cloneConfirm: true })) + .mockImplementationOnce(() => Promise.resolve({ linkConfirm: true })), { prompts: inquirer.prompt?.prompts || {}, registerPrompt: inquirer.prompt?.registerPrompt || vi.fn(), restoreDefaultPrompts: inquirer.prompt?.restoreDefaultPrompts || vi.fn(), }, ) + vi.mocked(fetchTemplates).mockResolvedValue([ { name: 'mockTemplateName', @@ -82,6 +91,7 @@ describe('sites:create-template', () => { full_name: 'mockName', private: true, default_branch: 'mockBranch', + name: 'repoName', }) }) @@ -145,4 +155,95 @@ describe('sites:create-template', () => { }) expect(stdoutwriteSpy).toHaveBeenCalledWith('A site with that name already exists on your account\n') }) + + test('it should automatically link to the site when the user clones the template repo', async (t) => { + const mockSuccessfulLinkOutput = ` + Directory Linked + + Admin url: https://app.netlify.com/sites/site-name + Site url: https://site-name.netlify.app + + You can now run other \`netlify\` cli commands in this directory + ` + vi.mocked(callLinkSite).mockImplementationOnce(() => Promise.resolve(mockSuccessfulLinkOutput)) + + const autoLinkRoutes = [ + { + path: 'accounts', + response: [{ slug: 'test-account' }], + }, + { + path: 'sites', + response: [{ name: 'test-name-unique' }], + }, + { + path: 'test-account/sites', + response: siteInfo, + method: 'post', + }, + ] + + const stdoutwriteSpy = vi.spyOn(process.stdout, 'write') + await withMockApi(autoLinkRoutes, async ({ apiUrl }) => { + Object.assign(process.env, getEnvironmentVariables({ apiUrl })) + + const program = new BaseCommand('netlify') + + vi.mocked(deployedSiteExists).mockResolvedValue(false) + + createSitesFromTemplateCommand(program) + + await program.parseAsync(['', '', 'sites:create-template']) + }) + + expect(stdoutwriteSpy).toHaveBeenCalledWith( + `\nDirectory ${chalk.cyanBright('repoName')} linked to site ${chalk.cyanBright( + 'https://site-name.netlify.app', + )}\n\n`, + ) + }) + + test('it should output instructions if a site is already linked', async (t) => { + const mockUnsuccessfulLinkOutput = ` + Site already linked to \"site-name\" + Admin url: https://app.netlify.com/sites/site-name + + To unlink this site, run: netlify unlink + ` + + vi.mocked(callLinkSite).mockImplementationOnce(() => Promise.resolve(mockUnsuccessfulLinkOutput)) + + const autoLinkRoutes = [ + { + path: 'accounts', + response: [{ slug: 'test-account' }], + }, + { + path: 'sites', + response: [{ name: 'test-name-unique' }], + }, + { + path: 'test-account/sites', + response: siteInfo, + method: 'post', + }, + ] + + const stdoutwriteSpy = vi.spyOn(process.stdout, 'write') + await withMockApi(autoLinkRoutes, async ({ apiUrl }) => { + Object.assign(process.env, getEnvironmentVariables({ apiUrl })) + + const program = new BaseCommand('netlify') + + vi.mocked(deployedSiteExists).mockResolvedValue(false) + + createSitesFromTemplateCommand(program) + + await program.parseAsync(['', '', 'sites:create-template']) + }) + + expect(stdoutwriteSpy).toHaveBeenCalledWith( + `\nThis directory appears to be linked to ${chalk.cyanBright(`"site-name"`)}\n`, + ) + }) })