create-svc 0.1.38 → 0.1.40
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 -1
- package/src/cli.ts +16 -1
- package/src/gcp.test.ts +75 -1
- package/src/gcp.ts +66 -0
- package/src/scaffold.test.ts +2 -0
- package/src/service-runtime/cloudrun/cleanup.ts +43 -4
- package/src/service-runtime/cloudrun/deploy-args.ts +90 -0
- package/src/service-runtime/cloudrun/deploy.ts +13 -3
- package/src/service-runtime/cloudrun/lib.test.ts +29 -0
- package/src/service-runtime/cloudrun/lib.ts +6 -62
- package/src/service-runtime/workers/cli.ts +44 -1
- package/templates/shared/README.md +4 -0
- package/templates/shared/service.jsonc +0 -1
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -11,7 +11,13 @@ import {
|
|
|
11
11
|
markGitHubRepositoryDeleteOnDestroy,
|
|
12
12
|
type GitBootstrapResult,
|
|
13
13
|
} from "./git-bootstrap";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
assertExistingProjectReadyForAutoDeploy,
|
|
16
|
+
listOpenBillingAccounts,
|
|
17
|
+
listAccessibleProjects,
|
|
18
|
+
type BillingAccount,
|
|
19
|
+
type GcpProject,
|
|
20
|
+
} from "./gcp";
|
|
15
21
|
import {
|
|
16
22
|
BILLING_ACCOUNT_DEFAULT,
|
|
17
23
|
QUOTA_PROJECT_DEFAULT,
|
|
@@ -91,6 +97,7 @@ export async function run(argv: string[]) {
|
|
|
91
97
|
|
|
92
98
|
const config = await resolveConfig(args);
|
|
93
99
|
const targetDir = resolve(process.cwd(), config.directory);
|
|
100
|
+
await assertPreScaffoldReady(config);
|
|
94
101
|
|
|
95
102
|
note(
|
|
96
103
|
[
|
|
@@ -959,6 +966,14 @@ export function assertDiscoveryReady(discovery: DiscoveryState) {
|
|
|
959
966
|
return discovery;
|
|
960
967
|
}
|
|
961
968
|
|
|
969
|
+
async function assertPreScaffoldReady(config: ScaffoldConfig) {
|
|
970
|
+
if (config.target !== "cloudrun" || !config.autoDeploy || config.gcpProjectMode !== "use_existing") {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
await assertExistingProjectReadyForAutoDeploy(config.gcpProject);
|
|
975
|
+
}
|
|
976
|
+
|
|
962
977
|
function chooseBillingAccount(input: string | undefined, accounts: BillingAccount[]) {
|
|
963
978
|
if (input) {
|
|
964
979
|
return input;
|
package/src/gcp.test.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
assertExistingProjectReadyForAutoDeploy,
|
|
4
|
+
attachBillingAccount,
|
|
5
|
+
createProject,
|
|
6
|
+
listAccessibleProjects,
|
|
7
|
+
listOpenBillingAccounts,
|
|
8
|
+
type GcpApi,
|
|
9
|
+
} from "./gcp";
|
|
3
10
|
|
|
4
11
|
test("listAccessibleProjects filters deleted projects and sorts by name", async () => {
|
|
5
12
|
const api: GcpApi = {
|
|
@@ -105,3 +112,70 @@ test("createProject and attachBillingAccount call the expected endpoints", async
|
|
|
105
112
|
expect(calls[0]).toBe("create:anmho-test:test");
|
|
106
113
|
expect(calls[1]).toBe("billing:anmho-test:billingAccounts/123");
|
|
107
114
|
});
|
|
115
|
+
|
|
116
|
+
test("assertExistingProjectReadyForAutoDeploy passes when project exists with billing", async () => {
|
|
117
|
+
const api: GcpApi = {
|
|
118
|
+
async listProjects() {
|
|
119
|
+
return [];
|
|
120
|
+
},
|
|
121
|
+
async listBillingAccounts() {
|
|
122
|
+
return [];
|
|
123
|
+
},
|
|
124
|
+
async describeProject(projectId) {
|
|
125
|
+
return { projectId, name: "services" };
|
|
126
|
+
},
|
|
127
|
+
async describeBillingProject() {
|
|
128
|
+
return { billingEnabled: true, billingAccountName: "billingAccounts/123" };
|
|
129
|
+
},
|
|
130
|
+
async createProject() {},
|
|
131
|
+
async attachBillingAccount() {},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
await expect(assertExistingProjectReadyForAutoDeploy("anmho-services", api)).resolves.toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("assertExistingProjectReadyForAutoDeploy rejects missing projects", async () => {
|
|
138
|
+
const api: GcpApi = {
|
|
139
|
+
async listProjects() {
|
|
140
|
+
return [];
|
|
141
|
+
},
|
|
142
|
+
async listBillingAccounts() {
|
|
143
|
+
return [];
|
|
144
|
+
},
|
|
145
|
+
async describeProject() {
|
|
146
|
+
throw new Error("not found");
|
|
147
|
+
},
|
|
148
|
+
async describeBillingProject() {
|
|
149
|
+
return { billingEnabled: true };
|
|
150
|
+
},
|
|
151
|
+
async createProject() {},
|
|
152
|
+
async attachBillingAccount() {},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
await expect(assertExistingProjectReadyForAutoDeploy("anmho-services", api)).rejects.toThrow(
|
|
156
|
+
"GCP project anmho-services does not exist or is not accessible"
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("assertExistingProjectReadyForAutoDeploy rejects projects without billing", async () => {
|
|
161
|
+
const api: GcpApi = {
|
|
162
|
+
async listProjects() {
|
|
163
|
+
return [];
|
|
164
|
+
},
|
|
165
|
+
async listBillingAccounts() {
|
|
166
|
+
return [];
|
|
167
|
+
},
|
|
168
|
+
async describeProject(projectId) {
|
|
169
|
+
return { projectId, name: "services" };
|
|
170
|
+
},
|
|
171
|
+
async describeBillingProject() {
|
|
172
|
+
return { billingEnabled: false };
|
|
173
|
+
},
|
|
174
|
+
async createProject() {},
|
|
175
|
+
async attachBillingAccount() {},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
await expect(assertExistingProjectReadyForAutoDeploy("anmho-services", api)).rejects.toThrow(
|
|
179
|
+
"GCP project anmho-services exists but billing is not enabled"
|
|
180
|
+
);
|
|
181
|
+
});
|
package/src/gcp.ts
CHANGED
|
@@ -10,9 +10,16 @@ export type BillingAccount = {
|
|
|
10
10
|
open: boolean;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
export type BillingProject = {
|
|
14
|
+
billingEnabled?: boolean;
|
|
15
|
+
billingAccountName?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
13
18
|
export type GcpApi = {
|
|
14
19
|
listProjects(): Promise<GcpProject[]>;
|
|
15
20
|
listBillingAccounts(): Promise<BillingAccount[]>;
|
|
21
|
+
describeProject?(projectId: string): Promise<GcpProject>;
|
|
22
|
+
describeBillingProject?(projectId: string): Promise<BillingProject>;
|
|
16
23
|
createProject(projectId: string, name: string): Promise<void>;
|
|
17
24
|
attachBillingAccount(projectId: string, billingAccountName: string): Promise<void>;
|
|
18
25
|
};
|
|
@@ -33,6 +40,20 @@ export function createGcpApi(): GcpApi {
|
|
|
33
40
|
);
|
|
34
41
|
},
|
|
35
42
|
|
|
43
|
+
async describeProject(projectId: string) {
|
|
44
|
+
return parseJson<GcpProject>(
|
|
45
|
+
runGcloud(["projects", "describe", projectId, "--format=json(projectId,name,lifecycleState)"]).stdout,
|
|
46
|
+
"GCP project"
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
async describeBillingProject(projectId: string) {
|
|
51
|
+
return parseJson<BillingProject>(
|
|
52
|
+
runGcloud(["beta", "billing", "projects", "describe", projectId, "--format=json(billingEnabled,billingAccountName)"]).stdout,
|
|
53
|
+
"GCP project billing"
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
|
|
36
57
|
async createProject(projectId: string, name: string) {
|
|
37
58
|
runGcloud(["projects", "create", projectId, "--name", name]);
|
|
38
59
|
},
|
|
@@ -64,6 +85,46 @@ export async function attachBillingAccount(projectId: string, billingAccountName
|
|
|
64
85
|
await api.attachBillingAccount(projectId, billingAccountName);
|
|
65
86
|
}
|
|
66
87
|
|
|
88
|
+
export async function assertExistingProjectReadyForAutoDeploy(projectId: string, api = createGcpApi()) {
|
|
89
|
+
try {
|
|
90
|
+
await api.describeProject?.(projectId);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
[
|
|
94
|
+
`GCP project ${projectId} does not exist or is not accessible.`,
|
|
95
|
+
"Create and enable billing on that project before one-shot create, pass --project-id <billed-project>, or pass --no-auto-deploy.",
|
|
96
|
+
formatErrorDetail(error),
|
|
97
|
+
]
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.join("\n")
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let billing: BillingProject;
|
|
104
|
+
try {
|
|
105
|
+
billing = (await api.describeBillingProject?.(projectId)) ?? {};
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
[
|
|
109
|
+
`Unable to verify billing for GCP project ${projectId}.`,
|
|
110
|
+
"Fix billing access before one-shot create, pass --project-id <billed-project>, or pass --no-auto-deploy.",
|
|
111
|
+
formatErrorDetail(error),
|
|
112
|
+
]
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.join("\n")
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!billing.billingEnabled) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
[
|
|
121
|
+
`GCP project ${projectId} exists but billing is not enabled.`,
|
|
122
|
+
"Link billing before one-shot create, pass --project-id <billed-project>, or pass --no-auto-deploy.",
|
|
123
|
+
].join("\n")
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
67
128
|
function runGcloud(args: string[]) {
|
|
68
129
|
const result = Bun.spawnSync(["gcloud", ...args], {
|
|
69
130
|
stdout: "pipe",
|
|
@@ -87,6 +148,11 @@ function accountSortName(account: BillingAccount) {
|
|
|
87
148
|
return account.displayName || account.name;
|
|
88
149
|
}
|
|
89
150
|
|
|
151
|
+
function formatErrorDetail(error: unknown) {
|
|
152
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
153
|
+
return message ? `Details: ${message}` : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
90
156
|
function parseJson<T>(value: string, label: string): T {
|
|
91
157
|
try {
|
|
92
158
|
return JSON.parse(value) as T;
|
package/src/scaffold.test.ts
CHANGED
|
@@ -70,6 +70,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
70
70
|
expect(serviceConfig).toContain('"api_key_secret_name": "dns-api-temporal-api-key"');
|
|
71
71
|
expect(serviceConfig).toContain('"project_mode": "create_new"');
|
|
72
72
|
expect(serviceConfig).toContain('"quota_project_id": "anmho-infra-prod"');
|
|
73
|
+
expect(serviceConfig).toContain('"artifact_repository": "cloud-run"');
|
|
74
|
+
expect(serviceConfig).not.toContain("cloudbuild.googleapis.com");
|
|
73
75
|
expect(serviceConfig).toContain('"jwks_url": "https://auth.anmho.com/api/auth/jwks"');
|
|
74
76
|
expect(serviceConfig).toContain('"git": {');
|
|
75
77
|
expect(serviceConfig).toContain('"repository": "dns-api"');
|
|
@@ -75,8 +75,9 @@ export async function cleanup(args = Bun.argv.slice(2)) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
await requireDestroyConfirmation(options.force);
|
|
78
|
+
const deleteGitHubRepository = await confirmGitHubRepositoryDeletion(plan.githubRepository, options.force);
|
|
78
79
|
|
|
79
|
-
await deletePlannedResources(plan);
|
|
80
|
+
await deletePlannedResources(plan, { deleteGitHubRepository });
|
|
80
81
|
|
|
81
82
|
if (options.destroyProject) {
|
|
82
83
|
await runStep(`Deleting GCP project ${config.project.id}`, () => deleteProject());
|
|
@@ -87,7 +88,7 @@ export async function cleanup(args = Bun.argv.slice(2)) {
|
|
|
87
88
|
return `Destroy finished for ${config.serviceName}`;
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
async function deletePlannedResources(plan: DestroyPlan) {
|
|
91
|
+
async function deletePlannedResources(plan: DestroyPlan, options: { deleteGitHubRepository: boolean }) {
|
|
91
92
|
const tasks: ParallelTask[] = [
|
|
92
93
|
{
|
|
93
94
|
label: "Stopping local dev resources",
|
|
@@ -135,7 +136,7 @@ async function deletePlannedResources(plan: DestroyPlan) {
|
|
|
135
136
|
},
|
|
136
137
|
];
|
|
137
138
|
|
|
138
|
-
if (plan.githubRepository) {
|
|
139
|
+
if (plan.githubRepository && options.deleteGitHubRepository) {
|
|
139
140
|
tasks.push({
|
|
140
141
|
label: `Deleting GitHub repository ${plan.githubRepository}`,
|
|
141
142
|
task: () => deleteGitHubRepository(plan.githubRepository!),
|
|
@@ -246,7 +247,10 @@ function planGitHubRepository(plan: DestroyPlan) {
|
|
|
246
247
|
}
|
|
247
248
|
|
|
248
249
|
plan.githubRepository = repository;
|
|
249
|
-
plan.
|
|
250
|
+
plan.skipped.push({
|
|
251
|
+
label: `GitHub repository ${repository}`,
|
|
252
|
+
detail: "owned by this service CLI run; deletion is optional and defaults to no",
|
|
253
|
+
});
|
|
250
254
|
}
|
|
251
255
|
|
|
252
256
|
function deleteGitHubRepository(repository: string) {
|
|
@@ -400,6 +404,41 @@ async function requireDestroyConfirmation(force: boolean) {
|
|
|
400
404
|
}
|
|
401
405
|
}
|
|
402
406
|
|
|
407
|
+
async function confirmGitHubRepositoryDeletion(repository: string | undefined, force: boolean) {
|
|
408
|
+
if (!repository) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (force) {
|
|
413
|
+
log.step(`Keeping GitHub repository ${repository}; delete manually with: ${manualGitHubDeleteCommand(repository)}`);
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!process.stdin.isTTY) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const deleteAnswer = await confirm({
|
|
422
|
+
message: `Delete GitHub repository ${repository}?`,
|
|
423
|
+
initialValue: false,
|
|
424
|
+
});
|
|
425
|
+
if (isCancel(deleteAnswer) || !deleteAnswer) {
|
|
426
|
+
log.step(`Keeping GitHub repository ${repository}; delete manually with: ${manualGitHubDeleteCommand(repository)}`);
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const confirmAnswer = await confirm({
|
|
431
|
+
message: `Confirm deleting GitHub repository ${repository}? This cannot be undone.`,
|
|
432
|
+
initialValue: false,
|
|
433
|
+
});
|
|
434
|
+
if (isCancel(confirmAnswer) || !confirmAnswer) {
|
|
435
|
+
log.step(`Keeping GitHub repository ${repository}; delete manually with: ${manualGitHubDeleteCommand(repository)}`);
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
|
|
403
442
|
if (import.meta.main) {
|
|
404
443
|
await runMain("Destroy", () => cleanup(Bun.argv.slice(2)));
|
|
405
444
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export type DeployArgs = {
|
|
2
|
+
build: "local" | "cloudbuild";
|
|
3
|
+
ci: boolean;
|
|
4
|
+
destroy: boolean;
|
|
5
|
+
environment: "main" | "preview" | "personal";
|
|
6
|
+
name?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function parseDeployArgs(argv: string[]): DeployArgs {
|
|
10
|
+
const parsed: DeployArgs = {
|
|
11
|
+
build: parseBuildStrategy(process.env.SERVICE_BUILD_STRATEGY || process.env.SERVICE_BUILD),
|
|
12
|
+
ci: false,
|
|
13
|
+
destroy: false,
|
|
14
|
+
environment: "main",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
18
|
+
const token = argv[i];
|
|
19
|
+
if (!token) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const next = argv[i + 1];
|
|
24
|
+
const readValue = () => {
|
|
25
|
+
if (!next || next.startsWith("-")) {
|
|
26
|
+
throw new Error(`Missing value for ${token}`);
|
|
27
|
+
}
|
|
28
|
+
i += 1;
|
|
29
|
+
return next;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (token === "--ci") {
|
|
33
|
+
parsed.ci = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (token === "--destroy") {
|
|
38
|
+
parsed.destroy = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (token === "--build") {
|
|
43
|
+
parsed.build = parseBuildStrategy(readValue());
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (token.startsWith("--build=")) {
|
|
48
|
+
parsed.build = parseBuildStrategy(token.slice("--build=".length));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (token === "--cloud-build") {
|
|
53
|
+
parsed.build = "cloudbuild";
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (token === "--environment") {
|
|
58
|
+
parsed.environment = readValue() as DeployArgs["environment"];
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (token.startsWith("--environment=")) {
|
|
63
|
+
parsed.environment = token.slice("--environment=".length) as DeployArgs["environment"];
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (token === "--name") {
|
|
68
|
+
parsed.name = readValue();
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (token.startsWith("--name=")) {
|
|
73
|
+
parsed.name = token.slice("--name=".length);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseBuildStrategy(value: string | undefined): DeployArgs["build"] {
|
|
82
|
+
if (!value || value === "local") {
|
|
83
|
+
return "local";
|
|
84
|
+
}
|
|
85
|
+
if (value === "cloudbuild" || value === "cloud-build") {
|
|
86
|
+
return "cloudbuild";
|
|
87
|
+
}
|
|
88
|
+
throw new Error(`Unknown build strategy: ${value}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
@@ -4,6 +4,7 @@ import { deleteBranch, ensureBranch, ensureDatabase, getConnectionUri, listBranc
|
|
|
4
4
|
import {
|
|
5
5
|
addSecretVersion,
|
|
6
6
|
deleteService,
|
|
7
|
+
dockerStreaming,
|
|
7
8
|
ensureArtifactRepository,
|
|
8
9
|
ensureProductionDomainMapping,
|
|
9
10
|
ensureSecretAccessor,
|
|
@@ -74,9 +75,18 @@ export async function deploy(args = Bun.argv.slice(2), deployOptions: DeployOpti
|
|
|
74
75
|
});
|
|
75
76
|
}
|
|
76
77
|
const image = imageUrl();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
if (options.build === "cloudbuild") {
|
|
79
|
+
await runStep("Building container image in Cloud Build", () =>
|
|
80
|
+
gcloudStreaming(["builds", "submit", "--project", config.project.id, "--region", config.region, "--tag", image])
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
requireCommand("docker");
|
|
84
|
+
await runStep("Authenticating Docker to Artifact Registry", () =>
|
|
85
|
+
gcloud(["auth", "configure-docker", `${config.region}-docker.pkg.dev`, "--quiet"])
|
|
86
|
+
);
|
|
87
|
+
await runStep("Building container image locally", () => dockerStreaming(["build", "-t", image, "."]));
|
|
88
|
+
await runStep("Pushing container image to Artifact Registry", () => dockerStreaming(["push", image]));
|
|
89
|
+
}
|
|
80
90
|
|
|
81
91
|
const renderedManifestPath = await runStep("Rendering Cloud Run manifest", () => writeRenderedManifest(image, target));
|
|
82
92
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { parseDeployArgs } from "./deploy-args";
|
|
3
|
+
|
|
4
|
+
const originalBuild = process.env.SERVICE_BUILD;
|
|
5
|
+
const originalBuildStrategy = process.env.SERVICE_BUILD_STRATEGY;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
process.env.SERVICE_BUILD = originalBuild;
|
|
9
|
+
process.env.SERVICE_BUILD_STRATEGY = originalBuildStrategy;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("parseDeployArgs defaults to local image builds", () => {
|
|
13
|
+
delete process.env.SERVICE_BUILD;
|
|
14
|
+
delete process.env.SERVICE_BUILD_STRATEGY;
|
|
15
|
+
|
|
16
|
+
expect(parseDeployArgs([]).build).toBe("local");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("parseDeployArgs accepts explicit Cloud Build fallback", () => {
|
|
20
|
+
expect(parseDeployArgs(["--build", "cloudbuild"]).build).toBe("cloudbuild");
|
|
21
|
+
expect(parseDeployArgs(["--build=cloud-build"]).build).toBe("cloudbuild");
|
|
22
|
+
expect(parseDeployArgs(["--cloud-build"]).build).toBe("cloudbuild");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("parseDeployArgs accepts build strategy from env", () => {
|
|
26
|
+
process.env.SERVICE_BUILD_STRATEGY = "cloudbuild";
|
|
27
|
+
|
|
28
|
+
expect(parseDeployArgs([]).build).toBe("cloudbuild");
|
|
29
|
+
});
|
|
@@ -2,6 +2,7 @@ import { intro, log, outro, spinner } from "@clack/prompts";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { config } from "./config";
|
|
4
4
|
import { serviceRoot } from "../runtime";
|
|
5
|
+
import { parseDeployArgs, type DeployArgs } from "./deploy-args";
|
|
5
6
|
|
|
6
7
|
type CommandOptions = {
|
|
7
8
|
allowFailure?: boolean;
|
|
@@ -9,13 +10,6 @@ type CommandOptions = {
|
|
|
9
10
|
env?: Record<string, string | undefined>;
|
|
10
11
|
};
|
|
11
12
|
|
|
12
|
-
type DeployArgs = {
|
|
13
|
-
ci: boolean;
|
|
14
|
-
destroy: boolean;
|
|
15
|
-
environment: "main" | "preview" | "personal";
|
|
16
|
-
name?: string;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
13
|
type CleanupArgs = {
|
|
20
14
|
destroyProject: boolean;
|
|
21
15
|
force: boolean;
|
|
@@ -125,6 +119,10 @@ export async function gcloudStreaming(args: string[], options: CommandOptions =
|
|
|
125
119
|
return runStreaming("gcloud", normalized, options);
|
|
126
120
|
}
|
|
127
121
|
|
|
122
|
+
export async function dockerStreaming(args: string[], options: CommandOptions = {}) {
|
|
123
|
+
return runStreaming("docker", args, options);
|
|
124
|
+
}
|
|
125
|
+
|
|
128
126
|
export async function runStreaming(command: string, args: string[], options: CommandOptions = {}): Promise<CommandResult> {
|
|
129
127
|
const child = Bun.spawn([command, ...args], {
|
|
130
128
|
cwd: process.cwd(),
|
|
@@ -454,61 +452,7 @@ export function imageUrl(tag = imageTag()) {
|
|
|
454
452
|
return `${artifactImageBase()}:${tag}`;
|
|
455
453
|
}
|
|
456
454
|
|
|
457
|
-
export
|
|
458
|
-
const parsed: DeployArgs = {
|
|
459
|
-
ci: false,
|
|
460
|
-
destroy: false,
|
|
461
|
-
environment: "main",
|
|
462
|
-
};
|
|
463
|
-
|
|
464
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
465
|
-
const token = argv[i];
|
|
466
|
-
if (!token) {
|
|
467
|
-
continue;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
const next = argv[i + 1];
|
|
471
|
-
const readValue = () => {
|
|
472
|
-
if (!next || next.startsWith("-")) {
|
|
473
|
-
throw new Error(`Missing value for ${token}`);
|
|
474
|
-
}
|
|
475
|
-
i += 1;
|
|
476
|
-
return next;
|
|
477
|
-
};
|
|
478
|
-
|
|
479
|
-
if (token === "--ci") {
|
|
480
|
-
parsed.ci = true;
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (token === "--destroy") {
|
|
485
|
-
parsed.destroy = true;
|
|
486
|
-
continue;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
if (token === "--environment") {
|
|
490
|
-
parsed.environment = readValue() as DeployArgs["environment"];
|
|
491
|
-
continue;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
if (token.startsWith("--environment=")) {
|
|
495
|
-
parsed.environment = token.slice("--environment=".length) as DeployArgs["environment"];
|
|
496
|
-
continue;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
if (token === "--name") {
|
|
500
|
-
parsed.name = readValue();
|
|
501
|
-
continue;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
if (token.startsWith("--name=")) {
|
|
505
|
-
parsed.name = token.slice("--name=".length);
|
|
506
|
-
continue;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
return parsed;
|
|
511
|
-
}
|
|
455
|
+
export { parseDeployArgs };
|
|
512
456
|
|
|
513
457
|
export function parseCleanupArgs(argv: string[]): CleanupArgs {
|
|
514
458
|
const parsed: CleanupArgs = {
|
|
@@ -97,7 +97,9 @@ export async function main(argv = Bun.argv.slice(2)) {
|
|
|
97
97
|
await requireDestroyConfirmation(rest.includes("--force"));
|
|
98
98
|
const wranglerArgs = rest.filter((arg) => arg !== "--force");
|
|
99
99
|
await stopLocalDev({ dockerCompose: false });
|
|
100
|
-
|
|
100
|
+
if (await confirmGitHubRepositoryDeletion(rest.includes("--force"))) {
|
|
101
|
+
deleteGitHubRepositoryIfOwned();
|
|
102
|
+
}
|
|
101
103
|
await deleteHyperdrive();
|
|
102
104
|
run("wrangler", ["delete", "--name", config.serviceName, "--force", ...wranglerArgs]);
|
|
103
105
|
await deleteNeonDatabase();
|
|
@@ -152,6 +154,47 @@ function deleteGitHubRepositoryIfOwned() {
|
|
|
152
154
|
run("gh", ["repo", "delete", repository, "--yes"]);
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
async function confirmGitHubRepositoryDeletion(force: boolean) {
|
|
158
|
+
const repository = `${config.git.owner}/${config.git.repository}`;
|
|
159
|
+
if (!config.git.deleteOnDestroy) {
|
|
160
|
+
log.step(
|
|
161
|
+
`Skipping GitHub repository ${repository}: ${
|
|
162
|
+
config.git.enabled ? `not created by this service CLI run; manual cleanup: ${manualGitHubDeleteCommand(repository)}` : "git disabled"
|
|
163
|
+
}`
|
|
164
|
+
);
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (force) {
|
|
169
|
+
log.step(`Keeping GitHub repository ${repository}; delete manually with: ${manualGitHubDeleteCommand(repository)}`);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!process.stdin.isTTY) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const deleteAnswer = await confirm({
|
|
178
|
+
message: `Delete GitHub repository ${repository}?`,
|
|
179
|
+
initialValue: false,
|
|
180
|
+
});
|
|
181
|
+
if (isCancel(deleteAnswer) || !deleteAnswer) {
|
|
182
|
+
log.step(`Keeping GitHub repository ${repository}; delete manually with: ${manualGitHubDeleteCommand(repository)}`);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const confirmAnswer = await confirm({
|
|
187
|
+
message: `Confirm deleting GitHub repository ${repository}? This cannot be undone.`,
|
|
188
|
+
initialValue: false,
|
|
189
|
+
});
|
|
190
|
+
if (isCancel(confirmAnswer) || !confirmAnswer) {
|
|
191
|
+
log.step(`Keeping GitHub repository ${repository}; delete manually with: ${manualGitHubDeleteCommand(repository)}`);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
155
198
|
function run(command: string, args: string[], options: { allowFailure?: boolean; capture?: boolean } = {}) {
|
|
156
199
|
if (!Bun.which(command)) {
|
|
157
200
|
throw new Error(`missing required command: ${command}`);
|
|
@@ -66,10 +66,14 @@ Create, deploy, and destroy use:
|
|
|
66
66
|
|
|
67
67
|
- known-good CLIs first, especially `gcloud`
|
|
68
68
|
- `gcloud`
|
|
69
|
+
- Docker for local image builds and Artifact Registry pushes
|
|
69
70
|
- `NEON_API_KEY`, or a working Vault login via `VAULT_ADDR` plus `VAULT_TOKEN`, `VAULT_TOKEN_FILE`, or `~/.vault-token`
|
|
70
71
|
- the repo-aware `service` CLI from this package
|
|
71
72
|
|
|
72
73
|
Local provisioning intentionally prefers known-good CLIs over SDKs for Google Cloud operations.
|
|
74
|
+
Cloud Run deploys build and push the container image locally by default. Use
|
|
75
|
+
`service deploy --build cloudbuild` only when you explicitly want Google Cloud
|
|
76
|
+
Build to build the image remotely.
|
|
73
77
|
|
|
74
78
|
Authenticate `gcloud` on the machine before running provisioning commands:
|
|
75
79
|
|