create-svc 0.1.14 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -3
- package/src/cli.ts +383 -39
- package/src/gcp.ts +35 -44
- package/templates/shared/scripts/authctl.ts +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-svc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Local microservice bootstrap CLI for Cloud Run and Workers services with Neon-backed data.",
|
|
5
5
|
"module": "index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -50,8 +50,6 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@clack/prompts": "^1.4.0",
|
|
53
|
-
"@google-cloud/billing": "^5.1.2",
|
|
54
|
-
"@google-cloud/resource-manager": "^6.2.2",
|
|
55
53
|
"@neondatabase/api-client": "^2.7.1",
|
|
56
54
|
"picocolors": "^1.1.1"
|
|
57
55
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,16 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
autocomplete,
|
|
3
|
-
cancel,
|
|
4
|
-
confirm,
|
|
5
|
-
intro,
|
|
6
|
-
isCancel,
|
|
7
|
-
log,
|
|
8
|
-
note,
|
|
9
|
-
outro,
|
|
10
|
-
select,
|
|
11
|
-
spinner,
|
|
12
|
-
text,
|
|
13
|
-
} from "@clack/prompts";
|
|
1
|
+
import { autocomplete, cancel, intro, isCancel, log, note, outro, select, spinner, text } from "@clack/prompts";
|
|
14
2
|
import pc from "picocolors";
|
|
15
3
|
import { readdirSync } from "node:fs";
|
|
16
4
|
import { basename, dirname, resolve } from "node:path";
|
|
@@ -68,7 +56,25 @@ type DiscoveryState = {
|
|
|
68
56
|
warnings: string[];
|
|
69
57
|
};
|
|
70
58
|
|
|
59
|
+
type GcpSelection = {
|
|
60
|
+
mode: GcpProjectMode;
|
|
61
|
+
projectId: string;
|
|
62
|
+
projectName: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type InteractiveStep = "serviceName" | "target" | "runtime" | "framework" | "modulePath" | "gcp" | "confirm";
|
|
66
|
+
|
|
67
|
+
type InteractiveState = {
|
|
68
|
+
serviceName?: string;
|
|
69
|
+
target?: DeployTarget;
|
|
70
|
+
runtime?: Runtime;
|
|
71
|
+
framework?: Framework;
|
|
72
|
+
modulePath?: string;
|
|
73
|
+
gcpSelection?: GcpSelection;
|
|
74
|
+
};
|
|
75
|
+
|
|
71
76
|
const DEFAULT_REGION = "us-west1";
|
|
77
|
+
const BACK = "__back__" as const;
|
|
72
78
|
|
|
73
79
|
export async function run(argv: string[]) {
|
|
74
80
|
try {
|
|
@@ -376,9 +382,11 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
376
382
|
|
|
377
383
|
export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
|
|
378
384
|
const inferredName = slugify(args.serviceName ?? basename(args.directory ?? "my-service"));
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
385
|
+
if (!args.yes) {
|
|
386
|
+
return resolveInteractiveConfig(args, inferredName);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const serviceName = inferredName;
|
|
382
390
|
const directory = args.directory ?? serviceName;
|
|
383
391
|
const targetDir = resolve(process.cwd(), directory);
|
|
384
392
|
await assertTargetDirectoryIsEmpty(targetDir);
|
|
@@ -392,22 +400,14 @@ export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
|
|
|
392
400
|
const modulePath = await resolveModulePath(args, runtime, defaults.modulePath);
|
|
393
401
|
const discovery = await waitForDiscovery(discoveryPromise);
|
|
394
402
|
const gcpSelection = await resolveGcpSelection(args, defaults, discovery);
|
|
403
|
+
if (gcpSelection === BACK) {
|
|
404
|
+
throw new Error("Unexpected back navigation in non-interactive config");
|
|
405
|
+
}
|
|
395
406
|
const region = args.region ?? DEFAULT_REGION;
|
|
396
407
|
const billingAccount = chooseBillingAccount(args.billingAccount, discovery.billingAccounts);
|
|
397
408
|
const autoDeploy = resolveAutoDeploy(args.autoDeploy);
|
|
398
409
|
const git = buildGitBootstrapConfig(serviceName, args.noGit);
|
|
399
410
|
|
|
400
|
-
if (!args.yes) {
|
|
401
|
-
const okay = await confirm({
|
|
402
|
-
message: "Create the scaffold with these defaults?",
|
|
403
|
-
initialValue: true,
|
|
404
|
-
});
|
|
405
|
-
if (isCancel(okay) || !okay) {
|
|
406
|
-
cancel("Aborted");
|
|
407
|
-
process.exit(1);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
411
|
for (const warning of discovery.warnings) {
|
|
412
412
|
log.warn(warning);
|
|
413
413
|
}
|
|
@@ -434,6 +434,226 @@ export async function resolveConfig(args: ParsedArgs): Promise<ScaffoldConfig> {
|
|
|
434
434
|
};
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
async function resolveInteractiveConfig(args: ParsedArgs, initialServiceName: string): Promise<ScaffoldConfig> {
|
|
438
|
+
const state: InteractiveState = {
|
|
439
|
+
serviceName: args.serviceName ? slugify(args.serviceName) : undefined,
|
|
440
|
+
target: args.target,
|
|
441
|
+
runtime: args.runtime,
|
|
442
|
+
framework: args.framework,
|
|
443
|
+
modulePath: args.modulePath,
|
|
444
|
+
};
|
|
445
|
+
let serviceNameDraft = state.serviceName ?? initialServiceName;
|
|
446
|
+
let discovery: DiscoveryState | undefined;
|
|
447
|
+
const discoveryPromise = discoverCloudInputs();
|
|
448
|
+
let step: InteractiveStep = state.serviceName ? "target" : "serviceName";
|
|
449
|
+
|
|
450
|
+
while (true) {
|
|
451
|
+
if (step === "serviceName") {
|
|
452
|
+
const value = await promptText("Service name", serviceNameDraft, (input) => validateServiceNameInput(input, args.directory));
|
|
453
|
+
serviceNameDraft = value;
|
|
454
|
+
state.serviceName = value;
|
|
455
|
+
step = "target";
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (!state.serviceName) {
|
|
460
|
+
step = "serviceName";
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const defaults = deriveDefaults(state.serviceName);
|
|
465
|
+
|
|
466
|
+
if (step === "target") {
|
|
467
|
+
if (args.target) {
|
|
468
|
+
state.target = args.target;
|
|
469
|
+
} else {
|
|
470
|
+
const value = await promptSelectWithBack<DeployTarget>(
|
|
471
|
+
"Deploy target",
|
|
472
|
+
[
|
|
473
|
+
{ value: "cloudrun", label: "Cloud Run", hint: "Default" },
|
|
474
|
+
{ value: "workers", label: "Cloudflare Workers" },
|
|
475
|
+
],
|
|
476
|
+
"cloudrun",
|
|
477
|
+
step,
|
|
478
|
+
args,
|
|
479
|
+
state
|
|
480
|
+
);
|
|
481
|
+
if (value === BACK) {
|
|
482
|
+
step = previousPromptStep(step, args, state) ?? step;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
state.target = value;
|
|
486
|
+
state.runtime = undefined;
|
|
487
|
+
state.framework = undefined;
|
|
488
|
+
}
|
|
489
|
+
step = "runtime";
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (step === "runtime") {
|
|
494
|
+
if (!state.target) {
|
|
495
|
+
step = "target";
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (state.target === "workers") {
|
|
499
|
+
state.runtime = "bun";
|
|
500
|
+
} else if (args.runtime) {
|
|
501
|
+
state.runtime = args.runtime;
|
|
502
|
+
} else {
|
|
503
|
+
const value = await promptSelectWithBack<Runtime>(
|
|
504
|
+
"Runtime",
|
|
505
|
+
[
|
|
506
|
+
{ value: "go", label: "Go", hint: "Default" },
|
|
507
|
+
{ value: "bun", label: "Bun" },
|
|
508
|
+
],
|
|
509
|
+
"go",
|
|
510
|
+
step,
|
|
511
|
+
args,
|
|
512
|
+
state
|
|
513
|
+
);
|
|
514
|
+
if (value === BACK) {
|
|
515
|
+
step = previousPromptStep(step, args, state) ?? step;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
state.runtime = value;
|
|
519
|
+
state.framework = undefined;
|
|
520
|
+
}
|
|
521
|
+
step = "framework";
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (step === "framework") {
|
|
526
|
+
if (!state.target || !state.runtime) {
|
|
527
|
+
step = state.target ? "runtime" : "target";
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const allowed = frameworksForTargetRuntime(state.target, state.runtime);
|
|
531
|
+
if (args.framework) {
|
|
532
|
+
if (!allowed.some((framework) => framework === args.framework)) {
|
|
533
|
+
throw new Error(`Framework ${args.framework} is not valid for target ${state.target} and runtime ${state.runtime}`);
|
|
534
|
+
}
|
|
535
|
+
state.framework = args.framework;
|
|
536
|
+
} else {
|
|
537
|
+
const value = await promptSelectWithBack<Framework>(
|
|
538
|
+
"Framework",
|
|
539
|
+
allowed.map((framework, index) => ({
|
|
540
|
+
value: framework,
|
|
541
|
+
label: framework,
|
|
542
|
+
hint: index === 0 ? "Default" : undefined,
|
|
543
|
+
})),
|
|
544
|
+
allowed[0],
|
|
545
|
+
step,
|
|
546
|
+
args,
|
|
547
|
+
state
|
|
548
|
+
);
|
|
549
|
+
if (value === BACK) {
|
|
550
|
+
step = previousPromptStep(step, args, state) ?? step;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
state.framework = value;
|
|
554
|
+
}
|
|
555
|
+
step = "modulePath";
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (step === "modulePath") {
|
|
560
|
+
if (!state.runtime) {
|
|
561
|
+
step = "runtime";
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (state.runtime !== "go") {
|
|
565
|
+
state.modulePath = args.modulePath ?? defaults.modulePath;
|
|
566
|
+
} else if (args.modulePath) {
|
|
567
|
+
state.modulePath = args.modulePath.trim();
|
|
568
|
+
} else {
|
|
569
|
+
const value = await promptTextWithBack(
|
|
570
|
+
"Go module path",
|
|
571
|
+
state.modulePath ?? defaults.modulePath,
|
|
572
|
+
(input) => {
|
|
573
|
+
if (!input.trim()) {
|
|
574
|
+
return "Go module path is required";
|
|
575
|
+
}
|
|
576
|
+
return true;
|
|
577
|
+
},
|
|
578
|
+
step,
|
|
579
|
+
args,
|
|
580
|
+
state
|
|
581
|
+
);
|
|
582
|
+
if (value === BACK) {
|
|
583
|
+
step = previousPromptStep(step, args, state) ?? step;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
state.modulePath = value;
|
|
587
|
+
}
|
|
588
|
+
step = "gcp";
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (step === "gcp") {
|
|
593
|
+
discovery ??= await waitForDiscovery(discoveryPromise);
|
|
594
|
+
const value = await resolveGcpSelection(args, defaults, discovery, {
|
|
595
|
+
allowBack: Boolean(previousPromptStep(step, args, state)),
|
|
596
|
+
});
|
|
597
|
+
if (value === BACK) {
|
|
598
|
+
step = previousPromptStep(step, args, state) ?? step;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
state.gcpSelection = value;
|
|
602
|
+
step = "confirm";
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (step === "confirm") {
|
|
607
|
+
if (!state.target || !state.runtime || !state.framework || !state.modulePath || !state.gcpSelection) {
|
|
608
|
+
step = "serviceName";
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const value = await promptSelectWithBack<"create">(
|
|
612
|
+
"Create the scaffold with these defaults?",
|
|
613
|
+
[{ value: "create", label: "Create scaffold", hint: "Default" }],
|
|
614
|
+
"create",
|
|
615
|
+
step,
|
|
616
|
+
args,
|
|
617
|
+
state
|
|
618
|
+
);
|
|
619
|
+
if (value === BACK) {
|
|
620
|
+
step = previousPromptStep(step, args, state) ?? step;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const directory = args.directory ?? state.serviceName;
|
|
625
|
+
const targetDir = resolve(process.cwd(), directory);
|
|
626
|
+
await assertTargetDirectoryIsEmpty(targetDir);
|
|
627
|
+
const billingAccount = chooseBillingAccount(args.billingAccount, discovery?.billingAccounts ?? []);
|
|
628
|
+
|
|
629
|
+
for (const warning of discovery?.warnings ?? []) {
|
|
630
|
+
log.warn(warning);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
directory,
|
|
635
|
+
serviceName: state.serviceName,
|
|
636
|
+
modulePath: state.modulePath,
|
|
637
|
+
target: state.target,
|
|
638
|
+
runtime: state.runtime,
|
|
639
|
+
framework: state.framework,
|
|
640
|
+
profile: args.profile,
|
|
641
|
+
region: args.region ?? DEFAULT_REGION,
|
|
642
|
+
gcpProjectMode: state.gcpSelection.mode,
|
|
643
|
+
gcpProject: state.gcpSelection.projectId,
|
|
644
|
+
gcpProjectName: state.gcpSelection.projectName,
|
|
645
|
+
billingAccount,
|
|
646
|
+
quotaProjectId: args.quotaProjectId ?? QUOTA_PROJECT_DEFAULT,
|
|
647
|
+
autoDeploy: resolveAutoDeploy(args.autoDeploy),
|
|
648
|
+
git: buildGitBootstrapConfig(state.serviceName, args.noGit),
|
|
649
|
+
neonDatabaseName: defaults.neonDatabaseName,
|
|
650
|
+
apiHostname: defaults.apiHostname,
|
|
651
|
+
generatorRoot: resolve(dirname(fileURLToPath(import.meta.url)), ".."),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
437
657
|
async function waitForDiscovery(discoveryPromise: Promise<DiscoveryState>) {
|
|
438
658
|
const indicator = spinner();
|
|
439
659
|
indicator.start("Discovering GCP defaults");
|
|
@@ -561,8 +781,9 @@ async function resolveModulePath(args: ParsedArgs, runtime: Runtime, initialValu
|
|
|
561
781
|
async function resolveGcpSelection(
|
|
562
782
|
args: ParsedArgs,
|
|
563
783
|
defaults: ReturnType<typeof deriveDefaults>,
|
|
564
|
-
discovery: DiscoveryState
|
|
565
|
-
|
|
784
|
+
discovery: DiscoveryState,
|
|
785
|
+
options: { allowBack?: boolean } = {}
|
|
786
|
+
): Promise<GcpSelection | typeof BACK> {
|
|
566
787
|
if (args.gcpProjectMode && args.gcpProject) {
|
|
567
788
|
const existing = discovery.projects.find((project) => matchesProject(project, args.gcpProject ?? ""));
|
|
568
789
|
return {
|
|
@@ -601,6 +822,15 @@ async function resolveGcpSelection(
|
|
|
601
822
|
message: "GCP project",
|
|
602
823
|
initialValue: "create_new",
|
|
603
824
|
options: [
|
|
825
|
+
...(options.allowBack
|
|
826
|
+
? [
|
|
827
|
+
{
|
|
828
|
+
value: BACK,
|
|
829
|
+
label: "Back",
|
|
830
|
+
hint: "Return to previous step",
|
|
831
|
+
},
|
|
832
|
+
]
|
|
833
|
+
: []),
|
|
604
834
|
{
|
|
605
835
|
value: "create_new",
|
|
606
836
|
label: `Create new project: ${defaults.projectName} (${defaults.projectId})`,
|
|
@@ -620,6 +850,10 @@ async function resolveGcpSelection(
|
|
|
620
850
|
process.exit(1);
|
|
621
851
|
}
|
|
622
852
|
|
|
853
|
+
if (mode === BACK) {
|
|
854
|
+
return BACK;
|
|
855
|
+
}
|
|
856
|
+
|
|
623
857
|
if (mode === "create_new") {
|
|
624
858
|
return {
|
|
625
859
|
mode: "create_new" as const,
|
|
@@ -632,7 +866,10 @@ async function resolveGcpSelection(
|
|
|
632
866
|
throw new Error("No existing GCP projects were discovered");
|
|
633
867
|
}
|
|
634
868
|
|
|
635
|
-
const selected = await promptForExistingProject(discovery.projects);
|
|
869
|
+
const selected = await promptForExistingProject(discovery.projects, options);
|
|
870
|
+
if (selected === BACK) {
|
|
871
|
+
return BACK;
|
|
872
|
+
}
|
|
636
873
|
if (!selected) {
|
|
637
874
|
return resolveGcpSelection(
|
|
638
875
|
{
|
|
@@ -641,7 +878,8 @@ async function resolveGcpSelection(
|
|
|
641
878
|
gcpProject: undefined,
|
|
642
879
|
},
|
|
643
880
|
defaults,
|
|
644
|
-
discovery
|
|
881
|
+
discovery,
|
|
882
|
+
options
|
|
645
883
|
);
|
|
646
884
|
}
|
|
647
885
|
|
|
@@ -717,6 +955,108 @@ async function promptText(
|
|
|
717
955
|
return value.trim();
|
|
718
956
|
}
|
|
719
957
|
|
|
958
|
+
async function promptTextWithBack(
|
|
959
|
+
message: string,
|
|
960
|
+
initialValue: string,
|
|
961
|
+
validate: (value: string) => true | string,
|
|
962
|
+
step: InteractiveStep,
|
|
963
|
+
args: ParsedArgs,
|
|
964
|
+
state: InteractiveState
|
|
965
|
+
): Promise<string | typeof BACK> {
|
|
966
|
+
const allowBack = Boolean(previousPromptStep(step, args, state));
|
|
967
|
+
const value = await text({
|
|
968
|
+
message: allowBack ? `${message} (type "back" to return)` : message,
|
|
969
|
+
initialValue,
|
|
970
|
+
validate: (input) => {
|
|
971
|
+
const normalized = (input ?? "").trim().toLowerCase();
|
|
972
|
+
if (allowBack && (normalized === "back" || normalized === "<")) {
|
|
973
|
+
return undefined;
|
|
974
|
+
}
|
|
975
|
+
return normalizeValidationResult(validate((input ?? "").trim()));
|
|
976
|
+
},
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
if (isCancel(value)) {
|
|
980
|
+
cancel("Aborted");
|
|
981
|
+
process.exit(1);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const trimmed = value.trim();
|
|
985
|
+
if (allowBack && (trimmed.toLowerCase() === "back" || trimmed === "<")) {
|
|
986
|
+
return BACK;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return trimmed;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
async function promptSelectWithBack<Value extends string>(
|
|
993
|
+
message: string,
|
|
994
|
+
options: Array<{ value: Value; label?: string; hint?: string; disabled?: boolean }>,
|
|
995
|
+
initialValue: Value | undefined,
|
|
996
|
+
step: InteractiveStep,
|
|
997
|
+
args: ParsedArgs,
|
|
998
|
+
state: InteractiveState
|
|
999
|
+
): Promise<Value | typeof BACK> {
|
|
1000
|
+
const allowBack = Boolean(previousPromptStep(step, args, state));
|
|
1001
|
+
const value = await select<Value | typeof BACK>({
|
|
1002
|
+
message,
|
|
1003
|
+
initialValue,
|
|
1004
|
+
options: [
|
|
1005
|
+
...(allowBack
|
|
1006
|
+
? [
|
|
1007
|
+
{
|
|
1008
|
+
value: BACK,
|
|
1009
|
+
label: "Back",
|
|
1010
|
+
hint: "Return to previous step",
|
|
1011
|
+
},
|
|
1012
|
+
]
|
|
1013
|
+
: []),
|
|
1014
|
+
...options,
|
|
1015
|
+
] as any,
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
if (isCancel(value)) {
|
|
1019
|
+
cancel("Aborted");
|
|
1020
|
+
process.exit(1);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
return value;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function previousPromptStep(step: InteractiveStep, args: ParsedArgs, state: InteractiveState): InteractiveStep | undefined {
|
|
1027
|
+
const steps: InteractiveStep[] = ["serviceName", "target", "runtime", "framework", "modulePath", "gcp", "confirm"];
|
|
1028
|
+
const currentIndex = steps.indexOf(step);
|
|
1029
|
+
for (let index = currentIndex - 1; index >= 0; index -= 1) {
|
|
1030
|
+
const candidate = steps[index];
|
|
1031
|
+
if (candidate && isPromptableStep(candidate, args, state)) {
|
|
1032
|
+
return candidate;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return undefined;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function isPromptableStep(step: InteractiveStep, args: ParsedArgs, state: InteractiveState) {
|
|
1039
|
+
if (step === "serviceName") {
|
|
1040
|
+
return !args.serviceName;
|
|
1041
|
+
}
|
|
1042
|
+
if (step === "target") {
|
|
1043
|
+
return !args.target;
|
|
1044
|
+
}
|
|
1045
|
+
if (step === "runtime") {
|
|
1046
|
+
return state.target !== "workers" && !args.runtime;
|
|
1047
|
+
}
|
|
1048
|
+
if (step === "framework") {
|
|
1049
|
+
return !args.framework;
|
|
1050
|
+
}
|
|
1051
|
+
if (step === "modulePath") {
|
|
1052
|
+
return state.runtime === "go" && !args.modulePath;
|
|
1053
|
+
}
|
|
1054
|
+
if (step === "gcp") {
|
|
1055
|
+
return !args.gcpProjectMode;
|
|
1056
|
+
}
|
|
1057
|
+
return step === "confirm";
|
|
1058
|
+
}
|
|
1059
|
+
|
|
720
1060
|
function formatError(error: unknown) {
|
|
721
1061
|
return error instanceof Error ? error.message : String(error);
|
|
722
1062
|
}
|
|
@@ -731,17 +1071,21 @@ function handleCliError(error: unknown) {
|
|
|
731
1071
|
process.exit(1);
|
|
732
1072
|
}
|
|
733
1073
|
|
|
734
|
-
async function promptForExistingProject(projects: GcpProject[]) {
|
|
1074
|
+
async function promptForExistingProject(projects: GcpProject[], options: { allowBack?: boolean } = {}) {
|
|
735
1075
|
const value = await autocomplete({
|
|
736
1076
|
message: "Existing GCP project",
|
|
737
1077
|
placeholder: "Search by project name or id",
|
|
738
1078
|
maxItems: 10,
|
|
739
1079
|
options: [
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1080
|
+
...(options.allowBack
|
|
1081
|
+
? [
|
|
1082
|
+
{
|
|
1083
|
+
value: BACK,
|
|
1084
|
+
label: "Back",
|
|
1085
|
+
hint: "Return to project mode",
|
|
1086
|
+
},
|
|
1087
|
+
]
|
|
1088
|
+
: []),
|
|
745
1089
|
...projects.map((project) => ({
|
|
746
1090
|
value: project.projectId,
|
|
747
1091
|
label: project.name,
|
|
@@ -755,8 +1099,8 @@ async function promptForExistingProject(projects: GcpProject[]) {
|
|
|
755
1099
|
process.exit(1);
|
|
756
1100
|
}
|
|
757
1101
|
|
|
758
|
-
if (value ===
|
|
759
|
-
return
|
|
1102
|
+
if (value === BACK) {
|
|
1103
|
+
return BACK;
|
|
760
1104
|
}
|
|
761
1105
|
|
|
762
1106
|
const project = projects.find((candidate) => candidate.projectId === value);
|
package/src/gcp.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { CloudBillingClient } from "@google-cloud/billing";
|
|
2
|
-
import { ProjectsClient } from "@google-cloud/resource-manager";
|
|
3
|
-
|
|
4
1
|
export type GcpProject = {
|
|
5
2
|
projectId: string;
|
|
6
3
|
name: string;
|
|
@@ -20,58 +17,29 @@ export type GcpApi = {
|
|
|
20
17
|
attachBillingAccount(projectId: string, billingAccountName: string): Promise<void>;
|
|
21
18
|
};
|
|
22
19
|
|
|
23
|
-
export function createGcpApi(
|
|
24
|
-
projectsClient = new ProjectsClient(),
|
|
25
|
-
billingClient = new CloudBillingClient()
|
|
26
|
-
): GcpApi {
|
|
20
|
+
export function createGcpApi(): GcpApi {
|
|
27
21
|
return {
|
|
28
22
|
async listProjects() {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
name: project.displayName ?? project.projectId ?? "",
|
|
34
|
-
lifecycleState: `${project.state ?? ""}`,
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return projects
|
|
39
|
-
.filter((project) => project.projectId && project.lifecycleState !== "DELETE_REQUESTED")
|
|
40
|
-
.sort((left, right) => left.name.localeCompare(right.name));
|
|
23
|
+
return parseJson<GcpProject[]>(
|
|
24
|
+
runGcloud(["projects", "list", "--format=json(projectId,name,lifecycleState)"]).stdout,
|
|
25
|
+
"GCP project discovery"
|
|
26
|
+
);
|
|
41
27
|
},
|
|
42
28
|
|
|
43
29
|
async listBillingAccounts() {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
displayName: account.displayName ?? account.name ?? "",
|
|
49
|
-
open: Boolean(account.open),
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return accounts
|
|
54
|
-
.filter((account) => account.name && account.open)
|
|
55
|
-
.sort((left, right) => left.displayName.localeCompare(right.displayName));
|
|
30
|
+
return parseJson<BillingAccount[]>(
|
|
31
|
+
runGcloud(["billing", "accounts", "list", "--format=json(name,displayName,open)"]).stdout,
|
|
32
|
+
"billing account discovery"
|
|
33
|
+
);
|
|
56
34
|
},
|
|
57
35
|
|
|
58
36
|
async createProject(projectId: string, name: string) {
|
|
59
|
-
|
|
60
|
-
project: {
|
|
61
|
-
projectId,
|
|
62
|
-
displayName: name,
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
await operation.promise();
|
|
37
|
+
runGcloud(["projects", "create", projectId, "--name", name]);
|
|
66
38
|
},
|
|
67
39
|
|
|
68
40
|
async attachBillingAccount(projectId: string, billingAccountName: string) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
projectBillingInfo: {
|
|
72
|
-
billingAccountName,
|
|
73
|
-
},
|
|
74
|
-
});
|
|
41
|
+
const account = billingAccountName.replace(/^billingAccounts\//, "");
|
|
42
|
+
runGcloud(["billing", "projects", "link", projectId, "--billing-account", account]);
|
|
75
43
|
},
|
|
76
44
|
};
|
|
77
45
|
}
|
|
@@ -95,3 +63,26 @@ export async function createProject(projectId: string, name: string, api = creat
|
|
|
95
63
|
export async function attachBillingAccount(projectId: string, billingAccountName: string, api = createGcpApi()) {
|
|
96
64
|
await api.attachBillingAccount(projectId, billingAccountName);
|
|
97
65
|
}
|
|
66
|
+
|
|
67
|
+
function runGcloud(args: string[]) {
|
|
68
|
+
const result = Bun.spawnSync(["gcloud", ...args], {
|
|
69
|
+
stdout: "pipe",
|
|
70
|
+
stderr: "pipe",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (result.exitCode !== 0) {
|
|
74
|
+
throw new Error(result.stderr.toString().trim() || `gcloud ${args.join(" ")} failed`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
stdout: result.stdout.toString(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseJson<T>(value: string, label: string): T {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(value) as T;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new Error(`Unable to parse ${label} output: ${(error as Error).message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -216,7 +216,7 @@ function authctl(args: string[], options: { allowFailure?: boolean; quiet?: bool
|
|
|
216
216
|
};
|
|
217
217
|
|
|
218
218
|
if (!output.success && !options.allowFailure) {
|
|
219
|
-
throw new Error(
|
|
219
|
+
throw new Error(formatAuthctlFailure(args, output));
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
if (output.stdout && !options.quiet) {
|
|
@@ -226,6 +226,22 @@ function authctl(args: string[], options: { allowFailure?: boolean; quiet?: bool
|
|
|
226
226
|
return output;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
function formatAuthctlFailure(args: string[], output: CommandResult) {
|
|
230
|
+
const detail = output.stderr || output.stdout;
|
|
231
|
+
if (detail.includes("status_code\":401") || detail.includes("Forbidden. You don't have permission")) {
|
|
232
|
+
return [
|
|
233
|
+
`authctl ${args.join(" ")} failed with exit code ${output.exitCode}`,
|
|
234
|
+
"authctl reached the auth internal API, but Cloudflare Access rejected the request.",
|
|
235
|
+
"Export the authctl Cloudflare Access service token before running service create:",
|
|
236
|
+
' export AUTH_INTERNAL_BASE_URL="$(vault kv get -mount=secret -field=AUTH_INTERNAL_BASE_URL prod/apps/auth/authctl/cloudflare-access)"',
|
|
237
|
+
' export CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID="$(vault kv get -mount=secret -field=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_ID prod/apps/auth/authctl/cloudflare-access)"',
|
|
238
|
+
' export CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET="$(vault kv get -mount=secret -field=CLOUDFLARE_ACCESS_SERVICE_TOKEN_CLIENT_SECRET prod/apps/auth/authctl/cloudflare-access)"',
|
|
239
|
+
].join("\n");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return `authctl ${args.join(" ")} failed with exit code ${output.exitCode}\n${detail}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
229
245
|
function authctlPath() {
|
|
230
246
|
return existsSync("./node_modules/.bin/authctl") ? "./node_modules/.bin/authctl" : Bun.which("authctl");
|
|
231
247
|
}
|