Skip to content

Commit 6bfc767

Browse files
sean-robertsSean Roberts
andauthored
fix: improve create sites and login directions (#8126)
๐ŸŽ‰ Thanks for submitting a pull request! ๐ŸŽ‰ #### Summary Right now, if there is a new site name conflict it always forces the user to an interactive prompt. With this change we are going to be more consistent with other commands that, if site names have conflicts, it will try with a suffix appended to the name. Also, adding a little more direction for agents to handle login requests --- For us to review and ship your PR efficiently, please perform the following steps: - [ ] Open a [bug/issue](https://github.com/netlify/cli/issues/new/choose) before writing your code ๐Ÿง‘โ€๐Ÿ’ป. This ensures we can discuss the changes and get feedback from everyone that should be involved. If you\`re fixing a typo or something that\`s on fire ๐Ÿ”ฅ (e.g. incident related), you can skip this step. - [ ] Read the [contribution guidelines](../CONTRIBUTING.md) ๐Ÿ“–. This ensures your code follows our style guide and passes our tests. - [ ] Update or add tests (if any source code was changed or added) ๐Ÿงช - [ ] Update or add documentation (if features were changed or added) ๐Ÿ“ - [ ] Make sure the status checks below are successful โœ… **A picture of a cute animal (not mandatory, but encouraged)** --------- Co-authored-by: Sean Roberts <sean.roberts@netlify.com>
1 parent 5d18836 commit 6bfc767

File tree

5 files changed

+193
-15
lines changed

5 files changed

+193
-15
lines changed

โ€Žsrc/commands/login/login-request.tsโ€Ž

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ export const loginRequest = async (message: string, apiOpts: NetlifyOptions['api
2222
url,
2323
check_command: `netlify login --check ${ticketId}`,
2424
agent_next_steps:
25-
'Poll the check_command for up to ten minutes to see if the user has logged in, or wait for them to tell you and then use check_command after.',
25+
'Give the URL to the user so they can authorize. Then poll the check_command for up to ten minutes to see if the user has logged in, or wait for them to tell you and then use check_command after.',
2626
})
2727

2828
log(`Ticket ID: ${ticketId}`)
2929
log(`Authorize URL: ${url}`)
3030
log()
3131
log(`After authorizing, run: netlify login --check ${ticketId}`)
32+
log()
33+
log('After user opens the authorization URL and approves, the login will be complete.')
3234
}

โ€Žsrc/commands/sites/sites-create.tsโ€Ž

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { isInteractive } from '../../utils/scripted-commands.js'
1010
import { resolveTeamForNonInteractive } from '../../utils/team.js'
1111
import { track } from '../../utils/telemetry/index.js'
1212
import type { SiteInfo } from '../../utils/types.js'
13+
import { MAX_SITE_NAME_LENGTH } from '../../utils/validation.js'
1314
import type BaseCommand from '../base-command.js'
1415
import { link } from '../link/link.js'
1516

@@ -64,32 +65,77 @@ export const sitesCreate = async (options: OptionValues, command: BaseCommand) =
6465

6566
let site!: SiteInfo
6667

