Repository: netlify/cli (ts)

Issue:
feat(sites-create-template): auto-link cloned repo to the created netlify site
### Which problem is this feature request solving?

Follow up to https://github.com/netlify/cli/issues/4411

After cloning the repo, we should auto-link it to the created site.

### Describe the solution you'd like

If the user confirmed to clone the repo, the CLI should auto-link the repo to the site that was created.

### Pull request (optional)

- [ ] I can submit a pull request.

Pull request:
feat(sites-create-template): auto-link cloned repo to the created net…
…lify site

🎉 Thanks for submitting a pull request! 🎉

#### Summary

Fixes #4438 

This PR adds functionality (requested in #4438)  to `sites:create-template` wherein after cloning the newly created repo, the cli will prompt the user asking if they'd like to link the directory to the cloned repo. 

This is what the user saw before our changes:
<img width="634" alt="Screenshot 2024-11-04 at 9 03 52 AM" src="https://github.com/user-attachments/assets/70e0f569-2638-46ae-9517-af8dfc205439">

This is the dialog the user sees with our changes:
<img width="637" alt="Screenshot 2024-11-04 at 9 03 26 AM" src="https://github.com/user-attachments/assets/88804b52-988e-4c4d-b46a-c3264a08ec27">

This PR also contains tests for the new functionality and error handling, including in the case of a site already being linked to a parent directory.

---

For us to review and ship your PR efficiently, please perform the following steps:

- [ ] Open a [bug/issue](https://github.com/netlify/cli/issues/new/choose) before writing your code 🧑‍💻. This ensures we can discuss the changes and get feedback from everyone that should be involved. If you\`re fixing a typo or something that\`s on fire 🔥 (e.g. incident related), you can skip this step.
- [ ] Read the [contribution guidelines](../CONTRIBUTING.md) 📖. This ensures your code follows our style guide and
      passes our tests.
- [ ] Update or add tests (if any source code was changed or added) 🧪
- [ ] Update or add documentation (if features were changed or added) 📝
- [ ] Make sure the status checks below are successful ✅

**A picture of a cute animal (not mandatory, but encouraged)**


Patch:
diff --git a/src/commands/sites/sites-create-template.ts b/src/commands/sites/sites-create-template.ts
index aad249cb..dadc1dea 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 4f9a7c72..44b6d1ef 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
+}


Test patch:
diff --git a/tests/integration/commands/sites/sites-create-template.test.ts b/tests/integration/commands/sites/sites-create-template.test.ts
index 17906c75..8c3a93c5 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`,
+    )
+  })
 })