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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
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",
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,
@@ -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"], {
@@ -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",