67-
// Allow the user to reenter site name if selected one isn't available
68-
const inputSiteName = async (name?: string) => {
69-
const { name: siteName } = await getSiteNameInput(name)
68+
const MAX_NAME_RETRIES = 2
7069

70+
const tryCreateSiteInteractive = async (nameToTry: string | undefined): Promise<void> => {
71+
const { name: attemptName } = await getSiteNameInput(nameToTry)
7172
const body: { name?: string } = {}
72-
if (typeof siteName === 'string') {
73-
body.name = siteName.trim()
73+
if (attemptName.trim()) {
74+
body.name = attemptName.trim()
7475
}
76+
7577
try {
76-
// FIXME(serhalp): `id` and `name` should be required in `netlify` package type
7778
site = (await api.createSiteInTeam({
7879
accountSlug: accountSlug,
7980
body,
8081
})) as unknown as SiteInfo
8182
} catch (error_) {
8283
if ((error_ as APIError).status === 422) {
83-
warn(`${siteName}.netlify.app already exists. Please try a different slug.`)
84-
await inputSiteName()
85-
} else {
86-
return logAndThrowError(
87-
`createSiteInTeam error: ${(error_ as APIError).status}: ${(error_ as APIError).message}`,
88-
)
84+
warn(`${attemptName}.netlify.app already exists. Please try a different slug.`)
85+
return tryCreateSiteInteractive(undefined)
8986
}
87+
88+
return logAndThrowError(`createSiteInTeam error: ${(error_ as APIError).status}: ${(error_ as APIError).message}`)
9089
}
9190
}
9291

92+
const createSiteWithRetry = async (siteName: string | undefined) => {
93+
if (!isInteractive() && siteName) {
94+
for (let attempt = 0; attempt <= MAX_NAME_RETRIES; attempt++) {
95+
let nameToTry = siteName
96+
97+
if (attempt > 0) {
98+
const suffix = `-${Math.floor(Math.random() * 900 + 100).toString()}`
99+
const normalizedBase = siteName.trim()
100+
const maxBaseLength = MAX_SITE_NAME_LENGTH - suffix.length
101+
const truncatedBase = normalizedBase.slice(0, maxBaseLength)
102+
nameToTry = `${truncatedBase}${suffix}`
103+
warn(`${siteName}.netlify.app already exists. Trying ${nameToTry}...`)
104+
}
105+
106+
const body: { name?: string } = {}
107+
if (nameToTry.trim()) {
108+
body.name = nameToTry.trim()
109+
}
110+
111+
try {
112+
site = (await api.createSiteInTeam({
113+
accountSlug: accountSlug,
114+
body,
115+
})) as unknown as SiteInfo
116+
return
117+
} catch (error_) {
118+
if ((error_ as APIError).status === 422) {
119+
if (attempt === MAX_NAME_RETRIES) {
120+
return logAndThrowError(`Project name "${nameToTry}" is already taken. Please try a different name.`)
121+
}
122+
continue
123+
}
124+
125+
return logAndThrowError(
126+
`createSiteInTeam error: ${(error_ as APIError).status}: ${(error_ as APIError).message}`,
127+
)
128+
}
129+
}
130+
}
131+
132+
if (isInteractive()) {
133+
return tryCreateSiteInteractive(siteName)
134+
}
135+
136+
return logAndThrowError('Failed to create site: name already taken')
137+
}
138+
93139
if (!isInteractive() && !options.name) {
94140
try {
95141
site = (await api.createSiteInTeam({
@@ -99,8 +145,11 @@ export const sitesCreate = async (options: OptionValues, command: BaseCommand) =
99145
} catch (error_) {
100146
return logAndThrowError(`Failed to create site: ${(error_ as APIError).status}: ${(error_ as APIError).message}`)
101147
}
148+
} else if (isInteractive() && !options.name) {
149+
const { name: siteName } = await getSiteNameInput(options.name)
150+
await createSiteWithRetry(siteName)
102151
} else {
103-
await inputSiteName(options.name)
152+
await createSiteWithRetry(options.name)
104153
}
105154

106155
log()

โ€Žsrc/utils/validation.tsโ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { InvalidArgumentError } from 'commander'
22

33
import { BANG, chalk } from './command-helpers.js'
44

5-
const MAX_SITE_NAME_LENGTH = 63
5+
export const MAX_SITE_NAME_LENGTH = 63
66

77
export const validateSiteName = (value: string): string => {
88
if (value.length > MAX_SITE_NAME_LENGTH) {

โ€Žtests/integration/commands/sites/sites.test.tsโ€Ž

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ const routes = [
4545
]
4646

4747
const OLD_ENV = process.env
48+
const OLD_STDIN_TTY = process.stdin.isTTY
49+
const OLD_STDOUT_TTY = process.stdout.isTTY
4850

4951
describe('sites command', () => {
5052
beforeEach(() => {
@@ -61,6 +63,8 @@ describe('sites command', () => {
6163
Object.defineProperty(process, 'env', {
6264
value: OLD_ENV,
6365
})
66+
process.stdin.isTTY = OLD_STDIN_TTY
67+
process.stdout.isTTY = OLD_STDOUT_TTY
6468
})
6569

6670
describe('sites:create', () => {
@@ -113,5 +117,127 @@ describe('sites command', () => {
113117
logJsonSpy.mockRestore()
114118
})
115119
})
120+
121+
test('should fail after max retries in non-interactive mode', async () => {
122+
const routesWithPersistentConflict = [
123+
{
124+
path: 'accounts',
125+
response: [{ slug: 'test-account' }],
126+
},
127+
{
128+
path: 'sites',
129+
response: [],
130+
},
131+
{
132+
path: 'user',
133+
response: { name: 'test user', slug: 'test-user', email: 'user@test.com' },
134+
},
135+
{
136+
path: 'test-account/sites',
137+
method: 'POST' as const,
138+
status: 422,
139+
response: { message: 'site name already exists' },
140+
},
141+
]
142+
143+
await withMockApi(routesWithPersistentConflict, async ({ apiUrl, requests }) => {
144+
Object.assign(process.env, getEnvironmentVariables({ apiUrl }))
145+
process.env.CI = 'true'
146+
process.stdin.isTTY = false
147+
process.stdout.isTTY = false
148+
149+
const program = new BaseCommand('netlify')
150+
program.exitOverride()
151+
createSitesCreateCommand(program)
152+
153+
const warnSpy = vi.spyOn(await import('../../../../src/utils/command-helpers.js'), 'warn')
154+
155+
await expect(async () => {
156+
await program.parseAsync([
157+
'',
158+
'',
159+
'sites:create',
160+
'--name',
161+
'taken-site',
162+
'--account-slug',
163+
'test-account',
164+
'--disable-linking',
165+
])
166+
}).rejects.toThrowError(/already taken/)
167+
168+
const siteCreateRequests = requests.filter(
169+
(r) => r.path === '/api/v1/test-account/sites' && r.method === 'POST',
170+
)
171+
expect(siteCreateRequests).toHaveLength(3)
172+
173+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('taken-site.netlify.app already exists'))
174+
expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/Trying taken-site-\d{3}\.\.\./))
175+
176+
warnSpy.mockRestore()
177+
})
178+
})
179+
180+
test('should truncate long site names when retrying to avoid exceeding max length', async () => {
181+
const routesWithPersistentConflict = [
182+
{
183+
path: 'accounts',
184+
response: [{ slug: 'test-account' }],
185+
},
186+
{
187+
path: 'sites',
188+
response: [],
189+
},
190+
{
191+
path: 'user',
192+
response: { name: 'test user', slug: 'test-user', email: 'user@test.com' },
193+
},
194+
{
195+
path: 'test-account/sites',
196+
method: 'POST' as const,
197+
status: 422,
198+
response: { message: 'site name already exists' },
199+
},
200+
]
201+
202+
await withMockApi(routesWithPersistentConflict, async ({ apiUrl, requests }) => {
203+
Object.assign(process.env, getEnvironmentVariables({ apiUrl }))
204+
process.env.CI = 'true'
205+
process.stdin.isTTY = false
206+
process.stdout.isTTY = false
207+
208+
const program = new BaseCommand('netlify')
209+
program.exitOverride()
210+
createSitesCreateCommand(program)
211+
212+
const longName = 'a'.repeat(60)
213+
214+
await expect(async () => {
215+
await program.parseAsync([
216+
'',
217+
'',
218+
'sites:create',
219+
'--name',
220+
longName,
221+
'--account-slug',
222+
'test-account',
223+
'--disable-linking',
224+
])
225+
}).rejects.toThrowError(/already taken/)
226+
227+
const siteCreateRequests = requests.filter(
228+
(r) => r.path === '/api/v1/test-account/sites' && r.method === 'POST',
229+
)
230+
231+
expect(siteCreateRequests).toHaveLength(3)
232+
233+
siteCreateRequests.slice(1).forEach((request) => {
234+
const bodyName = (request.body as { name?: string }).name
235+
expect(bodyName).toBeDefined()
236+
if (bodyName) {
237+
expect(bodyName.length).toBeLessThanOrEqual(63)
238+
}
239+
})
240+
})
241+
})
116242
})
117243
})

โ€Žtests/unit/commands/login/login-request.test.tsโ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ describe('loginRequest', () => {
4141
'Authorize URL: https://app.netlify.com/authorize?response_type=ticket&ticket=test-ticket-123',
4242
)
4343
expect(output).toContain('netlify login --check test-ticket-123')
44+
expect(output).toContain('After user opens the authorization URL and approves, the login will be complete.')
4445
})
4546

4647
test('passes message to createTicket', async () => {

0 commit comments

Comments
ย (0)
โšก