Skip to content

Commit 535af87

Browse files
crowlbotclaude
andcommitted
feat: add non-interactive flags for setup-aws / setup-gcp / env load
Second slice of the agent-ergonomics series. The previous PR added the global `--non-interactive` flag and the `requireInteractive()` guard; this one wires up the per-command flags so agents can actually drive the wizard subcommands end-to-end. - `setup-aws`: - `--policies <arn>` (repeatable) replaces the interactive policy multi-select. With `--non-interactive` set and no `--policies`, we now fail fast with `MISSING_FLAG` instead of blocking on stdin. - `--role-name <name>` replaces the random-suffixed default, so re-running the wizard with the same name is idempotent (subject to the underlying AWS error if the role already exists). - The final "apply these changes?" `confirm()` now auto-accepts in non-interactive mode. - `setup-gcp`: - `--roles <role>` (repeatable) — same treatment as `--policies`. - `--service-account-name <name>` — same treatment as `--role-name`. - `--enable-apis` skips the "enable missing APIs?" confirmation; the final apply-confirmation also auto-accepts in non-interactive mode. - `env load`: the existing TTY-only guard now also fires when `--non-interactive` is set on a TTY, so `env load .env --non-interactive` no longer falls through to a blocking `prompt()`. Existing interactive UX is unchanged when stdout is a TTY and no `--non-interactive` flag is set. The `getOrg`/`getApp` selection paths already route through `requireInteractive()`, which the previous PR taught to honor `context.nonInteractive`, so no additional changes were needed there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e520178 commit 535af87

4 files changed

Lines changed: 208 additions & 87 deletions

File tree

deploy/env.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command } from "@cliffy/command";
22
import { parse as dotEnvParse } from "@std/dotenv";
3-
import { error, isInteractive, tablePrinter } from "../util.ts";
3+
import { error, isNonInteractive, tablePrinter } from "../util.ts";
44
import { green } from "@std/fmt/colors";
55
import { createTrpcClient } from "../auth.ts";
66
import type { GlobalContext } from "../main.ts";
@@ -342,10 +342,10 @@ const envLoadCommand = new Command<EnvCommandContext>()
342342
updateEnvVars = [];
343343
} else if (options.replace) {
344344
// proceed with updates
345-
} else if (!isInteractive()) {
345+
} else if (isNonInteractive(options)) {
346346
error(
347347
options,
348-
"Existing env vars found and stdin is not a terminal.\nUse --replace to overwrite or --skip-existing to skip.",
348+
"Existing env vars found and prompting is disabled.\nUse --replace to overwrite or --skip-existing to skip.",
349349
);
350350
} else {
351351
console.log("The following env vars are already defined:");

deploy/mod.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ const setupAWSCommand = new Command<GlobalContext>()
2020
.option("--app <name:string>", "The name of the application", {
2121
required: true,
2222
})
23+
.option(
24+
"--policies <arn:string>",
25+
"IAM policy ARN to attach to the new role (repeatable; bypasses the interactive policy picker)",
26+
{ collect: true },
27+
)
28+
.option(
29+
"--role-name <name:string>",
30+
"Name for the IAM role to create (omit for a random-suffixed default; pass to allow idempotent re-runs)",
31+
)
2332
.arguments("[contexts:string]")
2433
.action(actionHandler(async (config, options, contexts) => {
2534
const org = await getOrg(options, config, options.org);
@@ -31,7 +40,10 @@ const setupAWSCommand = new Command<GlobalContext>()
3140
)
3241
: [];
3342

34-
await setupAws(options, org, app, contextList);
43+
await setupAws(options, org, app, contextList, {
44+
policies: options.policies,
45+
roleName: options.roleName as unknown as string | undefined,
46+
});
3547
}));
3648

