Skip to content

Commit 3c56c2b

Browse files
authored
feat(functions): improve parameter and secret prompting UX (#11086)
### Description Improves the terminal user experience and display formatting when prompting for Cloud Functions parameters and secrets (such as during `functions:kits:install` and `deploy --only functions`). Previously, parameter descriptions and selection instructions were embedded directly into Inquirer question messages, causing descriptions to be styled entirely in bold and echoed back onto the answered line. This change aligns parameter prompting with the cleaner pattern used by Extensions: - Displays parameter labels in bold with unbolded descriptions above the prompt. - Preserves vertical spacing between parameters without redundant headers when descriptions are omitted. - Provides transient navigation and selection instructions for single-select and multi-select prompts that disappear once answered. - Surfaces Cloud Secret Manager notices and secret descriptions prior to the masked password prompt. ### Scenarios Tested * Installed various kits that have select, multi-select, and secrets to verify interactive terminal rendering and post-selection display. * Added unit tests in `src/deploy/functions/params.spec.ts` verifying: * Bold label and markdown description output when descriptions are present. * Newline spacing when descriptions are omitted. * Clear-text instruction forwarding for `select` and multi-select `checkbox` prompts. ### Sample Commands - `firebase deploy --only functions` - `firebase functions:kits:install --package @firebase-function-kits/firestore-bigquery-export`
1 parent 5f0a62a commit 3c56c2b

4 files changed

Lines changed: 209 additions & 72 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- [changed] Improve formatting and user experience for Cloud Functions parameter and secret prompts.

src/deploy/functions/params.spec.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,4 +446,145 @@ describe("resolveParams", () => {
446446
loggerInfoStub.calledWith(sinon.match(/Prompting for parameters for codebase.*my-codebase/)),
447447
).to.be.true;
448448
});
449+
450+
it("should log bold label and description when description is present", async () => {
451+
const paramsToResolve: params.Param[] = [
452+
{
453+
name: "foo",
454+
label: "Foo Label",
455+
description: "A helpful description",
456+
type: "string",
457+
input: { text: {} },
458+
},
459+
];
460+
input.resolves("bar");
461+
await params.resolveParams({
462+
params: paramsToResolve,
463+
firebaseConfig: fakeConfig,
464+
userEnvs: {},
465+
codebase: "my-codebase",
466+
});
467+
expect(loggerInfoStub.calledWith(sinon.match(/Foo Label.*A helpful description/))).to.be.true;
468+
expect(input).to.have.been.calledWith(
469+
sinon.match({ message: "Enter a string value for Foo Label:" }),
470+
);
471+
});
472+
473+
it("should log empty newline and prompt without description when description is absent", async () => {
474+
const paramsToResolve: params.Param[] = [
475+
{
476+
name: "foo",
477+
type: "string",
478+
input: { text: {} },
479+
},
480+
];
481+
input.resolves("bar");
482+
await params.resolveParams({
483+
params: paramsToResolve,
484+
firebaseConfig: fakeConfig,
485+
userEnvs: {},
486+
codebase: "my-codebase",
487+
});
488+
expect(loggerInfoStub.calledWith("")).to.be.true;
489+
expect(input).to.have.been.calledWith(
490+
sinon.match({ message: "Enter a string value for foo:" }),
491+
);
492+
});
493+
494+
it("should pass clear text instructions to select", async () => {
495+
const selectStub = sinon.stub(prompt, "select").resolves("opt1");
496+
const paramsToResolve: params.Param[] = [
497+
{
498+
name: "choice",
499+
type: "string",
500+
input: {
501+
select: {
502+
options: [{ label: "Option 1", value: "opt1" }],
503+
},
504+
},
505+
},
506+
];
507+
try {
508+
await params.resolveParams({
509+
params: paramsToResolve,
510+
firebaseConfig: fakeConfig,
511+
userEnvs: {},
512+
codebase: "my-codebase",
513+
});
514+
expect(selectStub.firstCall.args[0].message).to.eq("Select a value for choice:");
515+
expect(selectStub.firstCall.args[0].instructions).to.eq(
516+
"(Use arrow keys to navigate, and Enter to confirm your choice)",
517+
);
518+
} finally {
519+
selectStub.restore();
520+
}
521+
});
522+
523+
it("should pass clear text instructions to checkbox for multi-select", async () => {
524+
const checkboxStub = sinon.stub(prompt, "checkbox").resolves(["opt1"]);
525+
const paramsToResolve: params.Param[] = [
526+
{
527+
name: "choices",
528+
type: "list",
529+
input: {
530+
multiSelect: {
531+
options: [{ label: "Option 1", value: "opt1" }],
532+
},
533+
},
534+
},
535+
];
536+
try {
537+
await params.resolveParams({
538+
params: paramsToResolve,
539+
firebaseConfig: fakeConfig,
540+
userEnvs: {},
541+
codebase: "my-codebase",
542+
});
543+
expect(checkboxStub.firstCall.args[0].message).to.eq("Select values for choices:");
544+
expect(checkboxStub.firstCall.args[0].instructions).to.eq(
545+
"(Press Space to select, and Enter to confirm your choices)",
546+
);
547+
} finally {
548+
checkboxStub.restore();
549+
}
550+
});
551+
552+
it("should log bold label, description, and notice, and prompt for secret value", async () => {
553+
const passwordStub = sinon.stub(prompt, "password").resolves("secret-val");
554+
const getSecretMetadataStub = sinon.stub(secretManager, "getSecretMetadata").resolves({
555+
secret: undefined,
556+
});
557+
const createSecretStub = sinon.stub(secretManager, "createSecret").resolves();
558+
const addVersionStub = sinon.stub(secretManager, "addVersion").resolves();
559+
const paramsToResolve: params.Param[] = [
560+
{
561+
name: "API_KEY",
562+
label: "API Key",
563+
description: "Key used to authenticate with 3rd party API.",
564+
type: "secret",
565+
resourceId: "API_KEY",
566+
},
567+
];
568+
try {
569+
await params.resolveParams({
570+
params: paramsToResolve,
571+
firebaseConfig: fakeConfig,
572+
userEnvs: {},
573+
codebase: "my-codebase",
574+
});
575+
expect(
576+
loggerInfoStub.calledWith(
577+
sinon.match(/API Key.*Key used to authenticate.*Cloud Secret Manager/),
578+
),
579+
).to.be.true;
580+
expect(passwordStub).to.have.been.calledWith(
581+
sinon.match({ message: "Enter a value for API Key:" }),
582+
);
583+
} finally {
584+
passwordStub.restore();
585+
getSecretMetadataStub.restore();
586+
createSecretStub.restore();
587+
addVersionStub.restore();
588+
}
589+
});
449590
});

