create-svc 0.1.37 → 0.1.39

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.37",
3
+ "version": "0.1.39",
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
@@ -11,7 +11,13 @@ import {
11
11
  markGitHubRepositoryDeleteOnDestroy,
12
12
  type GitBootstrapResult,
13
13
  } from "./git-bootstrap";
14
- import { listOpenBillingAccounts, listAccessibleProjects, type BillingAccount, type GcpProject } from "./gcp";
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 { attachBillingAccount, createProject, listAccessibleProjects, listOpenBillingAccounts, type GcpApi } from "./gcp";
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;
@@ -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.resources.push({ label: `GitHub repository ${repository}`, detail: "private generated repo" });
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
  }
@@ -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
- deleteGitHubRepositoryIfOwned();
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}`);
@@ -2,13 +2,12 @@ import { expect, test } from "bun:test";
2
2
  import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { findGeneratedServiceRoot, generatedDependenciesInstalled, normalizeScaffoldArgs } from "./service";
5
+ import { findGeneratedServiceRoot, formatOutsideServiceCommandError, generatedDependenciesInstalled, normalizeScaffoldArgs } from "./service";
6
6
 
7
- test("normalizeScaffoldArgs treats service create as the scaffold command outside a service repo", () => {
7
+ test("normalizeScaffoldArgs treats explicit scaffold commands as generator commands", () => {
8
8
  expect(normalizeScaffoldArgs(["create", "launch-api", "--yes"])).toEqual(["launch-api", "--yes"]);
9
9
  expect(normalizeScaffoldArgs(["new", "launch-api"])).toEqual(["launch-api"]);
10
10
  expect(normalizeScaffoldArgs(["init", "launch-api"])).toEqual(["launch-api"]);
11
- expect(normalizeScaffoldArgs(["launch-api", "--yes"])).toEqual(["launch-api", "--yes"]);
12
11
  });
13
12
 
14
13
  test("normalizeScaffoldArgs maps service help to generator help outside a service repo", () => {
@@ -16,6 +15,17 @@ test("normalizeScaffoldArgs maps service help to generator help outside a servic
16
15
  expect(normalizeScaffoldArgs(["help", "--verbose"])).toEqual(["--help", "--verbose"]);
17
16
  });
18
17
 
18
+ test("formatOutsideServiceCommandError rejects repo-local commands outside generated services", () => {
19
+ expect(formatOutsideServiceCommandError("destroy")).toContain("service destroy must be run inside a generated service repo");
20
+ expect(formatOutsideServiceCommandError("deploy")).toContain("No service.jsonc was found");
21
+ });
22
+
23
+ test("formatOutsideServiceCommandError does not treat positional names as scaffold commands", () => {
24
+ const message = formatOutsideServiceCommandError("launch-api");
25
+ expect(message).toContain("Unknown command: launch-api");
26
+ expect(message).toContain("service create <service_id>");
27
+ });
28
+
19
29
  test("findGeneratedServiceRoot detects generated service context from nested directories", async () => {
20
30
  const root = await mkdtemp(join(tmpdir(), "create-svc-service-root-"));
21
31
  const serviceRoot = join(root, "generated-api");
package/src/service.ts CHANGED
@@ -1,9 +1,22 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { run as runScaffoldCli } from "./cli";
3
+ import { formatScaffoldHelp, run as runScaffoldCli } from "./cli";
4
4
  import { parseJsonc } from "./jsonc";
5
5
 
6
6
  const SCAFFOLD_COMMANDS = new Set(["create", "new", "init"]);
7
+ const GENERATED_SERVICE_COMMANDS = new Set([
8
+ "auth",
9
+ "create",
10
+ "dashboards",
11
+ "deploy",
12
+ "destroy",
13
+ "dev",
14
+ "dns",
15
+ "doctor",
16
+ "migrate",
17
+ "sdk",
18
+ "seed",
19
+ ]);
7
20
 
8
21
  export async function runServiceCommand(argv: string[], cwd = process.cwd()) {
9
22
  const serviceRoot = findGeneratedServiceRoot(cwd);
@@ -12,7 +25,19 @@ export async function runServiceCommand(argv: string[], cwd = process.cwd()) {
12
25
  return;
13
26
  }
14
27
 
15
- await runScaffoldCli(normalizeScaffoldArgs(argv));
28
+ const [command] = argv;
29
+ if (!command || command === "--help" || command === "-h" || command === "help") {
30
+ console.log(formatScaffoldHelp());
31
+ return;
32
+ }
33
+
34
+ if (SCAFFOLD_COMMANDS.has(command)) {
35
+ await runScaffoldCli(normalizeScaffoldArgs(argv));
36
+ return;
37
+ }
38
+
39
+ console.error(formatOutsideServiceCommandError(command));
40
+ process.exit(1);
16
41
  }
17
42
 
18
43
  export function normalizeScaffoldArgs(argv: string[]) {
@@ -26,6 +51,20 @@ export function normalizeScaffoldArgs(argv: string[]) {
26
51
  return argv;
27
52
  }
28
53
 
54
+ export function formatOutsideServiceCommandError(command: string) {
55
+ if (GENERATED_SERVICE_COMMANDS.has(command)) {
56
+ return [
57
+ `service ${command} must be run inside a generated service repo.`,
58
+ "",
59
+ "No service.jsonc was found in this directory or its parents.",
60
+ "To create a new service, run:",
61
+ " service create <service_id>",
62
+ ].join("\n");
63
+ }
64
+
65
+ return [`Unknown command: ${command}`, "", formatScaffoldHelp()].join("\n");
66
+ }
67
+
29
68
  export function findGeneratedServiceRoot(start: string): string | undefined {
30
69
  let current = start;
31
70
  while (true) {