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
11 changes: 11 additions & 0 deletions .changeset/unarchive-feature-flags.md
Original file line number Diff line number Diff line change
@@ -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`
3 changes: 1 addition & 2 deletions packages/cli/src/commands/flags/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`)}`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve --project in the unarchive suggestion

When flags archive ... --project B is run from a directory linked to project A—or from an unlinked directory—the new next-step command drops --project B. Copying it therefore targets a same-named flag in A or fails as unlinked instead of undoing the archive in B; construct the suggestion with the explicit/resolved project and relevant global targeting flags.

AGENTS.md reference: packages/cli/AGENTS.md:L15-L18

Useful? React with 👍 / 👎.

);
} catch (err) {
output.stopSpinner();
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/commands/flags/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down Expand Up @@ -1638,6 +1667,7 @@ export const flagsCommand = {
rolloutSubcommand,
removeSubcommand,
archiveSubcommand,
unarchiveSubcommand,
disableSubcommand,
enableSubcommand,
rulesSubcommand,
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/commands/flags/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,6 +39,7 @@ import {
rolloutSubcommand,
removeSubcommand,
archiveSubcommand,
unarchiveSubcommand,
disableSubcommand,
prepareSubcommand,
enableSubcommand,
Expand All @@ -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),
Expand Down Expand Up @@ -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);
Expand Down
107 changes: 107 additions & 0 deletions packages/cli/src/commands/flags/unarchive.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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 <name>, 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Respect non-interactive mode before confirming

When client.nonInteractive is true because the caller explicitly passed global --non-interactive but stdin is still a TTY, this condition is false and the command invokes client.input.confirm(). This breaks the global flag's contract and can leave pseudo-TTY automation waiting for input; treat client.nonInteractive as non-prompting and report the missing --yes flag.

AGENTS.md reference: packages/cli/AGENTS.md:L98-L98

Useful? React with 👍 / 👎.

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)}?`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Show the resolved project before confirmation

When no --project is supplied and the cwd has a stale or unexpected link, this prompt names only the flag and gives the user no indication which resolved project will be mutated. Confirming can consequently reactivate the flag in the wrong project; print the resolved project/team before asking for confirmation.

AGENTS.md reference: packages/cli/AGENTS.md:L15-L18

Useful? React with 👍 / 👎.

false
);

if (!confirmed) {
output.log('Aborted');
return 0;
}
}

output.spinner('Unarchiving flag...');
await updateFlag(client, project.id, flagArg, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Disable retries for the unarchive mutation

When the API applies this PATCH but its response is lost, or it returns a retryable 5xx after committing, Client.fetch() retries the request up to 3 times by default. Each flag PATCH creates a new revision/message—the mock handler likewise increments revision on every call—so one unarchive can create duplicate history entries even though the final state is unchanged; issue this remote mutation without automatic retries or add a server-supported idempotency mechanism.

AGENTS.md reference: packages/cli/AGENTS.md:L102-L102

Useful? React with 👍 / 👎.

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;
}
7 changes: 7 additions & 0 deletions packages/cli/src/util/telemetry/commands/flags/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ export class FlagsTelemetryClient
});
}

trackCliSubcommandUnarchive(actual: string) {
this.trackCliSubcommand({
subcommand: 'unarchive',
value: actual,
});
}

trackCliSubcommandDisable(actual: string) {
this.trackCliSubcommand({
subcommand: 'disable',
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/util/telemetry/commands/flags/unarchive.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
}
11 changes: 11 additions & 0 deletions packages/cli/test/unit/commands/flags/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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();
Expand All @@ -31,6 +33,7 @@ describe('flags', () => {
splitSpy.mockClear();
updateSpy.mockClear();
versionsSpy.mockClear();
unarchiveSpy.mockClear();
});

describe('--help', () => {
Expand Down Expand Up @@ -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);
});
});
Loading
Loading