diff --git a/docs/commands/deploy.md b/docs/commands/deploy.md index 30e3a0cb7a9..6c46c0a58cb 100644 --- a/docs/commands/deploy.md +++ b/docs/commands/deploy.md @@ -30,7 +30,6 @@ netlify deploy - `alias` (*string*) - Specifies the alias for deployment, the string at the beginning of the deploy subdomain. Useful for creating predictable deployment URLs. Avoid setting an alias string to the same value as a deployed branch. `alias` doesn’t create a branch deploy and can’t be used in conjunction with the branch subdomain feature. Maximum 37 characters. - `context` (*string*) - Specify a deploy context for environment variables read during the build ("production", "deploy-preview", "branch-deploy", "dev") or `branch:your-branch` where `your-branch` is the name of a branch (default: dev) -- `create-site` (*string*) - Create a new site and deploy to it. Optionally specify a name, otherwise a random name will be generated. Requires --team flag if you have multiple teams. - `dir` (*string*) - Specify a folder to deploy - `filter` (*string*) - For monorepos, specify the name of the application to run the command in - `functions` (*string*) - Specify a functions folder to deploy @@ -43,8 +42,9 @@ netlify deploy - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `prod` (*boolean*) - Deploy to production - `site` (*string*) - A project name or ID to deploy to +- `site-name` (*string*) - Name for a new site. Implies --create-site if the site does not already exist. - `skip-functions-cache` (*boolean*) - Ignore any functions created as part of a previous `build` or `deploy` commands, forcing them to be bundled again as part of the deployment -- `team` (*string*) - Specify team slug when creating a site. Only works with --create-site flag. +- `team` (*string*) - Specify team slug when creating a site. Only works with --create-site or --site-name flag. - `timeout` (*string*) - Timeout to wait for deployment to finish - `trigger` (*boolean*) - Trigger a new build of your project on Netlify without uploading local files @@ -61,7 +61,7 @@ netlify deploy --message "A message with an $ENV_VAR" netlify deploy --auth $NETLIFY_AUTH_TOKEN netlify deploy --trigger netlify deploy --context deploy-preview -netlify deploy --create-site my-new-site --team my-team # Create site and deploy +netlify deploy --site-name my-new-site --team my-team # Create site and deploy ``` diff --git a/docs/commands/teams.md b/docs/commands/teams.md new file mode 100644 index 00000000000..db255b2e877 --- /dev/null +++ b/docs/commands/teams.md @@ -0,0 +1,63 @@ +--- +title: Netlify CLI teams command +sidebar: + label: teams +description: Manage Netlify teams via the command line +--- + +# `teams` + + +Handle various team operations +The teams command will help you manage your teams + +**Usage** + +```bash +netlify teams +``` + +**Flags** + +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +| Subcommand | description | +|:--------------------------- |:-----| +| [`teams:list`](/commands/teams#teamslist) | List all teams you have access to | + + +**Examples** + +```bash +netlify teams:list +``` + +--- +## `teams:list` + +List all teams you have access to + +**Usage** + +```bash +netlify teams:list +``` + +**Flags** + +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - Output team data as JSON +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify teams:list +netlify teams:list --json +``` + +--- + + diff --git a/docs/index.md b/docs/index.md index a306d1b873d..0eea41227b9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -182,6 +182,15 @@ Print status information Switch your active Netlify account +### [teams](/commands/teams) + +Handle various team operations + +| Subcommand | description | +|:--------------------------- |:-----| +| [`teams:list`](/commands/teams#teamslist) | List all teams you have access to | + + ### [unlink](/commands/unlink) Unlink a local folder from a Netlify project diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index d710b892c96..0f29a3ec258 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -68,7 +68,15 @@ const HELP_SEPARATOR_WIDTH = 5 * Those commands work with the system or are not writing any config files that need to be * workspace aware. */ -const COMMANDS_WITHOUT_WORKSPACE_OPTIONS = new Set(['api', 'recipes', 'completion', 'status', 'switch', 'login']) +const COMMANDS_WITHOUT_WORKSPACE_OPTIONS = new Set([ + 'api', + 'recipes', + 'completion', + 'status', + 'switch', + 'login', + 'teams', +]) /** * A list of commands where we need to fetch featureflags for config resolution diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 5b847a8654d..2503829766e 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto' import { type Stats } from 'fs' import { stat } from 'fs/promises' import { basename, resolve } from 'path' @@ -45,6 +46,7 @@ import { uploadSourceZip } from '../../utils/deploy/upload-source-zip.js' import { getEnvelopeEnv } from '../../utils/env/index.js' import { getFunctionsManifestPath, getInternalFunctionsDir } from '../../utils/functions/index.js' import openBrowser from '../../utils/open-browser.js' +import { isInteractive } from '../../utils/scripted-commands.js' import type BaseCommand from '../base-command.js' import { link } from '../link/link.js' import { sitesCreate } from '../sites/sites-create.js' @@ -292,25 +294,21 @@ const generateDeployCommand = ( ): string => { const parts = ['netlify deploy'] - // Handle site selection/creation first if (options.createSite) { const siteName = typeof options.createSite === 'string' ? options.createSite : '' - parts.push(`--create-site ${siteName}`) + parts.push(`--site-name ${siteName}`) if (availableTeams.length > 1) { parts.push('--team ') } } else if (options.site) { parts.push(`--site ${options.site}`) } else { - parts.push('--create-site ') - if (availableTeams.length > 1) { - parts.push('--team ') - } + parts.push('--site ') } if (command?.options) { for (const option of command.options) { - if (['createSite', 'site', 'team'].includes(option.attributeName())) { + if (['createSite', 'site', 'siteName', 'team'].includes(option.attributeName())) { continue } @@ -352,9 +350,15 @@ const prepareProductionDeploy = async ({ api, siteData, options, command }) => { if (isObject(siteData.published_deploy) && siteData.published_deploy.locked) { log(`\n${NETLIFYDEVERR} Deployments are "locked" for production context of this project\n`) - // Generate copy-pasteable command with current options const overrideCommand = generateDeployCommand({ ...options, prodIfUnlocked: true, prod: false }, [], command) + if (!isInteractive()) { + return logAndThrowError( + `Deployments are "locked" for production context of this project.\n\n` + + `To deploy anyway, use:\n ${overrideCommand}`, + ) + } + log('\nTo override deployment lock (USE WITH CAUTION), use:') log(` ${overrideCommand}`) log('\nWarning: Only use --prod-if-unlocked if you are absolutely sure you want to override the deployment lock.\n') @@ -960,8 +964,17 @@ const prepAndRunDeploy = async ({ return results } +const resolveTeam = ( + accounts: { slug: string; name: string; default?: boolean }[], +): (typeof accounts)[0] | undefined => { + if (accounts.length === 1) { + return accounts[0] + } + return accounts.find((acc) => acc.default) +} + const validateTeamForSiteCreation = ( - accounts: { slug: string; name: string }[], + accounts: { slug: string; name: string; default?: boolean }[], options: DeployOptionValues, siteName?: string, ) => { @@ -969,18 +982,20 @@ const validateTeamForSiteCreation = ( return logAndThrowError('No teams available. Please ensure you have access to at least one team.') } - if (accounts.length === 1) { - options.team = accounts[0].slug + const team = resolveTeam(accounts) + if (team) { + options.team = team.slug const message = siteName ? `Creating new site: ${siteName}` : 'Creating new site with random name' - log(`${message} (using team: ${accounts[0].name})`) + log(`${message} (using team: ${team.name})`) return } - const availableTeams = accounts.map((team) => team.slug).join(', ') + const availableTeams = accounts.map((t) => t.slug).join(', ') return logAndThrowError( `Multiple teams available. Please specify which team to use with --team flag.\n` + `Available teams: ${availableTeams}\n\n` + - `Example: netlify deploy --create-site${siteName ? ` ${siteName}` : ''} --team `, + `Example: netlify deploy --site-name${siteName ? ` ${siteName}` : ' '} --team \n\n` + + `To list teams with full details, run: netlify teams:list`, ) } @@ -1014,12 +1029,27 @@ const createSiteWithFlags = async (options: DeployOptionValues, command: BaseCom site.id = siteData.id return siteData as SiteInfo } catch (error_) { + if ((error_ as APIError).status === 422 && siteName) { + const suffix = randomBytes(4).toString('hex') + const suffixedName = `${siteName.trim()}-${suffix}` + log(`Site name "${siteName}" is taken. Retrying with "${suffixedName}"...`) + try { + const siteData = await api.createSiteInTeam({ + accountSlug: options.team, + body: { name: suffixedName }, + }) + site.id = siteData.id + return siteData as SiteInfo + } catch (retryError) { + return logAndThrowError( + `Failed to create site "${suffixedName}": ${(retryError as APIError).status}: ${ + (retryError as APIError).message + }`, + ) + } + } if ((error_ as APIError).status === 422) { - return logAndThrowError( - siteName - ? `Site name "${siteName}" is already taken. Please try a different name.` - : 'Unable to create site with a random name. Please try again or specify a different name.', - ) + return logAndThrowError('Unable to create site with a random name. Please try again or specify a different name.') } return logAndThrowError(`Failed to create site: ${(error_ as APIError).status}: ${(error_ as APIError).message}`) } @@ -1077,6 +1107,14 @@ const ensureSiteExists = async ( return createSiteWithFlags(options, command, site) } + if (!isInteractive()) { + const { accounts } = command.netlify + options.createSite = true + validateTeamForSiteCreation(accounts, options) + log(`No project linked. Auto-creating a new project (team: ${options.team})...`) + return createSiteWithFlags(options, command, site) + } + return promptForSiteAction(options, command, site) } diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index a7d7fdcd3f5..5bbfe471981 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -82,11 +82,17 @@ For detailed configuration options, see the Netlify documentation.`, false, ) .addOption(new Option('--upload-source-zip', 'Upload source code as a zip file').default(false).hideHelp(true)) + .addOption( + new Option( + '--create-site [name]', + 'Create a new site and deploy to it. Optionally specify a name, otherwise a random name will be generated. Uses your default team if --team is omitted.', + ).hideHelp(true), + ) + .option('--site-name ', 'Name for a new site. Implies --create-site if the site does not already exist.') .option( - '--create-site [name]', - 'Create a new site and deploy to it. Optionally specify a name, otherwise a random name will be generated. Requires --team flag if you have multiple teams.', + '--team ', + 'Specify team slug when creating a site. Only works with --create-site or --site-name flag.', ) - .option('--team ', 'Specify team slug when creating a site. Only works with --create-site flag.') .addExamples([ 'netlify deploy', 'netlify deploy --site my-first-project', @@ -98,7 +104,7 @@ For detailed configuration options, see the Netlify documentation.`, 'netlify deploy --auth $NETLIFY_AUTH_TOKEN', 'netlify deploy --trigger', 'netlify deploy --context deploy-preview', - 'netlify deploy --create-site my-new-site --team my-team # Create site and deploy', + 'netlify deploy --site-name my-new-site --team my-team # Create site and deploy', ]) .addHelpText('after', () => { const docsUrl = 'https://docs.netlify.com/site-deploys/overview/' @@ -123,8 +129,20 @@ For more information about Netlify deploys, see ${terminalLink(docsUrl, docsUrl, return logAndThrowError('--context flag is only available when using the --build flag') } + if (options.siteName) { + if (options.site) { + return logAndThrowError( + 'Cannot specify both --site-name and --site. Use --site to deploy to an existing project.', + ) + } + if (options.createSite && typeof options.createSite === 'string' && options.createSite !== options.siteName) { + return logAndThrowError('Cannot specify both --site-name and --create-site with different names.') + } + options.createSite = options.siteName + } + if (options.team && !options.createSite) { - return logAndThrowError('--team flag can only be used with --create-site flag') + return logAndThrowError('--team flag can only be used with --create-site or --site-name flag') } // Handle Windows + source zip upload diff --git a/src/commands/deploy/option_values.ts b/src/commands/deploy/option_values.ts index 4a9675d82d5..8cd678c77ab 100644 --- a/src/commands/deploy/option_values.ts +++ b/src/commands/deploy/option_values.ts @@ -17,6 +17,7 @@ export type DeployOptionValues = BaseOptionValues & { prod: boolean prodIfUnlocked: boolean site?: string + siteName?: string skipFunctionsCache: boolean team?: string timeout?: number diff --git a/src/commands/main.ts b/src/commands/main.ts index 5b1e95b4ed9..bb86dedfc92 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -46,6 +46,7 @@ import { createServeCommand } from './serve/index.js' import { createSitesCommand } from './sites/index.js' import { createStatusCommand } from './status/index.js' import { createSwitchCommand } from './switch/index.js' +import { createTeamsCommand } from './teams/index.js' import { AddressInUseError } from './types.js' import { createUnlinkCommand } from './unlink/index.js' import { createWatchCommand } from './watch/index.js' @@ -232,6 +233,7 @@ export const createMainCommand = (): BaseCommand => { createSitesCommand(program) createStatusCommand(program) createSwitchCommand(program) + createTeamsCommand(program) createUnlinkCommand(program) createWatchCommand(program) createLogsCommand(program) diff --git a/src/commands/teams/index.ts b/src/commands/teams/index.ts new file mode 100644 index 00000000000..8a3cb73920f --- /dev/null +++ b/src/commands/teams/index.ts @@ -0,0 +1 @@ +export { createTeamsCommand } from './teams.js' diff --git a/src/commands/teams/teams-list.ts b/src/commands/teams/teams-list.ts new file mode 100644 index 00000000000..9c8f5c5df64 --- /dev/null +++ b/src/commands/teams/teams-list.ts @@ -0,0 +1,47 @@ +import type { OptionValues } from 'commander' + +import { chalk, log, logJson } from '../../utils/command-helpers.js' +import type BaseCommand from '../base-command.js' + +export const teamsList = async (options: OptionValues, command: BaseCommand) => { + await command.authenticate(options.auth as string) + + const { accounts } = command.netlify + + if (options.json) { + logJson( + accounts.map((account) => ({ + id: account.id, + name: account.name, + slug: account.slug, + default: account.default, + type_name: account.type_name, + type_slug: account.type_slug, + members_count: account.members_count, + })), + ) + return + } + + if (accounts.length === 0) { + log('No teams found.') + return + } + + log(` +────────────────────────────┐ + Your Netlify Teams │ +────────────────────────────┘ + +Count: ${String(accounts.length)} +`) + + accounts.forEach((account) => { + const defaultLabel = account.default ? chalk.green(' (default)') : '' + log(`${chalk.greenBright(account.name)}${defaultLabel}`) + log(` ${chalk.whiteBright.bold('slug:')} ${chalk.yellowBright(account.slug)}`) + log(` ${chalk.whiteBright.bold('type:')} ${chalk.white(account.type_name)}`) + log(` ${chalk.whiteBright.bold('members:')} ${chalk.white(String(account.members_count))}`) + log(`─────────────────────────────────────────────────`) + }) +} diff --git a/src/commands/teams/teams.ts b/src/commands/teams/teams.ts new file mode 100644 index 00000000000..82e2384f153 --- /dev/null +++ b/src/commands/teams/teams.ts @@ -0,0 +1,25 @@ +import type { OptionValues } from 'commander' + +import type BaseCommand from '../base-command.js' + +const teams = (_options: OptionValues, command: BaseCommand) => { + command.help() +} + +export const createTeamsCommand = (program: BaseCommand) => { + program + .command('teams:list') + .description('List all teams you have access to') + .option('--json', 'Output team data as JSON') + .addExamples(['netlify teams:list', 'netlify teams:list --json']) + .action(async (options: OptionValues, command: BaseCommand) => { + const { teamsList } = await import('./teams-list.js') + await teamsList(options, command) + }) + + return program + .command('teams') + .description(`Handle various team operations\nThe teams command will help you manage your teams`) + .addExamples(['netlify teams:list']) + .action(teams) +} diff --git a/src/utils/scripted-commands.ts b/src/utils/scripted-commands.ts index d9bbf29d6da..84e05a64a43 100644 --- a/src/utils/scripted-commands.ts +++ b/src/utils/scripted-commands.ts @@ -15,6 +15,9 @@ export const shouldForceFlagBeInjected = (argv: string[]): boolean => { return Boolean(scriptedCommand && testingPrompts && noForceFlag) } +export const isInteractive = (): boolean => + Boolean(process.stdin.isTTY && process.stdout.isTTY && !isCI && !process.env.CI) + export const injectForceFlagIfScripted = (argv: string[]) => { if (shouldForceFlagBeInjected(argv)) { argv.push('--force') diff --git a/tests/integration/commands/deploy/deploy-non-interactive.test.ts b/tests/integration/commands/deploy/deploy-non-interactive.test.ts new file mode 100644 index 00000000000..68e964c5da8 --- /dev/null +++ b/tests/integration/commands/deploy/deploy-non-interactive.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, test } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions } from '../../utils/mock-api-vitest.js' +import { startDeployMockApi } from './deploy-api-routes.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import type { Route } from '../../utils/mock-api-vitest.js' +import type express from 'express' + +interface DeployOutput { + site_id: string + deploy_id: string +} + +const siteInfo = { + id: 'site_id', + name: 'test-site', + account_slug: 'test-account', + admin_url: 'https://app.netlify.com/projects/test-site', + ssl_url: 'https://test-site.netlify.app', + url: 'https://test-site.netlify.app', + build_settings: { repo_url: '' }, +} + +const deployResponse = { + id: 'deploy_id', + site_id: 'site_id', + name: 'test-site', + deploy_ssl_url: 'https://deploy-id--test-site.netlify.app', + deploy_url: 'https://deploy-id--test-site.netlify.app', + admin_url: 'https://app.netlify.com/projects/test-site', + ssl_url: 'https://test-site.netlify.app', + url: 'https://test-site.netlify.app', +} + +const createRoutesForSiteCreation = (options?: { failFirstCreate?: boolean }): Route[] => { + let createAttempts = 0 + + return [ + { path: 'sites', method: 'GET', response: [] }, + { path: 'accounts', response: [{ slug: 'test-account', name: 'Test Account', default: true }] }, + { path: 'accounts/test-account/env', response: [] }, + { + path: 'test-account/sites', + method: 'POST', + response: (req: express.Request, res: express.Response) => { + createAttempts++ + if (options?.failFirstCreate && createAttempts === 1) { + res.status(422) + res.json({ message: 'Name already taken' }) + return + } + const body = req.body as { name?: string } + const name = body.name || 'random-generated-name' + res.json({ + ...siteInfo, + name, + ssl_url: `https://${name}.netlify.app`, + }) + }, + }, + { path: 'sites/site_id', response: siteInfo }, + { + path: 'sites/site_id/deploys', + method: 'POST', + response: { ...deployResponse, state: 'prepared', required: [], required_functions: [] }, + }, + { + path: 'sites/site_id/deploys/deploy_id', + method: 'PUT', + response: { ...deployResponse, state: 'prepared', required: [], required_functions: [] }, + }, + { + path: 'sites/site_id/deploys/deploy_id', + method: 'GET', + response: { ...deployResponse, state: 'ready' }, + }, + { path: 'deploys/deploy_id/lock', method: 'POST', response: {} }, + { path: 'deploys/deploy_id/unlock', method: 'POST', response: {} }, + { path: 'deploys/deploy_id/cancel', method: 'POST', response: {} }, + { + path: 'deploys/deploy_id', + method: 'GET', + response: { ...deployResponse, state: 'ready', summary: { messages: [] } }, + }, + ] +} + +const parseDeploy = (output: string): DeployOutput => JSON.parse(output) as DeployOutput + +describe('deploy non-interactive mode', () => { + test('--site-name should create a site and deploy', async (t) => { + const routes = createRoutesForSiteCreation() + const mockApi = await startDeployMockApi({ routes }) + try { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'public/index.html', content: '