3749
const setupGCPCommand = new Command<GlobalContext>()
@@ -42,6 +54,19 @@ const setupGCPCommand = new Command<GlobalContext>()
4254
.option("--app <name:string>", "The name of the application", {
4355
required: true,
4456
})
57+
.option(
58+
"--roles <role:string>",
59+
"IAM role to grant to the service account (repeatable; bypasses the interactive role picker)",
60+
{ collect: true },
61+
)
62+
.option(
63+
"--service-account-name <name:string>",
64+
"Name for the service account to create (omit for a random-suffixed default; pass to allow idempotent re-runs)",
65+
)
66+
.option(
67+
"--enable-apis",
68+
"Auto-enable required APIs that are missing, without prompting",
69+
)
4570
.arguments("[contexts:string]")
4671
.action(actionHandler(async (config, options, contexts) => {
4772
const org = await getOrg(options, config, options.org);
@@ -53,7 +78,13 @@ const setupGCPCommand = new Command<GlobalContext>()
5378
)
5479
: [];
5580

56-
await setupGcp(options, org, app, contextList);
81+
await setupGcp(options, org, app, contextList, {
82+
roles: options.roles,
83+
serviceAccountName: options.serviceAccountName as unknown as
84+
| string
85+
| undefined,
86+
enableApis: options.enableApis as unknown as boolean | undefined,
87+
});
5788
}));
5889

5990
const tunnelLoginCommand = new Command<GlobalContext>()

deploy/setup-cloud.ts

Lines changed: 147 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,32 @@ import { promptMultipleSelect } from "@std/cli/unstable-prompt-multiple-select";
33
import { gray, green, yellow } from "@std/fmt/colors";
44
import { createTrpcClient } from "../auth.ts";
55
import type { GlobalContext } from "../main.ts";
6+
import { error, ExitCode, isNonInteractive } from "../util.ts";
7+
8+
export interface SetupAwsOptions {
9+
/** AWS IAM policy ARNs to attach. When set, the interactive multi-select is skipped. */
10+
policies?: string[];
11+
/** Use this IAM role name instead of generating a random-suffixed one. Enables idempotent re-runs. */
12+
roleName?: string;
13+
}
14+
15+
export interface SetupGcpOptions {
16+
/** GCP IAM role names to grant. When set, the interactive multi-select is skipped. */
17+
roles?: string[];
18+
/** Use this service-account name instead of generating a random-suffixed one. Enables idempotent re-runs. */
19+
serviceAccountName?: string;
20+
/** Auto-accept the API-enable prompt for any missing required APIs. */
21+
enableApis?: boolean;
22+
}
23+
24+
/**
25+
* Apply-confirmation helper. In non-interactive mode (`--yes`/`--non-interactive`
26+
* or no TTY) we proceed automatically; otherwise we still prompt the human.
27+
*/
28+
function confirmApply(context: GlobalContext, message: string): boolean {
29+
if (isNonInteractive(context)) return true;
30+
return confirm(message);
31+
}
632

733
const AWS_OIDC_AUDIENCE = "sts.amazonaws.com";
834

