Skip to content

Commit f66e71a

Browse files
sean-robertsSean Roberts
andauthored
feat: add site search and improve non-interactive linking (#8127)
# Improve `netlify link` and Add `netlify sites:search` for Non-Interactive Environments ## Overview This PR improves the Netlify CLI's behavior in non-interactive environments (CI/CD, scripts, AI agents) by fixing `netlify link` hanging issues and introducing a new `sites:search` command for site discovery. ## Changes ### 1. 🔍 New Command: `netlify sites:search` Added a new command for searching projects by name, making site discovery easier for both users and automation tools. **Usage:** ```bash netlify sites:search my-project netlify sites:search "partial name" --json ``` **Features:** - Searches by full or partial project name - Returns matching projects with ID, URL, repo, and account info - Supports `--json` flag for programmatic use - Works seamlessly in CI/CD environments ### 2. 🔗 Fixed `netlify link` Non-Interactive Behavior Previously, `netlify link` would hang indefinitely when run without options in CI/CD environments. Now it provides helpful guidance instead. **Before:** ```bash # In CI/CD: netlify link # ❌ Hangs trying to prompt user interactively ``` **After:** ```bash # In CI/CD: netlify link # ✅ Error: No project specified. In non-interactive mode, you must specify how to link: # # Link by project ID: # netlify link --id <project-id> # # Link by project name: # netlify link --name <project-name> # # To search for projects: # netlify sites:search <search-term> ``` **Updated error messages to guide users:** - When no site found by name → suggests `sites:search` and `link --id` - When no site found by git remote → suggests `sites:search` and manual linking options - When no options provided in CI/CD → shows all available linking methods ## Example Scenarios ### Scenario 1: AI Agent Linking a Project **Before:** ```bash Agent: netlify link CLI: [hangs waiting for interactive input] Agent: [timeout or confusion] ``` **After:** ```bash Agent: netlify link CLI: Error with helpful guidance showing all options Agent: netlify sites:search my-project --json CLI: [{"id":"site-123","name":"my-project",...}] Agent: netlify link --id site-123 CLI: ✓ Linked to my-project ``` ### Scenario 2: Agent Searching for a Site **Before:** ```bash Agent: [tries to use undocumented API or link --name with guessing] CLI: [may link to wrong site or fail] ``` **After:** ```bash Agent: netlify sites:search "customer-portal" --json CLI: [ {"id":"site-1","name":"customer-portal-staging",...}, {"id":"site-2","name":"customer-portal-prod",...} ] Agent: [can now make informed decision about which site to use] ``` ### Scenario 3: CI/CD Script Needing to Link **Before:** ```bash # CI/CD script without knowing site ID: netlify link # ❌ Hangs indefinitely, pipeline fails ``` **After:** ```bash # CI/CD script can discover and link: SITE_ID=$(netlify sites:search "$PROJECT_NAME" --json | jq -r '.[0].id') netlify link --id "$SITE_ID" # ✅ Successfully linked ``` ## Testing ### New Tests Added - **`sites:search`**: 6 tests covering search, JSON output, empty results, non-interactive mode - **`link`**: 3 tests for non-interactive error handling ### Test Coverage - ✅ All tests passing - ✅ Explicit CI/CD environment tests (`CI=true`, `isTTY=false`) - ✅ Verified no hanging in non-interactive scenarios - ✅ Verified helpful error messages appear correctly ### Documentation - ✅ Generated command documentation for `sites:search` - ✅ Updated help snapshots - ✅ All commands properly documented with examples ## Breaking Changes None. All changes are backward compatible and only add features or improve error handling. ## Related Issues Addresses common issues with: - CLI hanging in CI/CD environments - AI agents unable to discover and link sites programmatically - Unclear guidance when commands fail in non-interactive mode --- **Summary:** This PR makes the Netlify CLI more robust for automated environments by preventing `netlify link` from hanging in CI/CD and providing a new `sites:search` command for programmatic site discovery. --------- Co-authored-by: Sean Roberts <sean.roberts@netlify.com>
1 parent 574c33e commit f66e71a

File tree

7 files changed

+474
-5
lines changed

7 files changed

+474
-5
lines changed

docs/commands/sites.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ netlify sites
2828
| [`sites:create`](/commands/sites#sitescreate) | Create an empty project (advanced) |
2929
| [`sites:delete`](/commands/sites#sitesdelete) | Delete a project |
3030
| [`sites:list`](/commands/sites#siteslist) | List all projects you have access to |
31+
| [`sites:search`](/commands/sites#sitessearch) | Search for projects by name |
3132

3233

3334
**Examples**
@@ -109,6 +110,35 @@ netlify sites:list
109110
- `debug` (*boolean*) - Print debugging information
110111
- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in
111112

113+
---
114+
## `sites:search`
115+
116+
Search for projects by name
117+
118+
**Usage**
119+
120+
```bash
121+
netlify sites:search
122+
```
123+
124+
**Arguments**
125+
126+
- search-term - Full or partial project name to search for
127+
128+
**Flags**
129+
130+
- `filter` (*string*) - For monorepos, specify the name of the application to run the command in
131+
- `json` (*boolean*) - Output project data as JSON
132+
- `debug` (*boolean*) - Print debugging information
133+
- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in
134+
135+
**Examples**
136+
137+
```bash
138+
netlify sites:search my-project
139+
netlify sites:search "partial name" --json
140+
```
141+
112142
---
113143

114144
<!-- AUTO-GENERATED-CONTENT:END -->

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ Handle various project operations
175175
| [`sites:create`](/commands/sites#sitescreate) | Create an empty project (advanced) |
176176
| [`sites:delete`](/commands/sites#sitesdelete) | Delete a project |
177177
| [`sites:list`](/commands/sites#siteslist) | List all projects you have access to |
178+
| [`sites:search`](/commands/sites#sitessearch) | Search for projects by name |
178179

179180

180181
### [status](/commands/status)

src/commands/link/link.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { startSpinner } from '../../lib/spinner.js'
99
import { chalk, logAndThrowError, exit, log, APIError, netlifyCommand } from '../../utils/command-helpers.js'
1010
import { ensureNetlifyIgnore } from '../../utils/gitignore.js'
1111
import getRepoData from '../../utils/get-repo-data.js'
12+
import { isInteractive } from '../../utils/scripted-commands.js'
1213
import { track } from '../../utils/telemetry/index.js'
1314
import type { SiteInfo } from '../../utils/types.js'
1415
import BaseCommand from '../base-command.js'
@@ -39,7 +40,14 @@ const findSiteByRepoUrl = async (api: NetlifyAPI, repoUrl: string): Promise<Site
3940
4041
Double check you are in the correct working directory and a remote origin repo is configured.
4142
42-
Run ${chalk.cyanBright('git remote -v')} to see a list of your git remotes.`)
43+
Run ${chalk.cyanBright('git remote -v')} to see a list of your git remotes.
44+
45+
To link manually:
46+
${chalk.cyanBright(`${netlifyCommand()} link --id <project-id>`)}
47+
${chalk.cyanBright(`${netlifyCommand()} link --name <project-name>`)}
48+
49+
To search for projects:
50+
${chalk.cyanBright(`${netlifyCommand()} sites:search <search-term>`)}`)
4351

4452
return exit(1)
4553
}
@@ -144,8 +152,14 @@ const linkPrompt = async (command: BaseCommand, options: LinkOptionValues): Prom
144152
if (!matchingSites || matchingSites.length === 0) {
145153
return logAndThrowError(`No project names found containing '${searchTerm}'.
146154
147-
Run ${chalk.cyanBright(`${netlifyCommand()} link`)} again to try a new search,
148-
or run ${chalk.cyanBright(`npx ${netlifyCommand()} sites:create`)} to create a project.`)
155+
To search for projects:
156+
${chalk.cyanBright(`${netlifyCommand()} sites:search <search-term>`)}
157+
158+
To link by project ID:
159+
${chalk.cyanBright(`${netlifyCommand()} link --id <project-id>`)}
160+
161+
To create a new project:
162+
${chalk.cyanBright(`${netlifyCommand()} sites:create`)}`)
149163
}
150164

151165
if (matchingSites.length > 1) {
@@ -326,7 +340,13 @@ export const link = async (options: LinkOptionValues, command: BaseCommand) => {
326340
}
327341

328342
if (results.length === 0) {
329-
return logAndThrowError(new Error(`No projects found named ${options.name}`))
343+
return logAndThrowError(`No projects found named ${options.name}.
344+
345+
To search for projects:
346+
${chalk.cyanBright(`${netlifyCommand()} sites:search ${options.name}`)}
347+
348+
To link by project ID:
349+
${chalk.cyanBright(`${netlifyCommand()} link --id <project-id>`)}`)
330350
}
331351

332352
const matchingSiteData = results.find((site: SiteInfo) => site.name === options.name) || results[0]
@@ -350,6 +370,25 @@ export const link = async (options: LinkOptionValues, command: BaseCommand) => {
350370
kind: 'byRepoUrl',
351371
})
352372
} else {
373+
if (!isInteractive()) {
374+
return logAndThrowError(`No project specified. In non-interactive mode, you must specify how to link:
375+
376+
Link by project ID:
377+
${chalk.cyanBright(`${netlifyCommand()} link --id <project-id>`)}
378+
379+
Link by project name:
380+
${chalk.cyanBright(`${netlifyCommand()} link --name <project-name>`)}
381+
382+
Link by git remote URL:
383+
${chalk.cyanBright(`${netlifyCommand()} link --gitRemoteUrl <url>`)}
384+
385+
To search for projects:
386+
${chalk.cyanBright(`${netlifyCommand()} sites:search <search-term>`)}
387+
388+
To list all projects:
389+
${chalk.cyanBright(`${netlifyCommand()} sites:list`)}`)
390+
}
391+
353392
newSiteData = await linkPrompt(command, options)
354393
}
355394
// FIXME(serhalp): All the cases above except one (look up by site name) end up *returning*

src/commands/sites/sites-search.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { OptionValues } from 'commander'
2+
3+
import { listSites } from '../../lib/api.js'
4+
import { startSpinner, stopSpinner } from '../../lib/spinner.js'
5+
import { chalk, log, logJson } from '../../utils/command-helpers.js'
6+
import BaseCommand from '../base-command.js'
7+
8+
export const sitesSearch = async (searchTerm: string, options: OptionValues, command: BaseCommand) => {
9+
const { api } = command.netlify
10+
11+
await command.authenticate()
12+
13+
let spinner
14+
if (!options.json) {
15+
spinner = startSpinner({ text: `Searching for projects matching '${searchTerm}'` })
16+
}
17+
18+
const sites = await listSites({ api, options: { name: searchTerm, filter: 'all' } })
19+
20+
if (spinner) {
21+
stopSpinner({ spinner })
22+
}
23+
24+
if (sites.length === 0) {
25+
if (options.json) {
26+
logJson([])
27+
return
28+
}
29+
30+
log()
31+
log(chalk.yellow(`No projects found matching '${searchTerm}'`))
32+
log()
33+
return
34+
}
35+
36+
if (options.json) {
37+
const redactedSites = sites.map((site) => {
38+
if (site.build_settings?.env) {
39+
delete site.build_settings.env
40+
}
41+
return site
42+
})
43+
logJson(redactedSites)
44+
return
45+
}
46+
47+
log()
48+
log(`Found ${chalk.greenBright(sites.length)} project${sites.length === 1 ? '' : 's'} matching '${searchTerm}':`)
49+
log()
50+
51+
sites.forEach((site) => {
52+
log(`${chalk.greenBright(site.name)} - ${chalk.dim(site.id)}`)
53+
log(` ${chalk.whiteBright.bold('url:')} ${chalk.yellowBright(site.ssl_url)}`)
54+
if (site.build_settings?.repo_url) {
55+
log(` ${chalk.whiteBright.bold('repo:')} ${chalk.white(site.build_settings.repo_url)}`)
56+
}
57+
if (site.account_name) {
58+
log(` ${chalk.whiteBright.bold('account:')} ${chalk.white(site.account_name)}`)
59+
}
60+
log(`─────────────────────────────────────────────────`)
61+
})
62+
}

src/commands/sites/sites.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ export const createSitesCommand = (program: BaseCommand) => {
4747
await sitesList(options, command)
4848
})
4949

50+
program
51+
.command('sites:search')
52+
.description('Search for projects by name')
53+
.argument('<search-term>', 'Full or partial project name to search for')
54+
.option('--json', 'Output project data as JSON')
55+
.addExamples(['netlify sites:search my-project', 'netlify sites:search "partial name" --json'])
56+
.action(async (searchTerm: string, options: OptionValues, command: BaseCommand) => {
57+
const { sitesSearch } = await import('./sites-search.js')
58+
await sitesSearch(searchTerm, options, command)
59+
})
60+
5061
program
5162
.command('sites:delete')
5263
.description('Delete a project\nThis command will permanently delete the project on Netlify. Use with caution.')

tests/integration/commands/link/link.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,90 @@ describe('link command with multiple projects', () => {
150150
})
151151
})
152152
})
153+
154+
describe('link command non-interactive mode', () => {
155+
test('should error with helpful message when no options provided in non-interactive mode', async (t) => {
156+
await withSiteBuilder(t, async (builder) => {
157+
await builder.build()
158+
159+
await withMockApi(
160+
[],
161+
async ({ apiUrl }) => {
162+
try {
163+
await callCli(['link'], {
164+
...getCLIOptions({ builder, apiUrl, env: { NETLIFY_SITE_ID: '', CI: 'true' } }),
165+
})
166+
expect.fail('Should have thrown an error')
167+
} catch (error_) {
168+
const errorMessage = (error_ as Error).message
169+
expect(errorMessage).toContain('No project specified')
170+
expect(errorMessage).toContain('link --id')
171+
expect(errorMessage).toContain('link --name')
172+
expect(errorMessage).toContain('sites:search')
173+
}
174+
},
175+
true,
176+
)
177+
})
178+
})
179+
180+
test('should error with helpful message when site not found by name', async (t) => {
181+
const routes = [
182+
{
183+
path: 'sites',
184+
response: [],
185+
},
186+
]
187+
188+
await withSiteBuilder(t, async (builder) => {
189+
await builder.build()
190+
191+
await withMockApi(
192+
routes,
193+
async ({ apiUrl }) => {
194+
await expect(
195+
callCli(
196+
['link', '--name', 'nonexistent-site'],
197+
getCLIOptions({ builder, apiUrl, env: { NETLIFY_SITE_ID: '' } }),
198+
),
199+
).rejects.toThrow(/No projects found|sites:search/)
200+
},
201+
true,
202+
)
203+
})
204+
})
205+
206+
test('should error with helpful message when site not found by git remote', async (t) => {
207+
const routes = [
208+
{
209+
path: 'sites',
210+
response: [
211+
{
212+
id: 'different-site-id',
213+
name: 'different-site',
214+
build_settings: {
215+
repo_url: 'https://github.com/other/repo',
216+
},
217+
},
218+
],
219+
},
220+
]
221+
222+
await withSiteBuilder(t, async (builder) => {
223+
await builder.withGit().build()
224+
225+
await withMockApi(
226+
routes,
227+
async ({ apiUrl }) => {
228+
await expect(
229+
callCli(
230+
['link', '--git-remote-url', 'https://github.com/test/repo'],
231+
getCLIOptions({ builder, apiUrl, env: { NETLIFY_SITE_ID: '' } }),
232+
),
233+
).rejects.toThrow(/No matching project found|sites:search/)
234+
},
235+
true,
236+
)
237+
})
238+
})
239+
})

0 commit comments

Comments
 (0)