Hello

' }) + await builder.build() + + const output = (await callCli( + [ + 'deploy', + '--json', + '--no-build', + '--dir', + 'public', + '--site-name', + 'my-test-site', + '--team', + 'test-account', + ], + getCLIOptions({ apiUrl: mockApi.apiUrl, builder, env: { NETLIFY_SITE_ID: '' } }), + )) as string + + const deploy = parseDeploy(output) + expect(deploy.site_id).toBe('site_id') + expect(deploy.deploy_id).toBe('deploy_id') + + const siteCreateRequests = mockApi.requests.filter((r) => r.method === 'POST' && r.path.endsWith('/sites')) + expect(siteCreateRequests).toHaveLength(1) + const createBody = siteCreateRequests[0].body as { name?: string } + expect(createBody.name).toBe('my-test-site') + }) + } finally { + await mockApi.close() + } + }) + + test('should auto-resolve name collision with suffix', async (t) => { + const routes = createRoutesForSiteCreation({ failFirstCreate: true }) + const mockApi = await startDeployMockApi({ routes }) + try { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'public/index.html', content: '

Hello

' }) + await builder.build() + + const output = (await callCli( + ['deploy', '--json', '--no-build', '--dir', 'public', '--site-name', 'taken-name', '--team', 'test-account'], + getCLIOptions({ apiUrl: mockApi.apiUrl, builder, env: { NETLIFY_SITE_ID: '' } }), + )) as string + + const deploy = parseDeploy(output) + expect(deploy.site_id).toBe('site_id') + + const siteCreateRequests = mockApi.requests.filter((r) => r.method === 'POST' && r.path.endsWith('/sites')) + expect(siteCreateRequests).toHaveLength(2) + const secondBody = siteCreateRequests[1].body as { name?: string } + expect(secondBody.name).toMatch(/^taken-name-[0-9a-f]{8}$/) + }) + } finally { + await mockApi.close() + } + }) + + test('should fail fast with helpful error when non-interactive and multiple teams with no default', async (t) => { + const routes: Route[] = [ + { path: 'sites', method: 'GET', response: [] }, + { + path: 'accounts', + response: [ + { slug: 'team-a', name: 'Team A', default: false }, + { slug: 'team-b', name: 'Team B', default: false }, + ], + }, + { path: 'sites/site_id', response: {} }, + ] + const mockApi = await startDeployMockApi({ routes }) + try { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'public/index.html', content: '

