diff --git a/package-lock.json b/package-lock.json index e5e88f388a0..1a390437619 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6133,6 +6133,7 @@ }, "node_modules/@parcel/watcher-wasm/node_modules/napi-wasm": { "version": "1.1.0", + "extraneous": true, "inBundle": true, "license": "MIT" }, diff --git a/src/commands/database/db-status.ts b/src/commands/database/db-status.ts index 346024c3384..22428762de7 100644 --- a/src/commands/database/db-status.ts +++ b/src/commands/database/db-status.ts @@ -265,16 +265,16 @@ const renderPretty = (params: RenderParams) => { } } else { primary(STATUS_WARN, 'Netlify Database is not enabled for this project') - secondary(`Install the ${chalk.bold(NETLIFY_DATABASE_PACKAGE)} package and deploy your site to automatically`) + secondary(`Install the ${NETLIFY_DATABASE_PACKAGE} package and deploy your site to automatically`) secondary(`provision a database. Refer to ${DOCS_URL} for more information.`) } log('') if (packageInstalled) { - primary(STATUS_GOOD, `The ${chalk.bold(NETLIFY_DATABASE_PACKAGE)} package is installed`) + primary(STATUS_GOOD, `The ${NETLIFY_DATABASE_PACKAGE} package is installed`) secondary(`For a full API reference, visit ${DOCS_URL}`) } else { - primary(STATUS_WARN, `The ${chalk.bold(NETLIFY_DATABASE_PACKAGE)} package is not installed`) + primary(STATUS_WARN, `The ${NETLIFY_DATABASE_PACKAGE} package is not installed`) secondary(`Install it with \`npm install ${NETLIFY_DATABASE_PACKAGE}\``) secondary(`Refer to ${DOCS_URL} for more information`) } diff --git a/src/utils/init/config-manual.ts b/src/utils/init/config-manual.ts index 7b8c6779a3e..40a026e9cee 100644 --- a/src/utils/init/config-manual.ts +++ b/src/utils/init/config-manual.ts @@ -60,6 +60,9 @@ const addDeployHook = async (deployHook: string | undefined): Promise = return deployHookAdded } +const isSupportedProvider = (provider: string | null): provider is 'github' | 'gitlab' => + provider === 'github' || provider === 'gitlab' + export default async function configManual({ command, repoData, @@ -86,9 +89,9 @@ export default async function configManual({ const deployKey = await createDeployKey({ api }) await addDeployKey(deployKey) - const repoPath = await getRepoPath({ repoData }) + const repoPath = repoData.repo ?? (await getRepoPath({ repoData })) const repo = { - provider: 'manual', + provider: isSupportedProvider(repoData.provider) ? repoData.provider : 'manual', repo_path: repoPath, repo_branch: repoData.branch, allowed_branches: [repoData.branch], diff --git a/src/utils/telemetry/report-error.ts b/src/utils/telemetry/report-error.ts index c14b03ee1cc..a531b7a4a24 100644 --- a/src/utils/telemetry/report-error.ts +++ b/src/utils/telemetry/report-error.ts @@ -59,8 +59,12 @@ export const reportError = async function (error, config = {}) { // spawn detached child process to handle send and wait for the http request to finish // otherwise it can get canceled - await execa(process.execPath, [join(dirPath, 'request.js'), options], { - detached: true, - stdio: 'ignore', - }) + try { + await execa(process.execPath, [join(dirPath, 'request.js'), options], { + detached: true, + stdio: 'ignore', + }) + } catch { + return + } } diff --git a/tests/unit/commands/database/db-status.test.ts b/tests/unit/commands/database/db-status.test.ts index 8dc31ae79d3..0672d586960 100644 --- a/tests/unit/commands/database/db-status.test.ts +++ b/tests/unit/commands/database/db-status.test.ts @@ -1,6 +1,6 @@ import { relative, sep } from 'path' -import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest' +import { describe, expect, test, vi, beforeEach, afterEach, afterAll } from 'vitest' const { mockReaddir, @@ -255,6 +255,10 @@ afterEach(() => { delete process.env.NETLIFY_DB_BRANCH }) +afterAll(() => { + vi.unstubAllGlobals() +}) + describe('statusDb', () => { describe('enabled flag', () => { test('reports enabled=true when NETLIFY_DB_URL is set', async () => { diff --git a/tests/unit/utils/get-repo-data.test.ts b/tests/unit/utils/get-repo-data.test.ts new file mode 100644 index 00000000000..6433a1ffae1 --- /dev/null +++ b/tests/unit/utils/get-repo-data.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import getRepoData from '../../../src/utils/get-repo-data.js' + +const mockGitConfig = vi.fn() +const mockFindUp = vi.fn() +const mockGitRepoInfo = vi.fn() + +vi.mock('gitconfiglocal', () => ({ + default: (workingDir: string, cb: (err: Error | null, config: unknown) => void) => { + try { + cb(null, mockGitConfig(workingDir)) + } catch (err) { + cb(err as Error, null) + } + }, +})) + +vi.mock('find-up', () => ({ + findUp: (...args: unknown[]): unknown => mockFindUp(...args), +})) + +vi.mock('git-repo-info', () => ({ + default: (): unknown => mockGitRepoInfo(), +})) + +vi.mock('../../../src/utils/command-helpers.js', () => ({ + log: vi.fn(), +})) + +describe('getRepoData', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFindUp.mockResolvedValue('/test/.git') + mockGitRepoInfo.mockReturnValue({ branch: 'main' }) + }) + + it('parses GitHub SSH URLs', async () => { + mockGitConfig.mockReturnValue({ remote: { origin: { url: 'git@github.com:ownername/test.git' } } }) + + const result = await getRepoData({ workingDir: '/test' }) + + expect(result).toEqual({ + name: 'test', + owner: 'ownername', + repo: 'ownername/test', + url: 'git@github.com:ownername/test.git', + branch: 'main', + provider: 'github', + httpsUrl: 'https://github.com/ownername/test', + }) + }) + + it('parses GitLab SSH URLs', async () => { + mockGitConfig.mockReturnValue({ remote: { origin: { url: 'git@gitlab.com:ownername/test.git' } } }) + + const result = await getRepoData({ workingDir: '/test' }) + + expect(result).toEqual({ + name: 'test', + owner: 'ownername', + repo: 'ownername/test', + url: 'git@gitlab.com:ownername/test.git', + branch: 'main', + provider: 'gitlab', + httpsUrl: 'https://gitlab.com/ownername/test', + }) + }) + + it('parses GitHub HTTPS URLs', async () => { + mockGitConfig.mockReturnValue({ remote: { origin: { url: 'https://github.com/ownername/test.git' } } }) + + const result = await getRepoData({ workingDir: '/test' }) + + expect(result).toMatchObject({ + provider: 'github', + repo: 'ownername/test', + httpsUrl: 'https://github.com/ownername/test', + }) + }) + + it('parses GitLab HTTPS URLs', async () => { + mockGitConfig.mockReturnValue({ remote: { origin: { url: 'https://gitlab.com/ownername/test.git' } } }) + + const result = await getRepoData({ workingDir: '/test' }) + + expect(result).toMatchObject({ + provider: 'gitlab', + repo: 'ownername/test', + httpsUrl: 'https://gitlab.com/ownername/test', + }) + }) + + it('uses host as provider for unknown Git hosts', async () => { + mockGitConfig.mockReturnValue({ + remote: { origin: { url: 'git@custom-git.example.com:user/test.git' } }, + }) + + const result = await getRepoData({ workingDir: '/test' }) + + expect(result).toMatchObject({ + provider: 'custom-git.example.com', + repo: 'user/test', + httpsUrl: 'https://custom-git.example.com/user/test', + }) + }) + + it('uses the specified remote name when provided', async () => { + mockGitConfig.mockReturnValue({ + remote: { + origin: { url: 'git@github.com:owner/origin-repo.git' }, + upstream: { url: 'git@gitlab.com:owner/upstream-repo.git' }, + }, + }) + + const result = await getRepoData({ workingDir: '/test', remoteName: 'upstream' }) + + expect(result).toMatchObject({ + provider: 'gitlab', + repo: 'owner/upstream-repo', + }) + }) + + it('returns an error when no Git remote is found', async () => { + mockFindUp.mockResolvedValue(undefined) + mockGitConfig.mockReturnValue({ remote: {} }) + + const result = await getRepoData({ workingDir: '/test' }) + + expect(result).toEqual({ error: 'No Git remote found' }) + }) + + it('returns an error when the requested remote is not defined', async () => { + mockGitConfig.mockReturnValue({ remote: { origin: { url: 'git@github.com:owner/repo.git' } } }) + + const result = await getRepoData({ workingDir: '/test', remoteName: 'missing' }) + + expect(result).toEqual({ + error: + 'The specified remote "missing" is not defined in Git repo. Please use --git-remote-name flag to specify a remote.', + }) + }) +}) diff --git a/tests/unit/utils/init/config-manual.test.ts b/tests/unit/utils/init/config-manual.test.ts new file mode 100644 index 00000000000..c720c5d67a6 --- /dev/null +++ b/tests/unit/utils/init/config-manual.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { RepoData } from '../../../../src/utils/get-repo-data.js' +import type { NetlifyAPI } from '@netlify/api' +import type BaseCommand from '../../../../src/commands/base-command.js' + +const mockPrompt = vi.fn() +const mockLog = vi.fn() +const mockExit = vi.fn() +const mockCreateDeployKey = vi.fn() +const mockGetBuildSettings = vi.fn() +const mockSaveNetlifyToml = vi.fn() +const mockSetupSite = vi.fn() + +vi.mock('inquirer', () => ({ + default: { + prompt: mockPrompt, + }, +})) + +vi.mock('../../../../src/utils/command-helpers.js', () => ({ + log: mockLog, + exit: mockExit, +})) + +vi.mock('../../../../src/utils/init/utils.js', () => ({ + createDeployKey: mockCreateDeployKey, + getBuildSettings: mockGetBuildSettings, + saveNetlifyToml: mockSaveNetlifyToml, + setupSite: mockSetupSite, +})) + +describe('config-manual', () => { + let mockApi: Partial + let mockCommand: Pick + + beforeEach(() => { + vi.clearAllMocks() + + mockApi = {} + mockCommand = { + netlify: { + api: mockApi as NetlifyAPI, + cachedConfig: { configPath: '/test/netlify.toml' } as BaseCommand['netlify']['cachedConfig'], + config: { plugins: [] } as unknown as BaseCommand['netlify']['config'], + repositoryRoot: '/test', + } as BaseCommand['netlify'], + } + + mockPrompt.mockResolvedValue({ + sshKeyAdded: true, + repoPath: 'git@gitlab.com:test/repo.git', + deployHookAdded: true, + }) + + mockCreateDeployKey.mockResolvedValue({ id: 'key-123', public_key: 'ssh-rsa test' }) + mockGetBuildSettings.mockResolvedValue({ + baseDir: '', + buildCmd: 'npm run build', + buildDir: 'dist', + functionsDir: 'functions', + pluginsToInstall: [], + }) + mockSaveNetlifyToml.mockResolvedValue(undefined) + mockSetupSite.mockResolvedValue({ deploy_hook: 'https://api.netlify.com/hooks/test' }) + }) + + describe('GitLab repository configuration', () => { + it('should use provider from repoData for GitLab repos', async () => { + const configManual = (await import('../../../../src/utils/init/config-manual.js')).default + + const repoData: RepoData = { + name: 'test', + owner: 'ownername', + repo: 'ownername/test', + url: 'git@gitlab.com:ownername/test.git', + branch: 'main', + provider: 'gitlab', + httpsUrl: 'https://gitlab.com/ownername/test', + } + + await configManual({ + command: mockCommand as BaseCommand, + repoData, + siteId: 'site-123', + }) + + const setupCall = mockSetupSite.mock.calls[0][0] as { repo: { provider: string; repo_path: string } } + expect(setupCall.repo.provider).toBe('gitlab') + expect(setupCall.repo.repo_path).toBe('ownername/test') + }) + + it('should use repo path (owner/name format) instead of SSH URL for GitLab', async () => { + const configManual = (await import('../../../../src/utils/init/config-manual.js')).default + + const repoData: RepoData = { + name: 'test', + owner: 'ownername', + repo: 'ownername/test', + url: 'git@gitlab.com:ownername/test.git', + branch: 'main', + provider: 'gitlab', + httpsUrl: 'https://gitlab.com/ownername/test', + } + + await configManual({ + command: mockCommand as BaseCommand, + repoData, + siteId: 'site-123', + }) + + const setupSiteCall = mockSetupSite.mock.calls[0][0] as { + repo: { repo_path: string } + } + expect(setupSiteCall.repo.repo_path).toBe('ownername/test') + expect(setupSiteCall.repo.repo_path).not.toBe('git@gitlab.com:ownername/test.git') + }) + + it('should fallback to manual provider when provider is null', async () => { + const configManual = (await import('../../../../src/utils/init/config-manual.js')).default + + const repoData: RepoData = { + name: 'test', + owner: 'user', + repo: 'user/test', + url: 'git@custom.com:user/test.git', + branch: 'main', + provider: null, + httpsUrl: 'https://custom.com/user/test', + } + + await configManual({ + command: mockCommand as BaseCommand, + repoData, + siteId: 'site-123', + }) + + const setupCall = mockSetupSite.mock.calls[0][0] as { repo: { provider: string } } + expect(setupCall.repo.provider).toBe('manual') + }) + + it('should fallback to manual provider for unknown Git hosts', async () => { + const configManual = (await import('../../../../src/utils/init/config-manual.js')).default + + const repoData: RepoData = { + name: 'test', + owner: 'user', + repo: 'user/test', + url: 'git@custom-git.example.com:user/test.git', + branch: 'main', + provider: 'custom-git.example.com', + httpsUrl: 'https://custom-git.example.com/user/test', + } + + await configManual({ + command: mockCommand as BaseCommand, + repoData, + siteId: 'site-123', + }) + + const setupCall = mockSetupSite.mock.calls[0][0] as { repo: { provider: string } } + expect(setupCall.repo.provider).toBe('manual') + }) + }) + + describe('GitHub repository configuration', () => { + it('should use provider from repoData for GitHub repos', async () => { + const configManual = (await import('../../../../src/utils/init/config-manual.js')).default + + const repoData: RepoData = { + name: 'test', + owner: 'user', + repo: 'user/test', + url: 'git@github.com:user/test.git', + branch: 'main', + provider: 'github', + httpsUrl: 'https://github.com/user/test', + } + + await configManual({ + command: mockCommand as BaseCommand, + repoData, + siteId: 'site-123', + }) + + const setupCall = mockSetupSite.mock.calls[0][0] as { repo: { provider: string; repo_path: string } } + expect(setupCall.repo.provider).toBe('github') + expect(setupCall.repo.repo_path).toBe('user/test') + }) + }) +})