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
2 changes: 1 addition & 1 deletion eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -995,7 +995,7 @@
"count": 1
},
"@typescript-eslint/restrict-template-expressions": {
"count": 3
"count": 2
}
},
"src/utils/headers.ts": {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ export default class BaseCommand extends Command {
return accessToken
}

private async refreshAccounts() {
async refreshAccounts() {
try {
const accounts = (await this.netlify.api.listAccountsForUser()) as MinimalAccount[]
this.netlify.accounts = accounts
Expand Down
97 changes: 90 additions & 7 deletions src/commands/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { OptionValues } from 'commander'
import { confirm, select } from '@inquirer/prompts'
import { isEmpty } from '../../utils/object-utilities.js'

import { chalk, exit, log, netlifyCommand } from '../../utils/command-helpers.js'
import getRepoData from '../../utils/get-repo-data.js'
import { type APIError, chalk, exit, log, logAndThrowError, netlifyCommand, warn } from '../../utils/command-helpers.js'
import getRepoData, { type RepoDataError } from '../../utils/get-repo-data.js'
import { ensureNetlifyIgnore } from '../../utils/gitignore.js'
import { configureRepo } from '../../utils/init/config.js'
import { track } from '../../utils/telemetry/index.js'
Expand Down Expand Up @@ -38,6 +38,87 @@ const logExistingAndExit = ({ siteInfo }: { siteInfo: SiteInfo }): never => {
return exit()
}

const logLinkedWithoutGitAndExit = ({
siteInfo,
repoError,
}: {
siteInfo: SiteInfo
repoError: RepoDataError
}): never => {
if (repoError.reason !== 'no-remote') {
warn(`Could not read the git remote: ${repoError.error}`)
log(`Fix the git remote, then run ${chalk.cyanBright.bold(`${netlifyCommand()} init`)} again to connect it.`)
}
log()
log(`This folder is already linked to the Netlify project ${chalk.cyan(siteInfo.name)}`)
log()
log(`Project Url: ${chalk.cyan(siteInfo.ssl_url || siteInfo.url)}`)
log()
log(`To deploy to this project, run ${chalk.cyanBright.bold(`${netlifyCommand()} deploy`)}.`)
log(
`To link this folder to a different project, run ${chalk.cyanBright.bold(
`${netlifyCommand()} unlink`,
)}, then ${chalk.cyanBright.bold(`${netlifyCommand()} init`)}.`,
)
return exit()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This exits 0 even when the remote couldn't be read, so an agent running init --git-remote-name upstream sees success and moves on without the repo connected.

}

const ignoreLinkedSiteForThisRun = (command: BaseCommand): void => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This rebuilds BaseCommand's site object by hand, so anything added to site there later won't show up here. The id setter isn't called anywhere on these paths either.

const { site } = command.netlify
command.siteId = undefined
command.netlify.siteInfo = {} as SiteInfo
command.netlify.site = {
root: site.root,
configPath: site.configPath,
get id(): string | undefined {
return command.siteId
},
set id(id: string) {
site.id = id
},
}
}

const getLinkedSiteInfo = async (command: BaseCommand): Promise<SiteInfo | undefined> => {
const { api, siteInfo } = command.netlify
if (isEmpty(siteInfo)) {
return undefined
}
if (siteInfo.name) {
return siteInfo
}
try {
return (await api.getSite({ siteId: siteInfo.id })) as unknown as SiteInfo
} catch (error_) {
const { status, message } = error_ as APIError
const unusableLinks: Partial<Record<number, { reason: string; warning: string }>> = {
403: {
reason: 'forbidden',
warning: `You don't have access to the Netlify project ${siteInfo.id} linked to this folder. Continuing without it.`,
},
404: {
reason: 'not_found',
warning: `The Netlify project ${siteInfo.id} linked to this folder no longer exists. Continuing without it.`,
},
}
const unusableLink = unusableLinks[status]
if (unusableLink === undefined) {
return logAndThrowError(
`Could not load the Netlify project ${siteInfo.id} linked to this folder (${
status ? `status ${status.toString()}` : message
}). Try again, or run ${chalk.cyanBright.bold(`${netlifyCommand()} unlink`)} to start over.`,
)
}
warn(unusableLink.warning)
await track('sites_unlinked', { siteId: siteInfo.id, kind: 'init', reason: unusableLink.reason })
ignoreLinkedSiteForThisRun(command)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[consider] When the dead id comes from NETLIFY_SITE_ID, this still says the folder's link is the problem, and picking create saves the new project over whatever state.json had.

LocalState.get('siteId') returns the env var ahead of state.json, so the next run resolves the dead id again and offers to create another one.

if (command.netlify.accounts.length === 0) {
await command.refreshAccounts()
}
return undefined
}
}

/**
* Creates and new site and exits the process
*/
Expand Down Expand Up @@ -245,7 +326,6 @@ export const init = async (
})

const { repositoryRoot, state } = command.netlify
const { siteInfo: existingSiteInfo } = command.netlify

// Check logged in status
await command.authenticate()
Expand All @@ -257,14 +337,17 @@ export const init = async (
await installAgentSkills(command, resetContext)
}

const repoUrl = getRepoUrl(existingSiteInfo)
if (repoUrl && !options.force) {
logExistingAndExit({ siteInfo: existingSiteInfo })
const linkedSiteInfo = await getLinkedSiteInfo(command)
if (linkedSiteInfo && getRepoUrl(linkedSiteInfo) && !options.force) {
logExistingAndExit({ siteInfo: linkedSiteInfo })
}

// Look for local repo
const repoData = await getRepoData({ workingDir: command.workingDir, remoteName: options.gitRemoteName })
if ('error' in repoData) {
if (linkedSiteInfo) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[consider] Every getRepoData error ends up here, not only a missing remote. A --git-remote-name that doesn't exist, or a remote URL that won't parse, gets "already linked, run deploy" with nothing about the remote.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3a23cde. If the remote can't be read, a linked folder now prints the remote error and how to fix it before the "already linked" message. A new test uses --git-remote-name upstream.

I kept this exit rather than main's path, because on main these errors reach the "create without git" prompt, which is how the duplicate happens. This also fixed a no-git folder reporting "no gitconfig to be found": getRepoData now looks for .git before reading the git config.

return logLinkedWithoutGitAndExit({ siteInfo: linkedSiteInfo, repoError: repoData })
}
// TODO(ndhoule): Custom error messaage here
return await handleNoGitRemoteAndExit({
command,
Expand All @@ -275,7 +358,7 @@ export const init = async (
})
}

const siteInfo = isEmpty(existingSiteInfo) ? await createOrLinkSiteToRepo(command) : existingSiteInfo
const siteInfo = linkedSiteInfo ?? (await createOrLinkSiteToRepo(command))

log()

Expand Down
42 changes: 33 additions & 9 deletions src/utils/get-repo-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,40 @@ export interface RepoData {
httpsUrl: string
}

export type RepoDataErrorReason = 'no-remote' | 'unknown-remote' | 'invalid-url' | 'unreadable'

export interface RepoDataError {
error: string
reason: RepoDataErrorReason
}

class RepoDataLookupError extends Error {
constructor(
message: string,
readonly reason: RepoDataErrorReason,
) {
super(message)
}
}

const redactCredentials = (url: string): string => url.replace(/\/\/[^/@]*@/, '//***@')

const getRepoData = async ({
remoteName,
workingDir,
}: {
remoteName?: string
workingDir: string
}): Promise<RepoData | { error: string }> => {
}): Promise<RepoData | RepoDataError> => {
try {
const [gitConfig, gitDirectory] = await Promise.all([
util.promisify(gitconfiglocal)(workingDir),
findUp('.git', { cwd: workingDir, type: 'directory' }),
])
const gitDirectory = await findUp('.git', { cwd: workingDir, type: 'directory' })
if (!gitDirectory) {
throw new RepoDataLookupError('No Git remote found', 'no-remote')
}
const gitConfig = await util.promisify(gitconfiglocal)(workingDir)

if (!gitDirectory || !gitConfig?.remote || Object.keys(gitConfig.remote).length === 0) {
throw new Error('No Git remote found')
if (!gitConfig?.remote || Object.keys(gitConfig.remote).length === 0) {
throw new RepoDataLookupError('No Git remote found', 'no-remote')
}

const baseGitPath = dirname(gitDirectory)
Expand All @@ -51,16 +70,20 @@ const getRepoData = async ({
!gitConfig.remote[remoteName] ||
Object.keys(gitConfig.remote[remoteName]).length === 0
) {
throw new Error(
throw new RepoDataLookupError(
`The specified remote "${remoteName}" is not defined in Git repo. Please use --git-remote-name flag to specify a remote.`,
'unknown-remote',
)
}

const { url } = gitConfig.remote[remoteName]
const parsedUrl = parseGithubUrl(url)
// TODO(serhalp): Validate more aggressively? We should probably require `owner`, `repo`, `host`?
if (parsedUrl == null) {
throw new Error(`The specified Git remote ${remoteName} is not a valid URL: ${url}`)
throw new RepoDataLookupError(
`The specified Git remote ${remoteName} is not a valid URL: ${redactCredentials(String(url))}`,
'invalid-url',
)
}
const { host, name, owner, repo } = parsedUrl
const { branch } = gitRepoInfo()
Expand All @@ -76,6 +99,7 @@ const getRepoData = async ({
} catch (error) {
return {
error: error instanceof Error ? error.message : (error?.toString() ?? 'Failed to get repo data'),
reason: error instanceof RepoDataLookupError ? error.reason : 'unreadable',
}
}
}
Expand Down
Loading
Loading