src/deploy/functions/params.ts

Lines changed: 60 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { isCelExpression, resolveExpression } from "./cel";
1111
import { FirebaseConfig } from "./args";
1212
import { labels as secretLabels } from "../../gcp/secretManager";
1313
import * as experiments from "../../experiments";
14+
import { marked } from "marked";
1415

1516
// A convenience type containing options for Prompt's select
1617
interface ListItem {
@@ -542,9 +543,11 @@ async function ensureSecret(
542543
}
543544
return ensureSecret(secretParam, projectId, nonInteractive, force);
544545
}
545-
const promptMessage = `The value for this secret (${secretParam.name}) will be stored in Cloud Secret Manager (https://cloud.google.com/secret-manager/pricing) as ${resourceId}. Enter ${secretParam.format === "json" ? "a JSON value" : "a value"} for ${
546-
secretParam.label || secretParam.name
547-
}:`;
546+
const label = secretParam.label || secretParam.name;
547+
const notice = `The value for this secret will be stored in Cloud Secret Manager (https://cloud.google.com/secret-manager/pricing) as ${resourceId}.`;
548+
const desc = secretParam.description ? `${secretParam.description} ${notice}` : notice;
549+
logger.info(`\n${clc.bold(label)}: ${(await marked(desc)).trim()}`);
550+
const promptMessage = `Enter ${secretParam.format === "json" ? "a JSON value" : "a value"} for ${label}:`;
548551
const secretValue = await password({
549552
message: promptMessage,
550553
});
@@ -596,6 +599,14 @@ async function promptParam(
596599
projectId: string,
597600
resolvedDefault?: RawParamValue,
598601
): Promise<ParamValue> {
602+
const label = param.label || param.name;
603+
if (param.description) {
604+
logger.info(`\n${clc.bold(label)}: ${(await marked(param.description)).trim()}`);
605+
} else {
606+
// Provide newline spacing between successive parameter prompts when there is no description to display.
607+
logger.info("");
608+
}
609+
599610
if (param.type === "string") {
600611
const provided = await promptStringParam(
601612
param,
@@ -629,46 +640,32 @@ async function promptList(
629640
const defaultToText: TextInput<string> = { text: {} };
630641
param.input = defaultToText;
631642
}
632-
let prompt: string;
643+
const label = param.label || param.name;
633644

634645
if (isSelectInput(param.input)) {
635646
throw new FirebaseError("List params cannot have non-list selector inputs");
636647
} else if (isMultiSelectInput(param.input)) {
637-
prompt = `Select a value for ${param.label || param.name}:`;
638-
if (param.description) {
639-
prompt += ` \n(${param.description})`;
640-
}
641-
prompt += "\nSelect an option with the arrow keys, and use Enter to confirm your choice. ";
642648
return promptSelectMultiple<string>(
643-
prompt,
649+
`Select values for ${label}:`,
644650
param.input,
645651
resolvedDefault,
646652
param.input.multiSelect.nonEmpty,
647653
(res: string[]) => res,
648654
);
649655
} else if (isTextInput(param.input)) {
650-
prompt = `Enter a list of strings (delimiter: ${param.delimiter ? param.delimiter : ","}) for ${
651-
param.label || param.name
652-
}:`;
653-
if (param.description) {
654-
prompt += ` \n(${param.description})`;
655-
}
656+
const delimiter = param.delimiter ? param.delimiter : ",";
656657
return promptText<string[]>(
657-
prompt,
658+
`Enter a list of strings (delimiter: ${delimiter}) for ${label}:`,
658659
param.input,
659660
resolvedDefault,
660661
param.input.text.nonEmpty,
661662
(res: string): string[] => {
662-
return res.split(param.delimiter || ",");
663+
return res.split(delimiter);
663664
},
664665
);
665666
} else if (isResourceInput(param.input)) {
666667
// N.B: The type system in the SDK currently doesn't allow a ResourceInput to be assigned to a ListParam, so this path is unreachable.
667-
prompt = `Select values for ${param.label || param.name}:`;
668-
if (param.description) {
669-
prompt += ` \n(${param.description})`;
670-
}
671-
return promptResourceStrings(prompt, param.input, projectId, false);
668+
return promptResourceStrings(`Select values for ${label}:`, param.input, projectId, false);
672669
} else {
673670
assertExhaustive(param.input);
674671
}
@@ -683,24 +680,20 @@ async function promptBooleanParam(
683680
param.input = defaultToText;
684681
}
685682
const isTruthyInput = (res: string) => ["true", "y", "yes", "1"].includes(res.toLowerCase());
686-
let prompt: string;
683+
const label = param.label || param.name;
687684

688685
if (isSelectInput(param.input)) {
689-
prompt = `Select a value for ${param.label || param.name}:`;
690-
if (param.description) {
691-
prompt += ` \n(${param.description})`;
692-
}
693-
prompt += "\nSelect an option with the arrow keys, and use Enter to confirm your choice. ";
694-
return promptSelect<boolean>(prompt, param.input, resolvedDefault, isTruthyInput);
686+
return promptSelect<boolean>(
687+
`Select a value for ${label}:`,
688+
param.input,
689+
resolvedDefault,
690+
isTruthyInput,
691+
);
695692
} else if (isMultiSelectInput(param.input)) {
696693
throw new FirebaseError("Non-list params cannot have multi selector inputs");
697694
} else if (isTextInput(param.input)) {
698-
prompt = `Enter a boolean value for ${param.label || param.name}:`;
699-
if (param.description) {
700-
prompt += ` \n(${param.description})`;
701-
}
702695
return promptText<boolean>(
703-
prompt,
696+
`Enter a boolean value for ${label}:`,
704697
param.input,
705698
resolvedDefault,
706699
false, // enforceNonEmpty
@@ -722,30 +715,27 @@ async function promptStringParam(
722715
const defaultToText: TextInput<string> = { text: {} };
723716
param.input = defaultToText;
724717
}
725-
let prompt: string;
718+
const label = param.label || param.name;
726719

727720
if (isResourceInput(param.input)) {
728-
prompt = `Select a value for ${param.label || param.name}:`;
729-
if (param.description) {
730-
prompt += ` \n(${param.description})`;
731-
}
732-
return promptResourceString(prompt, param.input, projectId, resolvedDefault);
721+
return promptResourceString(
722+
`Select a value for ${label}:`,
723+
param.input,
724+
projectId,
725+
resolvedDefault,
726+
);
733727
} else if (isMultiSelectInput(param.input)) {
734728
throw new FirebaseError("Non-list params cannot have multi selector inputs");
735729
} else if (isSelectInput(param.input)) {
736-
prompt = `Select a value for ${param.label || param.name}:`;
737-
if (param.description) {
738-
prompt += ` \n(${param.description})`;
739-
}
740-
prompt += "\nSelect an option with the arrow keys, and use Enter to confirm your choice. ";
741-
return promptSelect<string>(prompt, param.input, resolvedDefault, (res: string) => res);
730+
return promptSelect<string>(
731+
`Select a value for ${label}:`,
732+
param.input,
733+
resolvedDefault,
734+
(res: string) => res,
735+
);
742736
} else if (isTextInput(param.input)) {
743-
prompt = `Enter a string value for ${param.label || param.name}:`;
744-
if (param.description) {
745-
prompt += ` \n(${param.description})`;
746-
}
747737
return promptText<string>(
748-
prompt,
738+
`Enter a string value for ${label}:`,
749739
param.input,
750740
resolvedDefault,
751741
param.input.text.nonEmpty,
@@ -761,32 +751,28 @@ async function promptIntParam(param: IntParam, resolvedDefault?: number): Promis
761751
const defaultToText: TextInput<number> = { text: {} };
762752
param.input = defaultToText;
763753
}
764-
let prompt: string;
754+
const label = param.label || param.name;
765755

766756
if (isSelectInput(param.input)) {
767-
prompt = `Select a value for ${param.label || param.name}:`;
768-
if (param.description) {
769-
prompt += ` \n(${param.description})`;
770-
}
771-
prompt += "\nSelect an option with the arrow keys, and use Enter to confirm your choice. ";
772-
return promptSelect(prompt, param.input, resolvedDefault, (res: string) => {
773-
if (isNaN(+res)) {
774-
return { message: `"${res}" could not be converted to a number.` };
775-
}
776-
if (res.includes(".")) {
777-
return { message: `${res} is not an integer value.` };
778-
}
779-
return +res;
780-
});
757+
return promptSelect(
758+
`Select a value for ${label}:`,
759+
param.input,
760+
resolvedDefault,
761+
(res: string) => {
762+
if (isNaN(+res)) {
763+
return { message: `"${res}" could not be converted to a number.` };
764+
}
765+
if (res.includes(".")) {
766+
return { message: `${res} is not an integer value.` };
767+
}
768+
return +res;
769+
},
770+
);
781771
} else if (isMultiSelectInput(param.input)) {
782772
throw new FirebaseError("Non-list params cannot have multi selector inputs");
783773
} else if (isTextInput(param.input)) {
784-
prompt = `Enter an integer value for ${param.label || param.name}:`;
785-
if (param.description) {
786-
prompt += ` \n(${param.description})`;
787-
}
788774
return promptText<number>(
789-
prompt,
775+
`Enter an integer value for ${label}:`,
790776
param.input,
791777
resolvedDefault,
792778
param.input.text.nonEmpty,
@@ -933,6 +919,7 @@ async function promptSelect<T extends RawParamValue>(
933919
const response = await select<string>({
934920
default: resolvedDefault as string,
935921
message: prompt,
922+
instructions: "(Use arrow keys to navigate, and Enter to confirm your choice)",
936923
choices: input.select.options.map((option: SelectOptions<T>): ListItem => {
937924
return {
938925
checked: false,
@@ -959,6 +946,7 @@ async function promptSelectMultiple<T extends string>(
959946
const response = await checkbox({
960947
default: resolvedDefault,
961948
message: prompt,
949+
instructions: "(Press Space to select, and Enter to confirm your choices)",
962950
choices: input.multiSelect.options.map((option: SelectOptions<string>): ListItem => {
963951
return {
964952
checked: false,

0 commit comments

Comments
 (0)