create-svc 0.1.35 → 0.1.36
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/README.md +4 -0
- package/package.json +1 -1
- package/src/cli.ts +43 -14
- package/src/git-bootstrap.test.ts +5 -1
- package/src/git-bootstrap.ts +4 -0
- package/src/naming.test.ts +14 -6
- package/src/naming.ts +15 -5
- package/src/post-scaffold.test.ts +18 -3
- package/src/post-scaffold.ts +23 -4
- package/src/service-runtime/cloudrun/cleanup.ts +90 -48
- package/src/service-runtime/cloudrun/lib.ts +18 -3
- package/src/service-runtime/parallel-tasks.test.ts +36 -0
- package/src/service-runtime/parallel-tasks.ts +35 -0
- package/src/service-runtime/workers/cli.ts +6 -1
- package/templates/shared/README.md +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,10 @@ By default, that scaffolds the repo, installs dependencies, runs the generated
|
|
|
54
54
|
repo's `service create`, deploys once, verifies production, starts local dev,
|
|
55
55
|
and verifies local. Pass `--no-auto-deploy` for scaffold-only generation.
|
|
56
56
|
|
|
57
|
+
Cloud Run services default to the shared existing GCP project `anmho-services`.
|
|
58
|
+
Override with `--project-id <id>` or explicitly opt into per-service project
|
|
59
|
+
creation with `--project-mode create_new`.
|
|
60
|
+
|
|
57
61
|
`--profile microservice` is accepted as a compatibility no-op. App workspaces live outside this package in private app template repositories.
|
|
58
62
|
|
|
59
63
|
By default, a standalone generated service is initialized as a git repository,
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -3,7 +3,7 @@ import pc from "picocolors";
|
|
|
3
3
|
import { readdirSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { buildDeploymentVerificationCommands, buildLocalVerificationCommands, runPostScaffoldFlow } from "./post-scaffold";
|
|
6
|
+
import { buildDeploymentVerificationCommands, buildLocalVerificationCommands, runPostScaffoldFlow, runPreGitBootstrapFlow } from "./post-scaffold";
|
|
7
7
|
import {
|
|
8
8
|
bootstrapGitHubRepository,
|
|
9
9
|
buildGitBootstrapConfig,
|
|
@@ -15,6 +15,8 @@ import { listOpenBillingAccounts, listAccessibleProjects, type BillingAccount, t
|
|
|
15
15
|
import {
|
|
16
16
|
BILLING_ACCOUNT_DEFAULT,
|
|
17
17
|
QUOTA_PROJECT_DEFAULT,
|
|
18
|
+
SERVICES_PROJECT_DEFAULT,
|
|
19
|
+
SERVICES_PROJECT_NAME_DEFAULT,
|
|
18
20
|
deriveDefaults,
|
|
19
21
|
frameworksForTargetRuntime,
|
|
20
22
|
parseDeployTarget,
|
|
@@ -108,6 +110,11 @@ export async function run(argv: string[]) {
|
|
|
108
110
|
await scaffoldProject(config);
|
|
109
111
|
buildSpinner.stop("Project files generated");
|
|
110
112
|
|
|
113
|
+
const preGitResult = runPreGitBootstrapFlow(config, targetDir);
|
|
114
|
+
if (preGitResult.changed) {
|
|
115
|
+
log.step("Generated local SDK artifacts before initial GitHub push");
|
|
116
|
+
}
|
|
117
|
+
|
|
111
118
|
const gitSpinner = spinner();
|
|
112
119
|
gitSpinner.start("Preparing git repository");
|
|
113
120
|
const gitResult = await bootstrapGitHubRepository(targetDir, config.git);
|
|
@@ -798,6 +805,15 @@ async function resolveGcpSelection(
|
|
|
798
805
|
discovery: DiscoveryState,
|
|
799
806
|
options: { allowBack?: boolean } = {}
|
|
800
807
|
): Promise<GcpSelection | typeof BACK> {
|
|
808
|
+
if (args.gcpProject && !args.gcpProjectMode) {
|
|
809
|
+
const existing = discovery.projects.find((project) => matchesProject(project, args.gcpProject ?? ""));
|
|
810
|
+
return {
|
|
811
|
+
mode: "use_existing" as const,
|
|
812
|
+
projectId: args.gcpProject,
|
|
813
|
+
projectName: existing?.name ?? args.gcpProject,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
801
817
|
if (args.gcpProjectMode && args.gcpProject) {
|
|
802
818
|
const existing = discovery.projects.find((project) => matchesProject(project, args.gcpProject ?? ""));
|
|
803
819
|
return {
|
|
@@ -819,22 +835,18 @@ async function resolveGcpSelection(
|
|
|
819
835
|
const existing = discovery.projects.find((project) => project.projectId === args.gcpProject);
|
|
820
836
|
return {
|
|
821
837
|
mode: "use_existing" as const,
|
|
822
|
-
projectId: args.gcpProject ??
|
|
823
|
-
projectName: existing?.name ?? args.gcpProject ??
|
|
838
|
+
projectId: args.gcpProject ?? SERVICES_PROJECT_DEFAULT,
|
|
839
|
+
projectName: existing?.name ?? args.gcpProject ?? SERVICES_PROJECT_NAME_DEFAULT,
|
|
824
840
|
};
|
|
825
841
|
}
|
|
826
842
|
|
|
827
843
|
if (args.yes) {
|
|
828
|
-
return
|
|
829
|
-
mode: "create_new" as const,
|
|
830
|
-
projectId: defaults.projectId,
|
|
831
|
-
projectName: defaults.projectName,
|
|
832
|
-
};
|
|
844
|
+
return sharedServicesProjectSelection(discovery);
|
|
833
845
|
}
|
|
834
846
|
|
|
835
847
|
const mode = await select({
|
|
836
848
|
message: "GCP project",
|
|
837
|
-
initialValue: "
|
|
849
|
+
initialValue: "use_shared",
|
|
838
850
|
options: [
|
|
839
851
|
...(options.allowBack
|
|
840
852
|
? [
|
|
@@ -845,16 +857,20 @@ async function resolveGcpSelection(
|
|
|
845
857
|
},
|
|
846
858
|
]
|
|
847
859
|
: []),
|
|
860
|
+
{
|
|
861
|
+
value: "use_shared",
|
|
862
|
+
label: `Use shared services project: ${sharedServicesProjectSelection(discovery).projectName} (${SERVICES_PROJECT_DEFAULT})`,
|
|
863
|
+
hint: "Default",
|
|
864
|
+
},
|
|
848
865
|
{
|
|
849
866
|
value: "create_new",
|
|
850
867
|
label: `Create new project: ${defaults.projectName} (${defaults.projectId})`,
|
|
851
|
-
hint: "
|
|
868
|
+
hint: "May hit billing quota limits",
|
|
852
869
|
},
|
|
853
870
|
{
|
|
854
871
|
value: "use_existing",
|
|
855
|
-
label: "Use existing project...",
|
|
856
|
-
hint: discovery.projects.length > 0 ? `${discovery.projects.length} available` : "
|
|
857
|
-
disabled: discovery.projects.length === 0,
|
|
872
|
+
label: "Use another existing project...",
|
|
873
|
+
hint: discovery.projects.length > 0 ? `${discovery.projects.length} available` : "Pass --project-id to use an undiscovered project",
|
|
858
874
|
},
|
|
859
875
|
],
|
|
860
876
|
});
|
|
@@ -868,6 +884,10 @@ async function resolveGcpSelection(
|
|
|
868
884
|
return BACK;
|
|
869
885
|
}
|
|
870
886
|
|
|
887
|
+
if (mode === "use_shared") {
|
|
888
|
+
return sharedServicesProjectSelection(discovery);
|
|
889
|
+
}
|
|
890
|
+
|
|
871
891
|
if (mode === "create_new") {
|
|
872
892
|
return {
|
|
873
893
|
mode: "create_new" as const,
|
|
@@ -877,7 +897,7 @@ async function resolveGcpSelection(
|
|
|
877
897
|
}
|
|
878
898
|
|
|
879
899
|
if (discovery.projects.length === 0) {
|
|
880
|
-
|
|
900
|
+
return sharedServicesProjectSelection(discovery);
|
|
881
901
|
}
|
|
882
902
|
|
|
883
903
|
const selected = await promptForExistingProject(discovery.projects, options);
|
|
@@ -904,6 +924,15 @@ async function resolveGcpSelection(
|
|
|
904
924
|
};
|
|
905
925
|
}
|
|
906
926
|
|
|
927
|
+
function sharedServicesProjectSelection(discovery: DiscoveryState): GcpSelection {
|
|
928
|
+
const project = discovery.projects.find((candidate) => candidate.projectId === SERVICES_PROJECT_DEFAULT);
|
|
929
|
+
return {
|
|
930
|
+
mode: "use_existing" as const,
|
|
931
|
+
projectId: SERVICES_PROJECT_DEFAULT,
|
|
932
|
+
projectName: project?.name ?? SERVICES_PROJECT_NAME_DEFAULT,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
907
936
|
async function discoverCloudInputs(): Promise<DiscoveryState> {
|
|
908
937
|
const result: DiscoveryState = {
|
|
909
938
|
projects: [],
|
|
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { buildGitBootstrapConfig, findExistingGitWorktree, markGitHubRepositoryDeleteOnDestroy } from "./git-bootstrap";
|
|
5
|
+
import { buildGitBootstrapConfig, findExistingGitWorktree, manualGitHubDeleteCommand, markGitHubRepositoryDeleteOnDestroy } from "./git-bootstrap";
|
|
6
6
|
|
|
7
7
|
test("buildGitBootstrapConfig defaults to anmho private repo creation", () => {
|
|
8
8
|
expect(buildGitBootstrapConfig("launch-api", undefined)).toEqual({
|
|
@@ -20,6 +20,10 @@ test("buildGitBootstrapConfig honors --no-git", () => {
|
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
test("manualGitHubDeleteCommand formats the advisory cleanup command", () => {
|
|
24
|
+
expect(manualGitHubDeleteCommand("anmho/launch-api")).toBe("gh repo delete anmho/launch-api --yes");
|
|
25
|
+
});
|
|
26
|
+
|
|
23
27
|
test("findExistingGitWorktree detects parent repositories", async () => {
|
|
24
28
|
const root = await mkdtemp(join(tmpdir(), "create-svc-git-"));
|
|
25
29
|
run(["git", "init", "-b", "main"], root);
|
package/src/git-bootstrap.ts
CHANGED
|
@@ -21,6 +21,10 @@ export function buildGitBootstrapConfig(serviceName: string, noGit: boolean | un
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export function manualGitHubDeleteCommand(repository: string) {
|
|
25
|
+
return `gh repo delete ${repository} --yes`;
|
|
26
|
+
}
|
|
27
|
+
|
|
24
28
|
export async function bootstrapGitHubRepository(targetDir: string, config: GitBootstrapConfig): Promise<GitBootstrapResult> {
|
|
25
29
|
if (!config.enabled) {
|
|
26
30
|
return { status: "disabled" };
|
package/src/naming.test.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
SERVICES_PROJECT_DEFAULT,
|
|
4
|
+
buildGcpProjectOptions,
|
|
5
|
+
compactDatabaseName,
|
|
6
|
+
compactIdentifier,
|
|
7
|
+
deriveDefaults,
|
|
8
|
+
deriveLocalPostgresPort,
|
|
9
|
+
} from "./naming";
|
|
3
10
|
|
|
4
11
|
test("deriveDefaults uses the service name for project, repo, and database naming", () => {
|
|
5
12
|
expect(deriveDefaults("edge-api")).toEqual({
|
|
@@ -25,16 +32,17 @@ test("compactDatabaseName switches to underscores", () => {
|
|
|
25
32
|
expect(compactDatabaseName("preview-worker")).toBe("preview_worker");
|
|
26
33
|
});
|
|
27
34
|
|
|
28
|
-
test("buildGcpProjectOptions puts
|
|
35
|
+
test("buildGcpProjectOptions puts the shared services project first", () => {
|
|
29
36
|
const options = buildGcpProjectOptions("preview-worker", "anmho-preview-worker", "preview-worker", [
|
|
30
37
|
{ projectId: "anmho-existing", name: "existing" },
|
|
31
38
|
]);
|
|
32
39
|
|
|
33
40
|
expect(options[0]).toEqual({
|
|
34
|
-
label:
|
|
35
|
-
mode: "
|
|
36
|
-
projectId: "anmho-
|
|
37
|
-
projectName: "
|
|
41
|
+
label: `Use shared services project: services (${SERVICES_PROJECT_DEFAULT})`,
|
|
42
|
+
mode: "use_existing",
|
|
43
|
+
projectId: "anmho-services",
|
|
44
|
+
projectName: "services",
|
|
38
45
|
});
|
|
39
46
|
expect(options[1]?.mode).toBe("use_existing");
|
|
47
|
+
expect(options[2]?.mode).toBe("create_new");
|
|
40
48
|
});
|
package/src/naming.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export const BILLING_ACCOUNT_DEFAULT = "billingAccounts/01BD2E-3A6949-8F4C84";
|
|
2
2
|
export const QUOTA_PROJECT_DEFAULT = "anmho-infra-prod";
|
|
3
|
+
export const SERVICES_PROJECT_DEFAULT = "anmho-services";
|
|
4
|
+
export const SERVICES_PROJECT_NAME_DEFAULT = "services";
|
|
3
5
|
|
|
4
6
|
export const DEPLOY_TARGETS = ["cloudrun", "workers"] as const;
|
|
5
7
|
|
|
@@ -108,19 +110,27 @@ export function buildGcpProjectOptions(
|
|
|
108
110
|
projectName: string,
|
|
109
111
|
projects: Array<{ projectId: string; name: string }>
|
|
110
112
|
) {
|
|
113
|
+
const servicesProject = projects.find((project) => project.projectId === SERVICES_PROJECT_DEFAULT);
|
|
114
|
+
const remainingProjects = projects.filter((project) => project.projectId !== SERVICES_PROJECT_DEFAULT);
|
|
111
115
|
return [
|
|
112
116
|
{
|
|
113
|
-
label:
|
|
114
|
-
mode: "
|
|
115
|
-
projectId,
|
|
116
|
-
projectName,
|
|
117
|
+
label: `Use shared services project: ${servicesProject?.name ?? SERVICES_PROJECT_NAME_DEFAULT} (${SERVICES_PROJECT_DEFAULT})`,
|
|
118
|
+
mode: "use_existing" as const,
|
|
119
|
+
projectId: SERVICES_PROJECT_DEFAULT,
|
|
120
|
+
projectName: servicesProject?.name ?? SERVICES_PROJECT_NAME_DEFAULT,
|
|
117
121
|
},
|
|
118
|
-
...
|
|
122
|
+
...remainingProjects.map((project) => ({
|
|
119
123
|
label: `Use existing project: ${project.name} (${project.projectId})`,
|
|
120
124
|
mode: "use_existing" as const,
|
|
121
125
|
projectId: project.projectId,
|
|
122
126
|
projectName: project.name,
|
|
123
127
|
})),
|
|
128
|
+
{
|
|
129
|
+
label: buildCreateProjectLabel(serviceName, projectId),
|
|
130
|
+
mode: "create_new" as const,
|
|
131
|
+
projectId,
|
|
132
|
+
projectName,
|
|
133
|
+
},
|
|
124
134
|
];
|
|
125
135
|
}
|
|
126
136
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildDeploymentVerificationCommands,
|
|
4
|
+
buildLocalVerificationCommands,
|
|
5
|
+
buildPostScaffoldCommands,
|
|
6
|
+
buildPreGitBootstrapCommands,
|
|
7
|
+
} from "./post-scaffold";
|
|
3
8
|
|
|
4
9
|
describe("buildPostScaffoldCommands", () => {
|
|
5
10
|
test("runs create for HTTP services", () => {
|
|
@@ -8,9 +13,8 @@ describe("buildPostScaffoldCommands", () => {
|
|
|
8
13
|
]);
|
|
9
14
|
});
|
|
10
15
|
|
|
11
|
-
test("
|
|
16
|
+
test("runs create for ConnectRPC services after pre-git SDK preparation", () => {
|
|
12
17
|
expect(buildPostScaffoldCommands({ framework: "connectrpc" })).toEqual([
|
|
13
|
-
{ command: "service", args: ["sdk", "build"] },
|
|
14
18
|
{ command: "service", args: ["create"] },
|
|
15
19
|
]);
|
|
16
20
|
});
|
|
@@ -22,6 +26,17 @@ describe("buildPostScaffoldCommands", () => {
|
|
|
22
26
|
});
|
|
23
27
|
});
|
|
24
28
|
|
|
29
|
+
describe("buildPreGitBootstrapCommands", () => {
|
|
30
|
+
test("builds SDK artifacts before the initial GitHub push for ConnectRPC services", () => {
|
|
31
|
+
expect(buildPreGitBootstrapCommands({ framework: "connectrpc" })).toEqual([{ command: "service", args: ["sdk", "build"] }]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("does not build SDK artifacts for HTTP or Workers services", () => {
|
|
35
|
+
expect(buildPreGitBootstrapCommands({ framework: "hono" })).toEqual([]);
|
|
36
|
+
expect(buildPreGitBootstrapCommands({ target: "workers", framework: "connectrpc" })).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
25
40
|
describe("buildLocalVerificationCommands", () => {
|
|
26
41
|
test("uses local curl checks for Bun Hono services", () => {
|
|
27
42
|
expect(buildLocalVerificationCommands({ apiHostname: "api.launch.anmho.com", framework: "hono", runtime: "bun" })).toEqual([
|
package/src/post-scaffold.ts
CHANGED
|
@@ -44,6 +44,19 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
|
|
|
44
44
|
return { message: "Backend package generated" };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export function runPreGitBootstrapFlow(config: Pick<ScaffoldConfig, "framework"> & Partial<Pick<ScaffoldConfig, "target">>, cwd: string) {
|
|
48
|
+
const commands = buildPreGitBootstrapCommands(config);
|
|
49
|
+
if (commands.length === 0) {
|
|
50
|
+
return { changed: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
installProjectDependencies(cwd);
|
|
54
|
+
for (const command of commands) {
|
|
55
|
+
run(command.command, command.args, { cwd });
|
|
56
|
+
}
|
|
57
|
+
return { changed: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
async function startLocalDevelopment(config: Pick<ScaffoldConfig, "target">, cwd: string) {
|
|
48
61
|
run("bun", ["run", "migrate"], { cwd });
|
|
49
62
|
await mkdir(join(cwd, ".service"), { recursive: true });
|
|
@@ -222,10 +235,16 @@ function localVerificationHost(config: Partial<Pick<ScaffoldConfig, "target" | "
|
|
|
222
235
|
export function buildPostScaffoldCommands(
|
|
223
236
|
config: Pick<ScaffoldConfig, "framework"> & Partial<Pick<ScaffoldConfig, "target">>
|
|
224
237
|
): PostScaffoldCommand[] {
|
|
225
|
-
return [
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
238
|
+
return [{ command: "service", args: ["create"] }];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function buildPreGitBootstrapCommands(
|
|
242
|
+
config: Pick<ScaffoldConfig, "framework"> & Partial<Pick<ScaffoldConfig, "target">>
|
|
243
|
+
): PostScaffoldCommand[] {
|
|
244
|
+
if (config.target === "workers" || config.framework !== "connectrpc") {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
return [{ command: "service", args: ["sdk", "build"] }];
|
|
229
248
|
}
|
|
230
249
|
|
|
231
250
|
function installProjectDependencies(cwd: string) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { confirm, isCancel, log } from "@clack/prompts";
|
|
2
2
|
import { deleteAuthResourceServer } from "../authctl";
|
|
3
|
+
import { manualGitHubDeleteCommand } from "../../git-bootstrap";
|
|
3
4
|
import { buildLocalDevCleanupPlan, stopLocalDev } from "../local-dev";
|
|
5
|
+
import { runParallelTasks, type ParallelTask } from "../parallel-tasks";
|
|
4
6
|
import { config } from "./config";
|
|
5
7
|
import { deleteBranch, deleteDatabase, listBranches, resolveNeonConfig } from "./neon";
|
|
6
8
|
import {
|
|
@@ -74,65 +76,105 @@ export async function cleanup(args = Bun.argv.slice(2)) {
|
|
|
74
76
|
|
|
75
77
|
await requireDestroyConfirmation(options.force);
|
|
76
78
|
|
|
77
|
-
await
|
|
79
|
+
await deletePlannedResources(plan);
|
|
78
80
|
|
|
79
|
-
if (
|
|
80
|
-
await runStep(`Deleting
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
await runStep(`Deleting auth resource server ${config.serviceName}`, () => deleteAuthResourceServer());
|
|
84
|
-
|
|
85
|
-
if (plan.hasProductionDomainMapping) {
|
|
86
|
-
await runStep(`Deleting production domain mapping ${config.domain.hostname}`, () => deleteProductionDomainMapping());
|
|
81
|
+
if (options.destroyProject) {
|
|
82
|
+
await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
|
|
83
|
+
return `Deleted project ${config.project.id}`;
|
|
87
84
|
}
|
|
88
85
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
assertOwnedResource(`Cloud Run service ${serviceName}`, describeCloudRunService(serviceName));
|
|
93
|
-
deleteService(serviceName);
|
|
94
|
-
}
|
|
95
|
-
});
|
|
86
|
+
log.step(`Production API hostname released: ${config.domain.hostname}`);
|
|
87
|
+
return `Destroy finished for ${config.serviceName}`;
|
|
88
|
+
}
|
|
96
89
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
90
|
+
async function deletePlannedResources(plan: DestroyPlan) {
|
|
91
|
+
const tasks: ParallelTask[] = [
|
|
92
|
+
{
|
|
93
|
+
label: "Stopping local dev resources",
|
|
94
|
+
task: () => stopLocalDev({ dockerCompose: true, removeVolumes: true }),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
label: `Deleting auth resource server ${config.serviceName}`,
|
|
98
|
+
task: () => deleteAuthResourceServer(),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
label: "Deleting Cloud Run services",
|
|
102
|
+
task: () => {
|
|
103
|
+
for (const serviceName of plan.serviceNames) {
|
|
104
|
+
assertOwnedResource(`Cloud Run service ${serviceName}`, describeCloudRunService(serviceName));
|
|
105
|
+
deleteService(serviceName);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
label: "Deleting Artifact Registry images",
|
|
111
|
+
task: () => {
|
|
112
|
+
for (const image of plan.artifactImages) {
|
|
113
|
+
deleteArtifactImage(image);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
label: "Deleting service secrets",
|
|
119
|
+
task: () => {
|
|
120
|
+
for (const secretName of plan.secretNames) {
|
|
121
|
+
assertOwnedResource(`Secret ${secretName}`, describeSecret(secretName));
|
|
122
|
+
deleteSecret(secretName);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
label: "Deleting Grafana resources",
|
|
128
|
+
task: () => deleteGrafanaResources(),
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
label: "Deleting service-specific identity resources",
|
|
132
|
+
task: () => {
|
|
133
|
+
deleteServiceAccount(config.runtimeServiceAccount);
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
];
|
|
103
137
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
});
|
|
138
|
+
if (plan.githubRepository) {
|
|
139
|
+
tasks.push({
|
|
140
|
+
label: `Deleting GitHub repository ${plan.githubRepository}`,
|
|
141
|
+
task: () => deleteGitHubRepository(plan.githubRepository!),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
111
144
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
await deleteBranch(neonPlan.projectId, branch.id);
|
|
117
|
-
}
|
|
145
|
+
if (plan.hasProductionDomainMapping) {
|
|
146
|
+
tasks.push({
|
|
147
|
+
label: `Deleting production domain mapping ${config.domain.hostname}`,
|
|
148
|
+
task: () => deleteProductionDomainMapping(),
|
|
118
149
|
});
|
|
150
|
+
}
|
|
119
151
|
|
|
120
|
-
|
|
152
|
+
if (plan.neon) {
|
|
153
|
+
const neonPlan = plan.neon;
|
|
154
|
+
tasks.push(
|
|
155
|
+
{
|
|
156
|
+
label: "Deleting Neon preview and personal branches",
|
|
157
|
+
task: async () => {
|
|
158
|
+
await Promise.all(neonPlan.branches.map((branch) => deleteBranch(neonPlan.projectId, branch.id)));
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
label: "Deleting Neon service database",
|
|
163
|
+
task: () => deleteDatabase(neonPlan.projectId, neonPlan.baseBranchId, neonPlan.databaseName),
|
|
164
|
+
}
|
|
165
|
+
);
|
|
121
166
|
}
|
|
122
167
|
|
|
123
|
-
await
|
|
168
|
+
await runDestroyTasks(tasks);
|
|
169
|
+
}
|
|
124
170
|
|
|
125
|
-
|
|
126
|
-
|
|
171
|
+
async function runDestroyTasks(tasks: Array<{ label: string; task: () => Promise<unknown> | unknown }>) {
|
|
172
|
+
log.step(`Deleting ${tasks.length} resource groups in parallel`);
|
|
173
|
+
await runParallelTasks(tasks, {
|
|
174
|
+
formatError,
|
|
175
|
+
onSuccess: (label) => log.step(`${label}: done`),
|
|
176
|
+
onFailure: (label, error) => log.error(`${label} failed\n${formatError(error)}`),
|
|
127
177
|
});
|
|
128
|
-
|
|
129
|
-
if (options.destroyProject) {
|
|
130
|
-
await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
|
|
131
|
-
return `Deleted project ${config.project.id}`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
log.step(`Production API hostname released: ${config.domain.hostname}`);
|
|
135
|
-
return `Destroy finished for ${config.serviceName}`;
|
|
136
178
|
}
|
|
137
179
|
|
|
138
180
|
async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
|
|
@@ -181,7 +223,7 @@ function planGitHubRepository(plan: DestroyPlan) {
|
|
|
181
223
|
if (!config.git.deleteOnDestroy) {
|
|
182
224
|
plan.skipped.push({
|
|
183
225
|
label: `GitHub repository ${repository}`,
|
|
184
|
-
detail: config.git.enabled ?
|
|
226
|
+
detail: config.git.enabled ? `not created by this service CLI run; manual cleanup: ${manualGitHubDeleteCommand(repository)}` : "git disabled",
|
|
185
227
|
});
|
|
186
228
|
return;
|
|
187
229
|
}
|
|
@@ -734,9 +734,24 @@ export function assertServiceNameAvailable(serviceName: string) {
|
|
|
734
734
|
|
|
735
735
|
export function deleteProductionDomainMapping() {
|
|
736
736
|
deleteCloudflareDnsRecord();
|
|
737
|
-
gcloud(
|
|
738
|
-
|
|
739
|
-
|
|
737
|
+
gcloud(
|
|
738
|
+
[
|
|
739
|
+
"beta",
|
|
740
|
+
"run",
|
|
741
|
+
"domain-mappings",
|
|
742
|
+
"delete",
|
|
743
|
+
"--domain",
|
|
744
|
+
config.domain.hostname,
|
|
745
|
+
"--project",
|
|
746
|
+
config.project.id,
|
|
747
|
+
"--region",
|
|
748
|
+
config.region,
|
|
749
|
+
"--quiet",
|
|
750
|
+
],
|
|
751
|
+
{
|
|
752
|
+
allowFailure: true,
|
|
753
|
+
}
|
|
754
|
+
);
|
|
740
755
|
}
|
|
741
756
|
|
|
742
757
|
export function listCloudRunServices() {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { runParallelTasks } from "./parallel-tasks";
|
|
3
|
+
|
|
4
|
+
describe("runParallelTasks", () => {
|
|
5
|
+
test("runs tasks concurrently and reports success as each task finishes", async () => {
|
|
6
|
+
const completed: string[] = [];
|
|
7
|
+
const startedAt = Date.now();
|
|
8
|
+
|
|
9
|
+
await runParallelTasks(
|
|
10
|
+
[
|
|
11
|
+
{ label: "slow", task: () => Bun.sleep(60) },
|
|
12
|
+
{ label: "fast", task: () => Bun.sleep(10) },
|
|
13
|
+
],
|
|
14
|
+
{ onSuccess: (label) => completed.push(label) }
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(Date.now() - startedAt).toBeLessThan(100);
|
|
18
|
+
expect(completed).toEqual(["fast", "slow"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("collects multiple failures before throwing", async () => {
|
|
22
|
+
const failures: string[] = [];
|
|
23
|
+
|
|
24
|
+
await expect(
|
|
25
|
+
runParallelTasks(
|
|
26
|
+
[
|
|
27
|
+
{ label: "one", task: () => Promise.reject(new Error("first")) },
|
|
28
|
+
{ label: "two", task: () => Promise.reject(new Error("second")) },
|
|
29
|
+
],
|
|
30
|
+
{ onFailure: (label) => failures.push(label) }
|
|
31
|
+
)
|
|
32
|
+
).rejects.toThrow("Destroy failed for one or more resource groups:");
|
|
33
|
+
|
|
34
|
+
expect(failures.sort()).toEqual(["one", "two"]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type ParallelTask = {
|
|
2
|
+
label: string;
|
|
3
|
+
task: () => Promise<unknown> | unknown;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
type ParallelTaskOptions = {
|
|
7
|
+
onSuccess?: (label: string) => void;
|
|
8
|
+
onFailure?: (label: string, error: unknown) => void;
|
|
9
|
+
formatError?: (error: unknown) => string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function runParallelTasks(tasks: ParallelTask[], options: ParallelTaskOptions = {}) {
|
|
13
|
+
const failures: string[] = [];
|
|
14
|
+
const format = options.formatError ?? defaultFormatError;
|
|
15
|
+
|
|
16
|
+
await Promise.all(
|
|
17
|
+
tasks.map(async ({ label, task }) => {
|
|
18
|
+
try {
|
|
19
|
+
await task();
|
|
20
|
+
options.onSuccess?.(label);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
failures.push(`${label}: ${format(error)}`);
|
|
23
|
+
options.onFailure?.(label, error);
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (failures.length > 0) {
|
|
29
|
+
throw new Error(["Destroy failed for one or more resource groups:", ...failures.map((failure) => `- ${failure}`)].join("\n"));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function defaultFormatError(error: unknown) {
|
|
34
|
+
return error instanceof Error ? error.message : String(error);
|
|
35
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { confirm, intro, isCancel, log, outro } from "@clack/prompts";
|
|
4
4
|
import { createApiClient } from "@neondatabase/api-client";
|
|
5
5
|
import { Client } from "pg";
|
|
6
|
+
import { manualGitHubDeleteCommand } from "../../git-bootstrap";
|
|
6
7
|
import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
|
|
7
8
|
import { stopLocalDev } from "../local-dev";
|
|
8
9
|
import { serviceConfig } from "../runtime";
|
|
@@ -135,7 +136,11 @@ function formatHelp() {
|
|
|
135
136
|
function deleteGitHubRepositoryIfOwned() {
|
|
136
137
|
const repository = `${config.git.owner}/${config.git.repository}`;
|
|
137
138
|
if (!config.git.deleteOnDestroy) {
|
|
138
|
-
log.step(
|
|
139
|
+
log.step(
|
|
140
|
+
`Skipping GitHub repository ${repository}: ${
|
|
141
|
+
config.git.enabled ? `not created by this service CLI run; manual cleanup: ${manualGitHubDeleteCommand(repository)}` : "git disabled"
|
|
142
|
+
}`
|
|
143
|
+
);
|
|
139
144
|
return;
|
|
140
145
|
}
|
|
141
146
|
run("gh", ["auth", "status"], { capture: true });
|
|
@@ -8,7 +8,7 @@ This `{{PROFILE}}` profile targets `{{RUNTIME}} + {{FRAMEWORK}}` on Cloud Run wi
|
|
|
8
8
|
- a lightweight `{{EXAMPLE_LABEL}}` example surface
|
|
9
9
|
- local Docker Compose Postgres for first-run development
|
|
10
10
|
- the `service` CLI for create, deploy, doctor, dashboards, and destroy
|
|
11
|
-
- GCP project
|
|
11
|
+
- shared GCP project deployment with quota-project-aware `gcloud` calls
|
|
12
12
|
- Neon-backed remote database provisioning during create and deploy
|
|
13
13
|
- Better Auth client-credentials resource-server registration through `authctl`
|
|
14
14
|
- stage-aware waitlist data and trigger ingestion
|