Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions src/commands/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -438,7 +439,17 @@ const reportDeployError = ({

const deployProgressCb = function () {
const spinnersByType: Record<DeployEvent['type'], Spinner> = {}
// Steps that produce concurrent stdout output (e.g., esbuild during bundling)
// should not use animated spinners to avoid mixing output (see #2391).
const noSpinnerTypes = new Set<DeployEvent['type']>(['edge-functions-bundling'])
return (event: DeployEvent) => {
if (noSpinnerTypes.has(event.type)) {
// For concurrent-output steps, log text status only (no spinner).
if (event.phase === 'stop') {
log(event.msg)
}
return
}
switch (event.phase) {
case 'start': {
spinnersByType[event.type] = startSpinner({
Expand Down Expand Up @@ -769,10 +780,11 @@ const bundleEdgeFunctions = async (options: DeployOptionValues, command: BaseCom
const argv = process.argv.slice(2)
const statusCb =
options.silent || argv.includes('--json') || argv.includes('--silent') ? () => {} : deployProgressCb()

// During bundling, esbuild outputs to stdout concurrently. deployProgressCb
// skips the spinner for this step to avoid mixing output (see #2391).
statusCb({
type: 'edge-functions-bundling',
msg: 'Bundling edge functions...\n',
msg: 'Bundling edge functions...',
phase: 'start',
})

Expand Down Expand Up @@ -944,6 +956,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
Expand Down
16 changes: 12 additions & 4 deletions src/commands/watch/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const BUILD_FINISH_INTERVAL = 1e3
// 20 minutes
const BUILD_FINISH_TIMEOUT = 12e5

const waitForBuildFinish = async function (api: NetlifyAPI, siteId: string, spinner: Spinner) {
const waitForBuildFinish = async function (api: NetlifyAPI, siteId: string, spinner: Spinner | undefined) {
let firstPass = true

const waitForBuildToFinish = async function () {
Expand All @@ -27,7 +27,11 @@ const waitForBuildFinish = async function (api: NetlifyAPI, siteId: string, spin
// @TODO implement build error messages into this

if (!currentBuilds || currentBuilds.length === 0) {
stopSpinner({ spinner })
if (spinner) {
stopSpinner({ spinner })
} else {
log('Waiting for active project deploys to complete... done')
}
return true
}
firstPass = false
Expand All @@ -46,7 +50,7 @@ const waitForBuildFinish = async function (api: NetlifyAPI, siteId: string, spin
return firstPass
}

export const watch = async (_options: unknown, command: BaseCommand) => {
export const watch = async (options: { silent?: boolean; json?: boolean }, command: BaseCommand) => {
await command.authenticate()
const client = command.netlify.api
let siteId = command.netlify.site.id
Expand Down Expand Up @@ -80,7 +84,11 @@ export const watch = async (_options: unknown, command: BaseCommand) => {
// "created_at": "2018-07-17T17:14:03.423Z"
// }
//
const spinner = startSpinner({ text: 'Waiting for active project deploys to complete' })
// Allow suppressing the spinner via --silent or --json (see #5301)
const suppressSpinner = options?.silent || options?.json
const spinner = suppressSpinner
? undefined
: startSpinner({ text: 'Waiting for active project deploys to complete' })
try {
// Fetch all builds!
// const builds = await client.listSiteBuilds({siteId})
Expand Down
2 changes: 1 addition & 1 deletion src/utils/detect-server-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
37 changes: 36 additions & 1 deletion src/utils/init/config-manual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,49 @@ const getRepoPath = async ({ repoData }: { repoData: RepoData }): Promise<string
type: 'input',
name: 'repoPath',
message: 'The SSH URL of the remote git repo:',
default: repoData.url,
default: toSshUrl(repoData.url, repoData.provider),
validate: (url: string) => (SSH_URL_REGEXP.test(url) ? true : 'The URL provided does not use the SSH protocol'),
},
])

return repoPath
}

/**
* Converts an https:// URL to its SSH equivalent for known Git providers.
* Returns the original URL if already SSH or if the provider is unknown.
*/
export const toSshUrl = (url: string, provider: string | null): string => {
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<boolean> => {
log('\nConfigure the following webhook for your repository:\n')
// FIXME(serhalp): Handle nullish `deployHook` by throwing user-facing error or fixing upstream type.
Expand Down
11 changes: 10 additions & 1 deletion src/utils/redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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
conditions: { country, language, role, ...conditions },
Expand All @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/utils/redirects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
})
})
45 changes: 45 additions & 0 deletions tests/unit/utils/to-ssh-url.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})