@@ -110,6 +136,7 @@ export async function setupAws(
110136
org: string,
111137
app: string,
112138
contexts: string[],
139+
opts: SetupAwsOptions = {},
113140
) {
114141
// Print out "AWS Setup Wizard for Deno Deploy" in an orange box
115142
console.log(
@@ -173,51 +200,66 @@ export async function setupAws(
173200
),
174201
);
175202

176-
log(gray(" Loading IAM policies..."));
177-
const allPolicies = await runAwsCommand<{
178-
Policies: Array<{ PolicyName: string; Arn: string }>;
179-
}>(["iam", "list-policies"]);
180-
log("\r");
181-
182-
const choices = allPolicies.Policies.map((policy) => ({
183-
label: policy.PolicyName,
184-
value: policy.Arn,
185-
}));
186-
187-
let policies;
188-
while (true) {
189-
const result = promptMultipleSelect(
190-
"Select permission policies you want to attach to the new role",
191-
choices,
203+
let policies: Array<{ label: string; value: string }>;
204+
if (opts.policies !== undefined) {
205+
// Flag path: trust the caller, skip the listing and the prompt entirely.
206+
policies = opts.policies.map((arn) => ({ label: arn, value: arn }));
207+
} else if (isNonInteractive(context)) {
208+
error(
209+
context,
210+
"Selecting AWS policies requires interactive input.\nUse --policies <arn> (repeatable) to pre-supply policies.",
192211
{
193-
clear: true,
194-
fitToRemainingHeight: true,
212+
code: ExitCode.USAGE,
213+
errorCode: "MISSING_FLAG",
214+
hint: "Pass --policies <arn> for each policy you want attached.",
195215
},
196216
);
217+
} else {
218+
log(gray(" Loading IAM policies..."));
219+
const allPolicies = await runAwsCommand<{
220+
Policies: Array<{ PolicyName: string; Arn: string }>;
221+
}>(["iam", "list-policies"]);
222+
log("\r");
223+
224+
const choices = allPolicies.Policies.map((policy) => ({
225+
label: policy.PolicyName,
226+
value: policy.Arn,
227+
}));
228+
229+
while (true) {
230+
const result = promptMultipleSelect(
231+
"Select permission policies you want to attach to the new role",
232+
choices,
233+
{
234+
clear: true,
235+
fitToRemainingHeight: true,
236+
},
237+
);
197238

198-
if (result === null) {
199-
console.log("%c Exiting setup.", "color: yellow;");
200-
Deno.exit(1);
201-
}
239+
if (result === null) {
240+
console.log("%c Exiting setup.", "color: yellow;");
241+
Deno.exit(1);
242+
}
202243

203-
if (result.length === 0) {
204-
const confirmNoPolicies = confirm(
205-
"Are you sure you don't want to associate any policies? Remember to use Space to select a policy, and Enter to confirm your selections.",
206-
);
207-
if (!confirmNoPolicies) {
208-
continue;
244+
if (result.length === 0) {
245+
const confirmNoPolicies = confirm(
246+
"Are you sure you don't want to associate any policies? Remember to use Space to select a policy, and Enter to confirm your selections.",
247+
);
248+
if (!confirmNoPolicies) {
249+
continue;
250+
}
251+
console.log(
252+
"%c No policies selected. You can attach policies later through the AWS Console.",
253+
"color: yellow;",
254+
);
209255
}
210-
console.log(
211-
"%c No policies selected. You can attach policies later through the AWS Console.",
212-
"color: yellow;",
213-
);
214-
}
215256

216-
policies = result;
217-
break;
257+
policies = result;
258+
break;
259+
}
218260
}
219261

220-
const roleName = `DenoDeploy-${org}-${app}-${
262+
const roleName = opts.roleName ?? `DenoDeploy-${org}-${app}-${
221263
Math.random()
222264
.toString(36)
223265
.substring(2, 8)
@@ -285,7 +327,7 @@ export async function setupAws(
285327

286328
console.log("");
287329

288-
if (!confirm("Do you want to apply these changes?")) {
330+
if (!confirmApply(context, "Do you want to apply these changes?")) {
289331
console.log("%c Exiting setup.", "color: yellow;");
290332
Deno.exit(1);
291333
}
@@ -405,6 +447,7 @@ export async function setupGcp(
405447
org: string,
406448
app: string,
407449
contexts: string[],
450+
opts: SetupGcpOptions = {},
408451
) {
409452
// Print out "GCP Setup Wizard for Deno Deploy" in a blue box
410453
console.log(
@@ -499,7 +542,9 @@ export async function setupGcp(
499542
}
500543
console.log("");
501544

502-
const enableApis = confirm("Do you want to enable these APIs now?");
545+
const enableApis = opts.enableApis ||
546+
isNonInteractive(context) ||
547+
confirm("Do you want to enable these APIs now?");
503548

504549
if (!enableApis) {
505550
console.log(
@@ -568,58 +613,78 @@ export async function setupGcp(
568613
),
569614
);
570615

571-
// List available IAM roles for selection
572-
log(gray(" Loading IAM roles..."));
573-
const roles = await runGcloudCommand<Array<{ name: string; title: string }>>(
574-
["iam", "roles", "list", "--filter=stage:GA"],
575-
);
576-
log("\r");
577-
578-
const roleChoices = roles.map((role) => ({
579-
label: `${role.title} (${role.name.split("/").pop()})`,
580-
value: role.name,
581-
}));
582-
583-
let selectedRoles;
584-
while (true) {
585-
const result = promptMultipleSelect(
586-
"Select IAM roles you want to grant to the service account",
587-
roleChoices,
616+
let selectedRoles: Array<{ label: string; value: string }>;
617+
if (opts.roles !== undefined) {
618+
selectedRoles = opts.roles.map((role) => ({ label: role, value: role }));
619+
} else if (isNonInteractive(context)) {
620+
error(
621+
context,
622+
"Selecting GCP roles requires interactive input.\nUse --roles <role> (repeatable) to pre-supply roles.",
588623
{
589-
clear: true,
590-
fitToRemainingHeight: true,
624+
code: ExitCode.USAGE,
625+
errorCode: "MISSING_FLAG",
626+
hint: "Pass --roles <role> for each role you want granted.",
591627
},
592628
);
629+
} else {
630+
log(gray(" Loading IAM roles..."));
631+
const roles = await runGcloudCommand<
632+
Array<{ name: string; title: string }>
633+
>(["iam", "roles", "list", "--filter=stage:GA"]);
634+
log("\r");
635+
636+
const roleChoices = roles.map((role) => ({
637+
label: `${role.title} (${role.name.split("/").pop()})`,
638+
value: role.name,
639+
}));
640+
641+
while (true) {
642+
const result = promptMultipleSelect(
643+
"Select IAM roles you want to grant to the service account",
644+
roleChoices,
645+
{
646+
clear: true,
647+
fitToRemainingHeight: true,
648+
},
649+
);
593650

594-
if (result === null) {
595-
console.log("%c Exiting setup.", "color: yellow;");
596-
Deno.exit(1);
597-
}
651+
if (result === null) {
652+
console.log("%c Exiting setup.", "color: yellow;");
653+
Deno.exit(1);
654+
}
598655

599-
if (result.length === 0) {
600-
const confirmNoRoles = confirm(
601-
"Are you sure you don't want to associate any roles? Remember to use Space to select a role, and Enter to confirm your selections.",
602-
);
603-
if (!confirmNoRoles) {
604-
continue;
656+
if (result.length === 0) {
657+
const confirmNoRoles = confirm(
658+
"Are you sure you don't want to associate any roles? Remember to use Space to select a role, and Enter to confirm your selections.",
659+
);
660+
if (!confirmNoRoles) {
661+
continue;
662+
}
663+
console.log(
664+
"%c No roles selected. You can grant roles later through the GCP Console.",
665+
"color: yellow;",
666+
);
605667
}
606-
console.log(
607-
"%c No roles selected. You can grant roles later through the GCP Console.",
608-
"color: yellow;",
609-
);
610-
}
611668

612-
selectedRoles = result;
613-
break;
669+
selectedRoles = result;
670+
break;
671+
}
614672
}
615673

616-
// service account name must be between 6 and 30 characters, lowercase, and can contain letters, numbers, and dashes
617-
let serviceAccountName = "deno-";
618-
const orgPart = org.slice(0, 8).replaceAll(/-+$/g, "");
619-
const appPart = app.slice(0, 17 - orgPart.length).replaceAll(/-+$/g, "");
620-
serviceAccountName += `${orgPart}-${appPart}-${
621-
Math.random().toString(36).substring(2, 8)
622-
}`;
674+
// Service-account name must be 6-30 chars, lowercase, [a-z0-9-]. With
675+
// --service-account-name we trust the caller; otherwise we derive a
676+
// random-suffixed default to avoid colliding with existing user resources.
677+
let serviceAccountName: string;
678+
if (opts.serviceAccountName !== undefined) {
679+
serviceAccountName = opts.serviceAccountName;
680+
} else {
681+
serviceAccountName = "deno-";
682+
const orgPart = org.slice(0, 8).replaceAll(/-+$/g, "");
683+
const appPart = app.slice(0, 17 - orgPart.length).replaceAll(/-+$/g, "");
684+
serviceAccountName += `${orgPart}-${appPart}-${
685+
Math.random().toString(36).substring(2, 8)
686+
}`;
687+
}
623688

624689
const serviceAccountEmail =
625690
`${serviceAccountName}@${projectId}.iam.gserviceaccount.com`;
@@ -696,7 +761,7 @@ export async function setupGcp(
696761

697762
console.log("");
698763

699-
if (!confirm("Do you want to apply these changes?")) {
764+
if (!confirmApply(context, "Do you want to apply these changes?")) {
700765
console.log("%c Exiting setup.", "color: yellow;");
701766
Deno.exit(1);
702767
}

0 commit comments

Comments
 (0)