diff --git a/.changeset/unarchive-feature-flags.md b/.changeset/unarchive-feature-flags.md new file mode 100644 index 000000000000..04c49ccef9ba --- /dev/null +++ b/.changeset/unarchive-feature-flags.md @@ -0,0 +1,11 @@ +--- +'vercel': patch +--- + +Add a `vercel flags unarchive` command for unarchiving feature flags. + +Examples: + +- `vercel flags unarchive my-feature-flag` +- `vercel flags unarchive my-feature-flag --yes` +- `vercel flags unarchive my-feature-flag --project my-project --yes` diff --git a/packages/cli/src/commands/flags/archive.ts b/packages/cli/src/commands/flags/archive.ts index 30789e97f6fd..9d71b867d634 100644 --- a/packages/cli/src/commands/flags/archive.ts +++ b/packages/cli/src/commands/flags/archive.ts @@ -6,7 +6,6 @@ import { printError } from '../../util/error'; import { getCommandName } from '../../util/pkg-name'; import { getFlag } from '../../util/flags/get-flags'; import { updateFlag } from '../../util/flags/update-flag'; -import { getFlagsDashboardUrl } from '../../util/flags/dashboard-url'; import output from '../../output-manager'; import { FlagsArchiveTelemetryClient } from '../../util/telemetry/commands/flags/archive'; import { archiveSubcommand } from './command'; @@ -102,7 +101,7 @@ export default async function archive( output.success(`Feature flag ${chalk.bold(flag.slug)} has been archived`); output.log( - `\nTo restore this flag, visit the dashboard: ${chalk.cyan(getFlagsDashboardUrl(link.org.slug, project.name) + '/archive')}` + `\nTo unarchive this flag, run ${getCommandName(`flags unarchive ${flag.slug}`)}` ); } catch (err) { output.stopSpinner(); diff --git a/packages/cli/src/commands/flags/command.ts b/packages/cli/src/commands/flags/command.ts index 9fea84dd5713..33b52a12ac98 100644 --- a/packages/cli/src/commands/flags/command.ts +++ b/packages/cli/src/commands/flags/command.ts @@ -1085,6 +1085,35 @@ export const archiveSubcommand = { ], } as const; +export const unarchiveSubcommand = { + name: 'unarchive', + aliases: [], + description: 'Unarchive a feature flag', + arguments: [ + { + name: 'flag', + required: true, + }, + ], + options: [ + projectOption, + { + ...yesOption, + description: 'Skip the confirmation prompt when unarchiving a flag', + }, + ], + examples: [ + { + name: 'Unarchive a feature flag', + value: `${packageName} flags unarchive my-feature-flag`, + }, + { + name: 'Unarchive without confirmation', + value: `${packageName} flags unarchive my-feature-flag --yes`, + }, + ], +} as const; + export const disableSubcommand = { name: 'disable', aliases: [], @@ -1638,6 +1667,7 @@ export const flagsCommand = { rolloutSubcommand, removeSubcommand, archiveSubcommand, + unarchiveSubcommand, disableSubcommand, enableSubcommand, rulesSubcommand, diff --git a/packages/cli/src/commands/flags/index.ts b/packages/cli/src/commands/flags/index.ts index 858f08e37e79..deea49edfafa 100644 --- a/packages/cli/src/commands/flags/index.ts +++ b/packages/cli/src/commands/flags/index.ts @@ -19,6 +19,7 @@ import split from './split'; import rollout from './rollout'; import rm from './rm'; import archive from './archive'; +import unarchive from './unarchive'; import disable from './disable'; import enable from './enable'; import { sdkKeys } from './sdk-keys'; @@ -38,6 +39,7 @@ import { rolloutSubcommand, removeSubcommand, archiveSubcommand, + unarchiveSubcommand, disableSubcommand, prepareSubcommand, enableSubcommand, @@ -62,6 +64,7 @@ const COMMAND_CONFIG = { rollout: getCommandAliases(rolloutSubcommand), rm: getCommandAliases(removeSubcommand), archive: getCommandAliases(archiveSubcommand), + unarchive: getCommandAliases(unarchiveSubcommand), disable: getCommandAliases(disableSubcommand), enable: getCommandAliases(enableSubcommand), rules: getCommandAliases(rulesSubcommand), @@ -201,6 +204,14 @@ export default async function main(client: Client) { } telemetry.trackCliSubcommandArchive(subcommandOriginal); return archive(client, args); + case 'unarchive': + if (needHelp) { + telemetry.trackCliFlagHelp('flags', subcommandOriginal); + printHelp(unarchiveSubcommand); + return 2; + } + telemetry.trackCliSubcommandUnarchive(subcommandOriginal); + return unarchive(client, args); case 'disable': if (needHelp) { telemetry.trackCliFlagHelp('flags', subcommandOriginal); diff --git a/packages/cli/src/commands/flags/unarchive.ts b/packages/cli/src/commands/flags/unarchive.ts new file mode 100644 index 000000000000..d1ba7017c705 --- /dev/null +++ b/packages/cli/src/commands/flags/unarchive.ts @@ -0,0 +1,107 @@ +import chalk from 'chalk'; +import type Client from '../../util/client'; +import { parseArguments } from '../../util/get-args'; +import { getFlagsSpecification } from '../../util/get-flags-specification'; +import { printError } from '../../util/error'; +import { getCommandName } from '../../util/pkg-name'; +import { getFlag } from '../../util/flags/get-flags'; +import { updateFlag } from '../../util/flags/update-flag'; +import output from '../../output-manager'; +import { FlagsUnarchiveTelemetryClient } from '../../util/telemetry/commands/flags/unarchive'; +import { unarchiveSubcommand } from './command'; +import { getLinkedFlagsProject, getProjectNameFromFlags } from './project'; + +export default async function unarchive( + client: Client, + argv: string[] +): Promise { + const telemetryClient = new FlagsUnarchiveTelemetryClient({ + opts: { + store: client.telemetryEventStore, + }, + }); + + let parsedArgs; + const flagsSpecification = getFlagsSpecification(unarchiveSubcommand.options); + try { + parsedArgs = parseArguments(argv, flagsSpecification); + } catch (err) { + printError(err); + return 1; + } + + const { args, flags } = parsedArgs; + const [flagArg] = args; + const skipConfirmation = flags['--yes'] as boolean | undefined; + const projectName = getProjectNameFromFlags(flags); + + if (!flagArg) { + output.error('Please provide a flag slug or ID to unarchive'); + output.log(`Example: ${getCommandName('flags unarchive my-feature')}`); + return 1; + } + + telemetryClient.trackCliArgumentFlag(flagArg); + telemetryClient.trackCliOptionProject(projectName); + telemetryClient.trackCliFlagYes(skipConfirmation); + + const link = await getLinkedFlagsProject(client, projectName); + if (link.status === 'error') { + return link.exitCode; + } else if (link.status === 'not_linked') { + output.error( + `Your codebase isn't linked to a project on Vercel. Pass --project , or run ${getCommandName('link')} to link it.` + ); + return 1; + } + + client.config.currentTeam = + link.org.type === 'team' ? link.org.id : undefined; + + const { project } = link; + + try { + output.spinner('Fetching flag...'); + const flag = await getFlag(client, project.id, flagArg); + output.stopSpinner(); + + if (flag.state === 'active') { + output.warn(`Flag ${chalk.bold(flag.slug)} is already active`); + return 0; + } + + if (!skipConfirmation) { + if (!client.stdin.isTTY) { + output.error( + 'Missing required flag --yes. Use --yes to skip the confirmation prompt in non-interactive mode.' + ); + return 1; + } + + const confirmed = await client.input.confirm( + `Are you sure you want to unarchive ${chalk.bold(flag.slug)}?`, + false + ); + + if (!confirmed) { + output.log('Aborted'); + return 0; + } + } + + output.spinner('Unarchiving flag...'); + await updateFlag(client, project.id, flagArg, { + state: 'active', + message: 'Unarchive', + }); + output.stopSpinner(); + + output.success(`Feature flag ${chalk.bold(flag.slug)} has been unarchived`); + } catch (err) { + output.stopSpinner(); + printError(err); + return 1; + } + + return 0; +} diff --git a/packages/cli/src/util/telemetry/commands/flags/index.ts b/packages/cli/src/util/telemetry/commands/flags/index.ts index e857390ca8f9..e76808209303 100644 --- a/packages/cli/src/util/telemetry/commands/flags/index.ts +++ b/packages/cli/src/util/telemetry/commands/flags/index.ts @@ -90,6 +90,13 @@ export class FlagsTelemetryClient }); } + trackCliSubcommandUnarchive(actual: string) { + this.trackCliSubcommand({ + subcommand: 'unarchive', + value: actual, + }); + } + trackCliSubcommandDisable(actual: string) { this.trackCliSubcommand({ subcommand: 'disable', diff --git a/packages/cli/src/util/telemetry/commands/flags/unarchive.ts b/packages/cli/src/util/telemetry/commands/flags/unarchive.ts new file mode 100644 index 000000000000..65f1fdea2071 --- /dev/null +++ b/packages/cli/src/util/telemetry/commands/flags/unarchive.ts @@ -0,0 +1,18 @@ +import { TelemetryClient } from '../..'; + +export class FlagsUnarchiveTelemetryClient extends TelemetryClient { + trackCliArgumentFlag(flag: string | undefined) { + if (flag) { + this.trackCliArgument({ + arg: 'flag', + value: this.redactedValue, + }); + } + } + + trackCliFlagYes(yes: boolean | undefined) { + if (yes) { + this.trackCliFlag('yes'); + } + } +} diff --git a/packages/cli/test/unit/commands/flags/index.test.ts b/packages/cli/test/unit/commands/flags/index.test.ts index a9cf8bece41c..02871a203b2d 100644 --- a/packages/cli/test/unit/commands/flags/index.test.ts +++ b/packages/cli/test/unit/commands/flags/index.test.ts @@ -8,6 +8,7 @@ import * as segmentsFlag from '../../../../src/commands/flags/segments'; import * as splitFlag from '../../../../src/commands/flags/split'; import * as updateFlag from '../../../../src/commands/flags/update'; import * as versionsFlag from '../../../../src/commands/flags/versions'; +import * as unarchiveFlag from '../../../../src/commands/flags/unarchive'; import { client } from '../../../mocks/client'; describe('flags', () => { @@ -21,6 +22,7 @@ describe('flags', () => { const splitSpy = vi.spyOn(splitFlag, 'default').mockResolvedValue(0); const updateSpy = vi.spyOn(updateFlag, 'default').mockResolvedValue(0); const versionsSpy = vi.spyOn(versionsFlag, 'default').mockResolvedValue(0); + const unarchiveSpy = vi.spyOn(unarchiveFlag, 'default').mockResolvedValue(0); afterEach(() => { lsSpy.mockClear(); @@ -31,6 +33,7 @@ describe('flags', () => { splitSpy.mockClear(); updateSpy.mockClear(); versionsSpy.mockClear(); + unarchiveSpy.mockClear(); }); describe('--help', () => { @@ -147,4 +150,12 @@ describe('flags', () => { await flags(client); expect(segmentsSpy).toHaveBeenCalledWith(client); }); + + it('routes to unarchive subcommand', async () => { + const args: string[] = ['my-feature', '--yes']; + + client.setArgv('flags', 'unarchive', ...args); + await flags(client); + expect(unarchiveSpy).toHaveBeenCalledWith(client, args); + }); }); diff --git a/packages/cli/test/unit/commands/flags/unarchive.test.ts b/packages/cli/test/unit/commands/flags/unarchive.test.ts new file mode 100644 index 000000000000..c426549e3da4 --- /dev/null +++ b/packages/cli/test/unit/commands/flags/unarchive.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import flags from '../../../../src/commands/flags'; +import { + removeProjectLink, + setupUnitFixture, +} from '../../../helpers/setup-unit-fixture'; +import { client } from '../../../mocks/client'; +import { defaultProject, useProject } from '../../../mocks/project'; +import { useTeams } from '../../../mocks/team'; +import { useUser } from '../../../mocks/user'; +import { useFlags } from '../../../mocks/flags'; +import { createTestFlags } from './fixtures'; +import type { Flag } from '../../../../src/util/flags/types'; + +describe('flags unarchive', () => { + let testFlags: Flag[]; + + beforeEach(() => { + testFlags = createTestFlags(); + testFlags[0].state = 'archived'; + useUser(); + useTeams('team_dummy'); + useProject({ + ...defaultProject, + id: 'vercel-flags-test', + name: 'vercel-flags-test', + accountId: 'team_dummy', + }); + useFlags(testFlags); + const cwd = setupUnitFixture('commands/flags/vercel-flags-test'); + client.cwd = cwd; + }); + + describe('--help', () => { + it('tracks telemetry', async () => { + const command = 'flags'; + const subcommand = 'unarchive'; + + client.setArgv(command, subcommand, '--help'); + const exitCodePromise = flags(client); + await expect(exitCodePromise).resolves.toEqual(2); + + expect(client.telemetryEventStore).toHaveTelemetryEvents([ + { + key: 'flag:help', + value: `${command}:${subcommand}`, + }, + ]); + }); + }); + + it('tracks `unarchive` subcommand', async () => { + client.setArgv('flags', 'unarchive', testFlags[0].slug, '--yes'); + const exitCode = await flags(client); + expect(exitCode).toEqual(0); + expect(client.telemetryEventStore).toHaveTelemetryEvents([ + { + key: 'subcommand:unarchive', + value: 'unarchive', + }, + { + key: 'argument:flag', + value: '[REDACTED]', + }, + { + key: 'flag:yes', + value: 'TRUE', + }, + ]); + }); + + it('unarchives an archived flag successfully with --yes', async () => { + client.setArgv('flags', 'unarchive', testFlags[0].slug, '--yes'); + const exitCode = await flags(client); + + expect(exitCode).toEqual(0); + expect(testFlags[0]).toMatchObject({ + state: 'active', + message: 'Unarchive', + }); + }); + + it('unarchives with --project when the cwd is not linked', async () => { + const cwd = setupUnitFixture('commands/flags/vercel-flags-test'); + removeProjectLink(cwd); + client.cwd = cwd; + + client.setArgv( + 'flags', + 'unarchive', + testFlags[0].slug, + '--project', + 'vercel-flags-test', + '--yes' + ); + const exitCode = await flags(client); + + expect(exitCode).toEqual(0); + expect(testFlags[0]).toMatchObject({ + state: 'active', + message: 'Unarchive', + }); + expect(client.telemetryEventStore).toHaveTelemetryEvents([ + { key: 'subcommand:unarchive', value: 'unarchive' }, + { key: 'argument:flag', value: '[REDACTED]' }, + { key: 'option:project', value: '[REDACTED]' }, + { key: 'flag:yes', value: 'TRUE' }, + ]); + }); + + it('errors in non-interactive mode without --yes', async () => { + client.stdin.isTTY = false; + client.setArgv('flags', 'unarchive', testFlags[0].slug); + + const exitCode = await flags(client); + + expect(exitCode).toEqual(1); + expect(client.stderr.getFullOutput()).toContain( + 'Missing required flag --yes' + ); + }); + + it('errors without flag argument', async () => { + client.setArgv('flags', 'unarchive'); + const exitCode = await flags(client); + expect(exitCode).toEqual(1); + }); + + it('errors when flag is not found', async () => { + client.setArgv('flags', 'unarchive', 'nonexistent-flag', '--yes'); + const exitCode = await flags(client); + expect(exitCode).toEqual(1); + expect(client.stderr.getFullOutput()).toContain('Flag not found'); + }); + + it('warns when flag is already active', async () => { + testFlags[0].state = 'active'; + + client.setArgv('flags', 'unarchive', testFlags[0].slug, '--yes'); + const exitCode = await flags(client); + expect(exitCode).toEqual(0); + expect(client.stderr.getFullOutput()).toContain('already active'); + }); +});