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
120 changes: 60 additions & 60 deletions src/commands/logs/sources/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import type { NetlifyAPI } from '@netlify/api'
import type { NetlifyAPI } from "@netlify/api";

import { getWebSocket } from '../../../utils/websockets/index.js'
import { debugFetch } from '../log-api.js'
import type { LogEntry } from '../log-api.js'
import { getWebSocket } from "../../../utils/websockets/index.js";
import { debugFetch } from "../log-api.js";
import type { LogEntry } from "../log-api.js";

interface DeployLogLine {
ts: string
log?: string
message?: string
level?: string
section?: string
type?: string
ts: string;
log?: string;
message?: string;
level?: string;
section?: string;
type?: string;
}

export const fetchDeployHistoricalLogs = async ({
Expand All @@ -20,44 +20,44 @@ export const fetchDeployHistoricalLogs = async ({
from,
to,
}: {
apiBase: string
accessToken: string | null | undefined
deployId: string
from: number
to: number
apiBase: string;
accessToken: string | null | undefined;
deployId: string;
from: number;
to: number;
}): Promise<LogEntry[]> => {
const response = await debugFetch(`${apiBase}/api/v1/deploys/${encodeURIComponent(deployId)}/log`, {
const response = await debugFetch(`${apiBase}/deploys/${encodeURIComponent(deployId)}/log`, {
headers: {
Authorization: `Bearer ${accessToken ?? ''}`,
Authorization: `Bearer ${accessToken ?? ""}`,
},
})
});

if (!response.ok) {
throw new Error(`Failed to fetch deploy logs: ${response.status.toString()} ${response.statusText}`)
throw new Error(`Failed to fetch deploy logs: ${response.status.toString()} ${response.statusText}`);
}

const logData = (await response.json()) as DeployLogLine[]
const logData = (await response.json()) as DeployLogLine[];
if (!Array.isArray(logData)) {
return []
return [];
}

return logData
.map((line): LogEntry | null => {
const ts = new Date(line.ts).getTime()
const ts = new Date(line.ts).getTime();
if (Number.isNaN(ts) || ts < from || ts > to) {
return null
return null;
}
return {
source: 'deploy',
name: 'deploy',
source: "deploy",
name: "deploy",
ts,
level: line.level ?? 'INFO',
message: line.log ?? line.message ?? '',
level: line.level ?? "INFO",
message: line.log ?? line.message ?? "",
section: line.section,
}
};
})
.filter((entry): entry is LogEntry => entry !== null)
}
.filter((entry): entry is LogEntry => entry !== null);
};

export const streamDeploy = (
siteId: string,
Expand All @@ -66,56 +66,56 @@ export const streamDeploy = (
onEntry: (entry: LogEntry) => void,
onClose: () => void,
): (() => void) => {
const ws = getWebSocket('wss://socketeer.services.netlify.com/build/logs')
const ws = getWebSocket("wss://socketeer.services.netlify.com/build/logs");

ws.on('open', () => {
ws.on("open", () => {
ws.send(
JSON.stringify({
deploy_id: deployId,
site_id: siteId,
access_token: accessToken,
}),
)
})
);
});

ws.on('message', (data: string) => {
ws.on("message", (data: string) => {
const logData = JSON.parse(data) as {
message: string
section?: string
type?: string
level?: string
ts?: string
}
message: string;
section?: string;
type?: string;
level?: string;
ts?: string;
};

onEntry({
source: 'deploy',
name: 'deploy',
source: "deploy",
name: "deploy",
ts: logData.ts ? new Date(logData.ts).getTime() : Date.now(),
level: logData.level ?? 'INFO',
level: logData.level ?? "INFO",
message: logData.message,
section: logData.section,
})
});

if (logData.type === 'report' && logData.section === 'building') {
ws.close()
if (logData.type === "report" && logData.section === "building") {
ws.close();
}
})
});

ws.on('close', () => {
onClose()
})
ws.on("close", () => {
onClose();
});

return () => {
ws.close()
}
}
ws.close();
};
};

export const findCurrentBuildingDeploy = async (client: NetlifyAPI, siteId: string): Promise<string | undefined> => {
const deploys = (await client.listSiteDeploys({ siteId, state: 'building' })) as { id: string }[]
return deploys.length > 0 ? deploys[0].id : undefined
}
const deploys = (await client.listSiteDeploys({ siteId, state: "building" })) as { id: string }[];
return deploys.length > 0 ? deploys[0].id : undefined;
};

export const findLatestReadyDeploy = async (client: NetlifyAPI, siteId: string): Promise<string | undefined> => {
const deploys = (await client.listSiteDeploys({ siteId, state: 'ready', per_page: 1 })) as { id: string }[]
return deploys.length > 0 ? deploys[0].id : undefined
}
const deploys = (await client.listSiteDeploys({ siteId, state: "ready", per_page: 1 })) as { id: string }[];
return deploys.length > 0 ? deploys[0].id : undefined;
};
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
Loading