Hello

' }) + await builder.build() + + const rejected = callCli( + ['deploy', '--no-build', '--dir', 'public'], + getCLIOptions({ + apiUrl: mockApi.apiUrl, + builder, + env: { NETLIFY_SITE_ID: '', CI: 'true' }, + }), + ) + await expect(rejected).rejects.toThrow(/--team/) + await expect(rejected).rejects.toThrow(/team-a/) + await expect(rejected).rejects.toThrow(/team-b/) + await expect(rejected).rejects.toThrow(/teams:list/) + + const siteCreateRequests = mockApi.requests.filter((r) => r.method === 'POST' && r.path.endsWith('/sites')) + expect(siteCreateRequests).toHaveLength(0) + }) + } finally { + await mockApi.close() + } + }) + + test('should auto-create site when non-interactive with single team', async (t) => { + const routes = createRoutesForSiteCreation() + const mockApi = await startDeployMockApi({ routes }) + try { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'public/index.html', content: '

Hello

' }) + await builder.build() + + const output = (await callCli( + ['deploy', '--json', '--no-build', '--dir', 'public'], + getCLIOptions({ + apiUrl: mockApi.apiUrl, + builder, + env: { NETLIFY_SITE_ID: '', CI: 'true' }, + }), + )) as string + + const deploy = parseDeploy(output) + expect(deploy.site_id).toBe('site_id') + expect(deploy.deploy_id).toBe('deploy_id') + }) + } finally { + await mockApi.close() + } + }) + + test('should auto-create site when non-interactive with default team among multiple', async (t) => { + const routes: Route[] = [ + { path: 'sites', method: 'GET', response: [] }, + { + path: 'accounts', + response: [ + { slug: 'team-a', name: 'Team A', default: false }, + { slug: 'default-team', name: 'Default Team', default: true }, + ], + }, + { path: 'accounts/default-team/env', response: [] }, + { + path: 'default-team/sites', + method: 'POST', + response: siteInfo, + }, + { path: 'sites/site_id', response: siteInfo }, + { + path: 'sites/site_id/deploys', + method: 'POST', + response: { ...deployResponse, state: 'prepared', required: [], required_functions: [] }, + }, + { + path: 'sites/site_id/deploys/deploy_id', + method: 'PUT', + response: { ...deployResponse, state: 'prepared', required: [], required_functions: [] }, + }, + { + path: 'sites/site_id/deploys/deploy_id', + method: 'GET', + response: { ...deployResponse, state: 'ready' }, + }, + { path: 'deploys/deploy_id/lock', method: 'POST', response: {} }, + { path: 'deploys/deploy_id/unlock', method: 'POST', response: {} }, + { path: 'deploys/deploy_id/cancel', method: 'POST', response: {} }, + { + path: 'deploys/deploy_id', + method: 'GET', + response: { ...deployResponse, state: 'ready', summary: { messages: [] } }, + }, + ] + const mockApi = await startDeployMockApi({ routes }) + try { + await withSiteBuilder(t, async (builder) => { + builder.withContentFile({ path: 'public/index.html', content: '

