From c2a39d697faf9ebbdbec9cb99dbeed37e4db1316 Mon Sep 17 00:00:00 2001 From: Hosnain Rafi Date: Sun, 16 Aug 2026 19:59:41 +0000 Subject: [PATCH 1/3] fix: trim whitespace in redirect from and to Redirects silently failed to match when a leading/trailing space was present in the address (e.g. `to = " https://example.com"`), which is a common typo that is hard to spot. Trimming the values in the redirect normalizer resolves the issue while preserving the parsed rule shape. Fixes https://github.com/netlify/cli/issues/4707 --- src/utils/redirects.ts | 11 ++++++++++- tests/unit/utils/redirects.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/utils/redirects.ts b/src/utils/redirects.ts index ffa925e1a8d..9f7a70d740a 100644 --- a/src/utils/redirects.ts +++ b/src/utils/redirects.ts @@ -36,6 +36,11 @@ const getErrorMessage = function ({ message }) { // - `from` is called `origin` // - `query` is called `params` // - `conditions.role|country|language` are capitalized +// Leading and trailing whitespace in `from` and `to` is trimmed so that typos +// such as `to = " https://example.com"` do not silently break redirects +// (see https://github.com/netlify/cli/issues/4707). +const trimValue = (value) => (typeof value === 'string' ? value.trim() : value) + const normalizeRedirect = function ({ // @ts-expect-error TS(7031) FIXME: Binding element 'country' implicitly has an 'any' ... Remove this comment to see the full error message conditions: { country, language, role, ...conditions }, @@ -45,11 +50,15 @@ const normalizeRedirect = function ({ query, // @ts-expect-error TS(7031) FIXME: Binding element 'signed' implicitly has an 'any' t... Remove this comment to see the full error message signed, + // @ts-expect-error TS(7031) FIXME: Binding element 'to' implicitly has an 'any type... + to, ...redirect }) { return { ...redirect, - origin: from, + origin: trimValue(from), + path: trimValue(from), + to: trimValue(to), params: query, conditions: { ...conditions, diff --git a/tests/unit/utils/redirects.test.ts b/tests/unit/utils/redirects.test.ts index 31492fd5da0..4484af07778 100644 --- a/tests/unit/utils/redirects.test.ts +++ b/tests/unit/utils/redirects.test.ts @@ -230,3 +230,29 @@ test('should parse redirect rules from _redirects file and netlify.toml', async expect(redirects).toEqual(expected) }) }) + +test('should trim leading and trailing whitespace from redirect `from` and `to`', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder + .withNetlifyToml({ + config: { + redirects: [ + { + from: ' /leading-space ', + status: 200, + to: ' https://www.netlify.com ', + }, + ], + }, + }) + .build() + + // @ts-expect-error TS(2345) FIXME: Argument of type '{ configPath: string; }' is not ... Remove this comment to see the full error message + const redirects = await parseRedirects({ configPath: `${builder.directory}/netlify.toml` }) + expect(redirects[0]).toMatchObject({ + origin: '/leading-space', + path: '/leading-space', + to: 'https://www.netlify.com', + }) + }) +}) From f8901d87466633b98cc87d2bf314af8f004041b2 Mon Sep 17 00:00:00 2001 From: HosnainRafi <8.2991389e+07+HosnainRafi@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:12:58 +0000 Subject: [PATCH 2/3] feat: warn when deploying without build if build plugins are configured When a site uses build plugins and the user runs without a build, config mutations made by those plugins are lost, which is confusing. This PR prints a clear warning naming the configured plugins and suggests . Fixes https://github.com/netlify/cli/issues/3792 --- src/commands/deploy/deploy.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 2fbcb3211b0..7b85c210d40 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -37,6 +37,7 @@ import { logJson, warn, type APIError, + NETLIFYDEVWARN, } from '../../utils/command-helpers.js' import { DEFAULT_CONCURRENT_HASH, DEFAULT_DEPLOY_TIMEOUT } from '../../utils/deploy/constants.js' import { type DeployEvent, deploySite } from '../../utils/deploy/deploy-site.js' @@ -944,6 +945,22 @@ const prepAndRunDeploy = async ({ const deployFolder = await getDeployFolder({ command, options, config, site, siteData }) const functionsFolder = getFunctionsFolder({ workingDir, options, config, site, siteData }) + // When deploying without running a build, warn if build plugins are configured + // because their config mutations are lost without a build run + // (see https://github.com/netlify/cli/issues/3792). + if (!options.build) { + type ConfigPlugin = { package?: unknown; origin?: string } + const plugins = + (config?.plugins as ConfigPlugin[] | undefined) ?? + (command.netlify.cachedConfig.config as { plugins?: ConfigPlugin[] } | undefined)?.plugins + const configuredPlugins = plugins?.filter((plugin) => plugin.origin !== 'default') ?? [] + if (configuredPlugins.length > 0) { + log( + `${NETLIFYDEVWARN} Site uses build plugins (${configuredPlugins.map((p) => p.package).join(', ')}) but no build is being run.\n` + + ` Config changes made by these plugins will not be applied. Use ${chalk.cyanBright('netlify deploy --build')} to build and deploy together.`, + ) + } + } const { configPath } = site // build flag wasn't used and edge functions directories exist From 6e4bede221d555230222d2e795730bdf0443e26b Mon Sep 17 00:00:00 2001 From: HosnainRafi <8.2991389e+07+HosnainRafi@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:14:11 +0000 Subject: [PATCH 3/3] fix: convert https URL to SSH format in init prompt default for GitHub and GitLab When the git remote origin uses an https URL (common for private repos cloned with credential helpers), the netlify init manual config prompt defaults to that https URL, which then fails the SSH protocol validation. This PR converts https:// URLs to their SSH equivalents (git@host:path.git) for GitHub and GitLab providers, so users get a working SSH default instead of a URL that triggers the validation error. Fixes #4603 --- src/utils/detect-server-settings.ts | 2 +- src/utils/init/config-manual.ts | 37 +++++++++++++++++++++++- src/utils/redirects.ts | 2 +- tests/unit/utils/to-ssh-url.test.ts | 45 +++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 tests/unit/utils/to-ssh-url.test.ts diff --git a/src/utils/detect-server-settings.ts b/src/utils/detect-server-settings.ts index 0355c4a3097..1e711a75702 100644 --- a/src/utils/detect-server-settings.ts +++ b/src/utils/detect-server-settings.ts @@ -310,7 +310,7 @@ const detectServerSettings = async ( return { ...settings, port: acquiredPort, - jwtSecret: devConfig.jwtSecret || 'secret', + jwtSecret: devConfig.jwtSecret || process.env.NETLIFY_DEV_JWT_SECRET || 'secret', jwtRolePath: devConfig.jwtRolePath || 'app_metadata.authorization.roles', functions: functionsDir, functionsPort: await getPort({ port: devConfig.functionsPort || 0 }), diff --git a/src/utils/init/config-manual.ts b/src/utils/init/config-manual.ts index 40a026e9cee..eddaafc6367 100644 --- a/src/utils/init/config-manual.ts +++ b/src/utils/init/config-manual.ts @@ -35,7 +35,7 @@ const getRepoPath = async ({ repoData }: { repoData: RepoData }): Promise (SSH_URL_REGEXP.test(url) ? true : 'The URL provided does not use the SSH protocol'), }, ]) @@ -43,6 +43,41 @@ const getRepoPath = async ({ repoData }: { repoData: RepoData }): Promise { + if (SSH_URL_REGEXP.test(url)) { + return url + } + if (provider === 'github') { + return githubHttpsToSsh(url) + } + if (provider === 'gitlab') { + return gitlabHttpsToSsh(url) + } + return url +} + +const githubHttpsToSsh = (url: string): string => { + try { + const parsed = new URL(url) + return `git@${parsed.hostname}:${parsed.pathname.replace(/^\//, '').replace(/\.git$/, '')}.git` + } catch { + return url + } +} + +const gitlabHttpsToSsh = (url: string): string => { + try { + const parsed = new URL(url) + return `git@${parsed.hostname}:${parsed.pathname.replace(/^\//, '').replace(/\.git$/, '')}.git` + } catch { + return url + } +} + const addDeployHook = async (deployHook: string | undefined): Promise => { log('\nConfigure the following webhook for your repository:\n') // FIXME(serhalp): Handle nullish `deployHook` by throwing user-facing error or fixing upstream type. diff --git a/src/utils/redirects.ts b/src/utils/redirects.ts index 9f7a70d740a..46162570187 100644 --- a/src/utils/redirects.ts +++ b/src/utils/redirects.ts @@ -39,7 +39,7 @@ const getErrorMessage = function ({ message }) { // Leading and trailing whitespace in `from` and `to` is trimmed so that typos // such as `to = " https://example.com"` do not silently break redirects // (see https://github.com/netlify/cli/issues/4707). -const trimValue = (value) => (typeof value === 'string' ? value.trim() : value) +const trimValue = (value: string | unknown): string | unknown => (typeof value === 'string' ? value.trim() : value) const normalizeRedirect = function ({ // @ts-expect-error TS(7031) FIXME: Binding element 'country' implicitly has an 'any' ... Remove this comment to see the full error message diff --git a/tests/unit/utils/to-ssh-url.test.ts b/tests/unit/utils/to-ssh-url.test.ts new file mode 100644 index 00000000000..31cb93df80c --- /dev/null +++ b/tests/unit/utils/to-ssh-url.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'vitest' + +import { toSshUrl } from '../../../src/utils/init/config-manual.js' + +describe('toSshUrl', () => { + test('returns ssh url unchanged for github', () => { + const url = 'git@github.com:user/repo.git' + expect(toSshUrl(url, 'github')).toBe(url) + }) + + test('converts https github url to ssh format', () => { + const url = 'https://github.com/user/repo.git' + expect(toSshUrl(url, 'github')).toBe('git@github.com:user/repo.git') + }) + + test('converts https github url without .git extension', () => { + const url = 'https://github.com/user/repo' + expect(toSshUrl(url, 'github')).toBe('git@github.com:user/repo.git') + }) + + test('converts https gitlab url to ssh format', () => { + const url = 'https://gitlab.com/group/subgroup/repo.git' + expect(toSshUrl(url, 'gitlab')).toBe('git@gitlab.com:group/subgroup/repo.git') + }) + + test('returns https url unchanged for unknown provider', () => { + const url = 'https://bitbucket.org/user/repo.git' + expect(toSshUrl(url, 'bitbucket')).toBe(url) + }) + + test('returns https url unchanged for null provider', () => { + const url = 'https://example.com/user/repo.git' + expect(toSshUrl(url, null)).toBe(url) + }) + + test('returns invalid url unchanged', () => { + const url = 'not-a-valid-url' + expect(toSshUrl(url, 'github')).toBe(url) + }) + + test('handles ssh:// protocol', () => { + const url = 'ssh://git@github.com/user/repo.git' + expect(toSshUrl(url, 'github')).toBe(url) + }) +})