create-svc 0.1.32 → 0.1.33
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 +7 -0
- package/src/git-bootstrap.test.ts +11 -2
- package/src/git-bootstrap.ts +11 -0
- package/src/scaffold.test.ts +3 -0
- package/src/scaffold.ts +3 -0
- package/src/service-runtime/cloudrun/cleanup.ts +42 -0
- package/src/service-runtime/cloudrun/config.ts +6 -0
- package/src/service-runtime/workers/cli.ts +22 -0
- package/templates/shared/service.jsonc +9 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
bootstrapGitHubRepository,
|
|
9
9
|
buildGitBootstrapConfig,
|
|
10
10
|
commitAndPushGeneratedArtifacts,
|
|
11
|
+
markGitHubRepositoryDeleteOnDestroy,
|
|
11
12
|
type GitBootstrapResult,
|
|
12
13
|
} from "./git-bootstrap";
|
|
13
14
|
import { listOpenBillingAccounts, listAccessibleProjects, type BillingAccount, type GcpProject } from "./gcp";
|
|
@@ -111,6 +112,7 @@ export async function run(argv: string[]) {
|
|
|
111
112
|
gitSpinner.start("Preparing git repository");
|
|
112
113
|
const gitResult = await bootstrapGitHubRepository(targetDir, config.git);
|
|
113
114
|
if (gitResult.status === "created") {
|
|
115
|
+
await markGitHubRepositoryDeleteOnDestroy(targetDir);
|
|
114
116
|
gitSpinner.stop(`GitHub repository created: ${gitResult.url}`);
|
|
115
117
|
} else if (gitResult.status === "skipped-existing-worktree") {
|
|
116
118
|
gitSpinner.stop(`Existing git worktree detected: ${gitResult.root}`);
|
|
@@ -136,6 +138,11 @@ export async function run(argv: string[]) {
|
|
|
136
138
|
const result = commitAndPushGeneratedArtifacts(targetDir, "Record generated deployment artifacts");
|
|
137
139
|
publishSpinner.stop(result.committed ? "Generated artifacts committed and pushed" : "Generated artifacts already committed");
|
|
138
140
|
}
|
|
141
|
+
} else if (gitResult.status === "created") {
|
|
142
|
+
const publishSpinner = spinner();
|
|
143
|
+
publishSpinner.start("Publishing generated git ownership");
|
|
144
|
+
const result = commitAndPushGeneratedArtifacts(targetDir, "Record generated GitHub ownership");
|
|
145
|
+
publishSpinner.stop(result.committed ? "GitHub ownership committed and pushed" : "GitHub ownership already committed");
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
outro(config.autoDeploy ? "Created and deployed" : "Created");
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import { mkdir, mkdtemp, realpath } from "node:fs/promises";
|
|
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 } from "./git-bootstrap";
|
|
5
|
+
import { buildGitBootstrapConfig, findExistingGitWorktree, markGitHubRepositoryDeleteOnDestroy } from "./git-bootstrap";
|
|
6
6
|
|
|
7
7
|
test("buildGitBootstrapConfig defaults to anmho private repo creation", () => {
|
|
8
8
|
expect(buildGitBootstrapConfig("launch-api", undefined)).toEqual({
|
|
@@ -28,6 +28,15 @@ test("findExistingGitWorktree detects parent repositories", async () => {
|
|
|
28
28
|
expect(findExistingGitWorktree(join(root, "apps", "launch-api"))).toBe(await realpath(root));
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
test("markGitHubRepositoryDeleteOnDestroy records generated repo ownership", async () => {
|
|
32
|
+
const root = await mkdtemp(join(tmpdir(), "create-svc-git-"));
|
|
33
|
+
await writeFile(join(root, "service.jsonc"), '{\n "git": { "delete_on_destroy": false }\n}\n');
|
|
34
|
+
|
|
35
|
+
await markGitHubRepositoryDeleteOnDestroy(root);
|
|
36
|
+
|
|
37
|
+
expect(await Bun.file(join(root, "service.jsonc")).text()).toContain('"delete_on_destroy": true');
|
|
38
|
+
});
|
|
39
|
+
|
|
31
40
|
function run(command: string[], cwd: string) {
|
|
32
41
|
const result = Bun.spawnSync(command, {
|
|
33
42
|
cwd,
|
package/src/git-bootstrap.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
4
|
|
|
4
5
|
export type GitBootstrapConfig = {
|
|
@@ -62,6 +63,16 @@ export function commitAndPushGeneratedArtifacts(targetDir: string, message: stri
|
|
|
62
63
|
return { committed: true };
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
export async function markGitHubRepositoryDeleteOnDestroy(targetDir: string) {
|
|
67
|
+
const path = `${targetDir}/service.jsonc`;
|
|
68
|
+
const text = await readFile(path, "utf8");
|
|
69
|
+
const updated = text.replace('"delete_on_destroy": false', '"delete_on_destroy": true');
|
|
70
|
+
if (updated === text) {
|
|
71
|
+
throw new Error("service.jsonc does not contain a delete_on_destroy marker");
|
|
72
|
+
}
|
|
73
|
+
await writeFile(path, updated);
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
export function findExistingGitWorktree(targetDir: string) {
|
|
66
77
|
const cwd = existingPath(targetDir);
|
|
67
78
|
const result = Bun.spawnSync(["git", "-C", cwd, "rev-parse", "--show-toplevel"], {
|
package/src/scaffold.test.ts
CHANGED
|
@@ -71,6 +71,9 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
71
71
|
expect(serviceConfig).toContain('"project_mode": "create_new"');
|
|
72
72
|
expect(serviceConfig).toContain('"quota_project_id": "anmho-infra-prod"');
|
|
73
73
|
expect(serviceConfig).toContain('"jwks_url": "https://auth.anmho.com/api/auth/jwks"');
|
|
74
|
+
expect(serviceConfig).toContain('"git": {');
|
|
75
|
+
expect(serviceConfig).toContain('"repository": "dns-api"');
|
|
76
|
+
expect(serviceConfig).toContain('"delete_on_destroy": false');
|
|
74
77
|
expect(serviceConfig).toContain('"project_id": ""');
|
|
75
78
|
expect(serviceConfig).toContain('"base_branch_id": ""');
|
|
76
79
|
expect(serviceConfig).toContain('"base_branch_name": "main"');
|
package/src/scaffold.ts
CHANGED
|
@@ -209,6 +209,9 @@ function buildReplacements(config: ScaffoldConfig) {
|
|
|
209
209
|
RUNTIME_SERVICE_ACCOUNT: runtimeServiceAccount,
|
|
210
210
|
API_HOSTNAME: config.apiHostname,
|
|
211
211
|
API_BASE_DOMAIN: "anmho.com",
|
|
212
|
+
GIT_ENABLED: String(config.git.enabled),
|
|
213
|
+
GIT_OWNER: config.git.owner,
|
|
214
|
+
GIT_REPOSITORY: config.git.repository,
|
|
212
215
|
AUTH_ISSUER: authIssuer,
|
|
213
216
|
AUTH_AUDIENCE: authAudience,
|
|
214
217
|
AUTH_JWKS_URL: authJwksUrl,
|
|
@@ -45,6 +45,7 @@ type DestroyPlan = {
|
|
|
45
45
|
resources: PlannedResource[];
|
|
46
46
|
skipped: PlannedResource[];
|
|
47
47
|
blockers: string[];
|
|
48
|
+
githubRepository?: string;
|
|
48
49
|
hasProductionDomainMapping: boolean;
|
|
49
50
|
serviceNames: string[];
|
|
50
51
|
secretNames: string[];
|
|
@@ -69,6 +70,10 @@ export async function cleanup(args = Bun.argv.slice(2)) {
|
|
|
69
70
|
|
|
70
71
|
await requireDestroyConfirmation(options.force);
|
|
71
72
|
|
|
73
|
+
if (plan.githubRepository) {
|
|
74
|
+
await runStep(`Deleting GitHub repository ${plan.githubRepository}`, () => deleteGitHubRepository(plan.githubRepository!));
|
|
75
|
+
}
|
|
76
|
+
|
|
72
77
|
await runStep(`Deleting auth resource server ${config.serviceName}`, () => deleteAuthResourceServer());
|
|
73
78
|
|
|
74
79
|
if (plan.hasProductionDomainMapping) {
|
|
@@ -125,11 +130,13 @@ async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
|
|
|
125
130
|
],
|
|
126
131
|
skipped: [],
|
|
127
132
|
blockers: [],
|
|
133
|
+
githubRepository: undefined,
|
|
128
134
|
hasProductionDomainMapping: false,
|
|
129
135
|
serviceNames: [],
|
|
130
136
|
secretNames: [],
|
|
131
137
|
};
|
|
132
138
|
|
|
139
|
+
planGitHubRepository(plan);
|
|
133
140
|
planProductionDomainMapping(plan);
|
|
134
141
|
planCloudRunServices(plan);
|
|
135
142
|
planSecrets(plan);
|
|
@@ -143,6 +150,41 @@ async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
|
|
|
143
150
|
return plan;
|
|
144
151
|
}
|
|
145
152
|
|
|
153
|
+
function planGitHubRepository(plan: DestroyPlan) {
|
|
154
|
+
const repository = `${config.git.owner}/${config.git.repository}`;
|
|
155
|
+
if (!config.git.deleteOnDestroy) {
|
|
156
|
+
plan.skipped.push({
|
|
157
|
+
label: `GitHub repository ${repository}`,
|
|
158
|
+
detail: config.git.enabled ? "not created by this service CLI run" : "git disabled",
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!Bun.which("gh")) {
|
|
164
|
+
plan.blockers.push(`GitHub repository ${repository}: missing required command gh`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const auth = run("gh", ["auth", "status"], { allowFailure: true });
|
|
169
|
+
if (!auth.success) {
|
|
170
|
+
plan.blockers.push(`GitHub repository ${repository}: authenticate GitHub CLI with gh auth login`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const view = run("gh", ["repo", "view", repository, "--json", "name"], { allowFailure: true });
|
|
175
|
+
if (!view.success) {
|
|
176
|
+
plan.skipped.push({ label: `GitHub repository ${repository}`, detail: "not found" });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
plan.githubRepository = repository;
|
|
181
|
+
plan.resources.push({ label: `GitHub repository ${repository}`, detail: "private generated repo" });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function deleteGitHubRepository(repository: string) {
|
|
185
|
+
run("gh", ["repo", "delete", repository, "--yes"], { allowFailure: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
146
188
|
function planProductionDomainMapping(plan: DestroyPlan) {
|
|
147
189
|
try {
|
|
148
190
|
const mapping = describeProductionDomainMapping();
|
|
@@ -49,6 +49,12 @@ export const config = {
|
|
|
49
49
|
previewBranchPrefix: neon.preview_branch_prefix,
|
|
50
50
|
personalBranchPrefix: neon.personal_branch_prefix,
|
|
51
51
|
},
|
|
52
|
+
git: {
|
|
53
|
+
enabled: Boolean(serviceConfig.git?.enabled),
|
|
54
|
+
owner: serviceConfig.git?.owner || "anmho",
|
|
55
|
+
repository: serviceConfig.git?.repository || serviceConfig.service_id,
|
|
56
|
+
deleteOnDestroy: Boolean(serviceConfig.git?.delete_on_destroy),
|
|
57
|
+
},
|
|
52
58
|
requiredApis: cloudrun.required_apis,
|
|
53
59
|
} as const;
|
|
54
60
|
|
|
@@ -11,6 +11,12 @@ const config = {
|
|
|
11
11
|
hostname: serviceConfig.dns.hostname,
|
|
12
12
|
neonDatabaseName: serviceConfig.neon.database_name,
|
|
13
13
|
neonRoleName: serviceConfig.neon.role_name,
|
|
14
|
+
git: {
|
|
15
|
+
enabled: Boolean(serviceConfig.git?.enabled),
|
|
16
|
+
owner: serviceConfig.git?.owner || "anmho",
|
|
17
|
+
repository: serviceConfig.git?.repository || serviceConfig.service_id,
|
|
18
|
+
deleteOnDestroy: Boolean(serviceConfig.git?.delete_on_destroy),
|
|
19
|
+
},
|
|
14
20
|
};
|
|
15
21
|
|
|
16
22
|
type DoctorStatus = "pass" | "warn" | "fail";
|
|
@@ -81,6 +87,7 @@ export async function main(argv = Bun.argv.slice(2)) {
|
|
|
81
87
|
return runMain("Destroy", async () => {
|
|
82
88
|
await requireDestroyConfirmation(rest.includes("--force"));
|
|
83
89
|
const wranglerArgs = rest.filter((arg) => arg !== "--force");
|
|
90
|
+
deleteGitHubRepositoryIfOwned();
|
|
84
91
|
await deleteHyperdrive();
|
|
85
92
|
run("wrangler", ["delete", "--name", config.serviceName, "--force", ...wranglerArgs]);
|
|
86
93
|
await deleteNeonDatabase();
|
|
@@ -115,6 +122,21 @@ function formatHelp() {
|
|
|
115
122
|
].join("\n");
|
|
116
123
|
}
|
|
117
124
|
|
|
125
|
+
function deleteGitHubRepositoryIfOwned() {
|
|
126
|
+
const repository = `${config.git.owner}/${config.git.repository}`;
|
|
127
|
+
if (!config.git.deleteOnDestroy) {
|
|
128
|
+
log.step(`Skipping GitHub repository ${repository}: ${config.git.enabled ? "not created by this service CLI run" : "git disabled"}`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
run("gh", ["auth", "status"], { capture: true });
|
|
132
|
+
const view = run("gh", ["repo", "view", repository, "--json", "name"], { allowFailure: true, capture: true });
|
|
133
|
+
if (!view.success) {
|
|
134
|
+
log.step(`Skipping GitHub repository ${repository}: not found`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
run("gh", ["repo", "delete", repository, "--yes"]);
|
|
138
|
+
}
|
|
139
|
+
|
|
118
140
|
function run(command: string, args: string[], options: { allowFailure?: boolean; capture?: boolean } = {}) {
|
|
119
141
|
if (!Bun.which(command)) {
|
|
120
142
|
throw new Error(`missing required command: ${command}`);
|
|
@@ -27,6 +27,15 @@
|
|
|
27
27
|
"service_id": "{{SERVICE_ID}}"
|
|
28
28
|
},
|
|
29
29
|
|
|
30
|
+
"git": {
|
|
31
|
+
"enabled": {{GIT_ENABLED}},
|
|
32
|
+
"owner": "{{GIT_OWNER}}",
|
|
33
|
+
"repository": "{{GIT_REPOSITORY}}",
|
|
34
|
+
// This flips to true only after `service create` actually creates the
|
|
35
|
+
// GitHub repository. Existing worktrees and --no-git stay false.
|
|
36
|
+
"delete_on_destroy": false
|
|
37
|
+
},
|
|
38
|
+
|
|
30
39
|
"auth": {
|
|
31
40
|
"issuer": "{{AUTH_ISSUER}}",
|
|
32
41
|
"token_endpoint": "https://auth.anmho.com/api/auth/oauth2/token",
|