Hello

' }) + await builder.build() + + const output = (await callCli( + ['deploy', '--json', '--no-build', '--dir', 'public'], + getCLIOptions({ + apiUrl: mockApi.apiUrl, + builder, + env: { NETLIFY_SITE_ID: '', CI: 'true' }, + }), + )) as string + + const deploy = parseDeploy(output) + expect(deploy.site_id).toBe('site_id') + }) + } finally { + await mockApi.close() + } + }) +}) diff --git a/tests/integration/commands/help/__snapshots__/help.test.ts.snap b/tests/integration/commands/help/__snapshots__/help.test.ts.snap index 4422d3af839..76a8c4e66b5 100644 --- a/tests/integration/commands/help/__snapshots__/help.test.ts.snap +++ b/tests/integration/commands/help/__snapshots__/help.test.ts.snap @@ -38,6 +38,7 @@ COMMANDS $ sites Handle various project operations $ status Print status information $ switch Switch your active Netlify account + $ teams Handle various team operations $ unlink Unlink a local folder from a Netlify project $ watch Watch for project deploy to finish diff --git a/tests/integration/commands/teams/teams.test.ts b/tests/integration/commands/teams/teams.test.ts new file mode 100644 index 00000000000..a34cdf3021d --- /dev/null +++ b/tests/integration/commands/teams/teams.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions } from '../../utils/mock-api-vitest.js' +import { startDeployMockApi } from '../deploy/deploy-api-routes.js' +import { withSiteBuilder } from '../../utils/site-builder.js' + +const multipleTeamsRoutes = [ + { + path: 'accounts', + response: [ + { + id: 'account-1', + slug: 'team-alpha', + name: 'Team Alpha', + default: true, + type_name: 'Starter', + type_slug: 'starter', + members_count: 3, + }, + { + id: 'account-2', + slug: 'team-beta', + name: 'Team Beta', + default: false, + type_name: 'Pro', + type_slug: 'pro', + members_count: 7, + }, + ], + }, + { path: 'sites/site_id', response: { id: 'site_id', name: 'test-site' } }, +] + +const singleTeamRoutes = [ + { + path: 'accounts', + response: [ + { + id: 'account-1', + slug: 'only-team', + name: 'Only Team', + default: true, + type_name: 'Starter', + type_slug: 'starter', + members_count: 1, + }, + ], + }, + { path: 'sites/site_id', response: { id: 'site_id', name: 'test-site' } }, +] + +describe('teams:list command', () => { + test('should output JSON with --json flag for multiple teams', async (t) => { + const mockApi = await startDeployMockApi({ routes: multipleTeamsRoutes }) + try { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + const output = (await callCli( + ['teams:list', '--json'], + getCLIOptions({ apiUrl: mockApi.apiUrl, builder }), + )) as string + + const teams = JSON.parse(output) as { slug: string; name: string }[] + expect(teams).toHaveLength(2) + expect(teams[0]).toMatchObject({ slug: 'team-alpha', name: 'Team Alpha' }) + expect(teams[1]).toMatchObject({ slug: 'team-beta', name: 'Team Beta' }) + }) + } finally { + await mockApi.close() + } + }) + + test('should output JSON with --json flag for single team', async (t) => { + const mockApi = await startDeployMockApi({ routes: singleTeamRoutes }) + try { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + const output = (await callCli( + ['teams:list', '--json'], + getCLIOptions({ apiUrl: mockApi.apiUrl, builder }), + )) as string + + const teams = JSON.parse(output) as { slug: string; name: string; members_count: number }[] + expect(teams).toHaveLength(1) + expect(teams[0]).toMatchObject({ slug: 'only-team', name: 'Only Team', members_count: 1 }) + }) + } finally { + await mockApi.close() + } + }) + + test('should display human-readable output without --json', async (t) => { + const mockApi = await startDeployMockApi({ routes: multipleTeamsRoutes }) + try { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + const output = (await callCli(['teams:list'], getCLIOptions({ apiUrl: mockApi.apiUrl, builder }))) as string + + expect(output).toContain('Team Alpha') + expect(output).toContain('Team Beta') + expect(output).toContain('team-alpha') + expect(output).toContain('team-beta') + }) + } finally { + await mockApi.close() + } + }) +}) diff --git a/tests/unit/utils/scripted-commands.test.ts b/tests/unit/utils/scripted-commands.test.ts new file mode 100644 index 00000000000..0b2a5fed09e --- /dev/null +++ b/tests/unit/utils/scripted-commands.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test, vi, afterEach } from 'vitest' + +describe('isInteractive', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.resetModules() + }) + + const loadModule = async () => { + const mod = await import('../../../src/utils/scripted-commands.js') + return mod.isInteractive + } + + test('should return false when CI env var is set', async () => { + const originalCI = process.env.CI + process.env.CI = 'true' + try { + const isInteractive = await loadModule() + expect(isInteractive()).toBe(false) + } finally { + if (originalCI === undefined) { + delete process.env.CI + } else { + process.env.CI = originalCI + } + } + }) + + test('should return false when stdin is not a TTY', async () => { + const originalIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { value: undefined, configurable: true }) + try { + const isInteractive = await loadModule() + expect(isInteractive()).toBe(false) + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }) + } + }) + + test('should return false when stdout is not a TTY', async () => { + const originalIsTTY = process.stdout.isTTY + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, configurable: true }) + try { + const isInteractive = await loadModule() + expect(isInteractive()).toBe(false) + } finally { + Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true }) + } + }) +})