create-svc 0.1.32 → 0.1.34
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 +6 -3
- package/package.json +1 -1
- package/src/cli.ts +8 -1
- package/src/git-bootstrap.test.ts +11 -2
- package/src/git-bootstrap.ts +11 -0
- package/src/post-scaffold.test.ts +2 -5
- package/src/post-scaffold.ts +1 -2
- package/src/scaffold.test.ts +3 -0
- package/src/scaffold.ts +4 -0
- package/src/service-runtime/cloudrun/bootstrap.ts +28 -5
- package/src/service-runtime/cloudrun/cleanup.ts +56 -0
- package/src/service-runtime/cloudrun/cli.ts +13 -4
- package/src/service-runtime/cloudrun/config.ts +6 -0
- package/src/service-runtime/cloudrun/deploy.ts +26 -16
- package/src/service-runtime/cloudrun/lib.ts +113 -0
- package/src/service-runtime/cloudrun/neon.ts +1 -1
- package/src/service-runtime/local-dev.test.ts +47 -0
- package/src/service-runtime/local-dev.ts +138 -0
- package/src/service-runtime/workers/cli.ts +32 -0
- package/templates/shared/README.md +3 -1
- package/templates/shared/service.jsonc +9 -0
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ service deploy
|
|
|
41
41
|
To install from npm:
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
|
|
44
|
+
npm install -g create-svc@latest
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
For the strict one-command production path:
|
|
@@ -51,8 +51,8 @@ service create my-service --yes
|
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
By default, that scaffolds the repo, installs dependencies, runs the generated
|
|
54
|
-
repo's `service create`,
|
|
55
|
-
`--no-auto-deploy` for scaffold-only generation.
|
|
54
|
+
repo's `service create`, deploys once, verifies production, starts local dev,
|
|
55
|
+
and verifies local. Pass `--no-auto-deploy` for scaffold-only generation.
|
|
56
56
|
|
|
57
57
|
`--profile microservice` is accepted as a compatibility no-op. App workspaces live outside this package in private app template repositories.
|
|
58
58
|
|
|
@@ -111,6 +111,7 @@ bun run lint
|
|
|
111
111
|
bun run test
|
|
112
112
|
service create
|
|
113
113
|
service deploy
|
|
114
|
+
service dev down
|
|
114
115
|
service destroy
|
|
115
116
|
```
|
|
116
117
|
|
|
@@ -124,10 +125,12 @@ make lint
|
|
|
124
125
|
make test
|
|
125
126
|
service create
|
|
126
127
|
service deploy
|
|
128
|
+
service dev down
|
|
127
129
|
service destroy
|
|
128
130
|
```
|
|
129
131
|
|
|
130
132
|
Language-specific tasks such as local running, linting, formatting, testing, and building stay in package scripts or Make targets. Service lifecycle operations are exposed through the generated `service` CLI.
|
|
133
|
+
`service destroy --force` also stops local dev and runs Docker Compose cleanup for generated Cloud Run services.
|
|
131
134
|
|
|
132
135
|
After `service create` has provisioned auth, the generated repo can mint a
|
|
133
136
|
client-credentials bearer token for smoke checks:
|
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");
|
|
@@ -1184,7 +1191,7 @@ export function formatScaffoldHelp() {
|
|
|
1184
1191
|
" --billing-account <name> Billing account resource name",
|
|
1185
1192
|
" --quota-project <id> Billing quota project for gcloud calls",
|
|
1186
1193
|
" --region <region> Cloud Run region",
|
|
1187
|
-
" --auto-deploy Scaffold, run service create,
|
|
1194
|
+
" --auto-deploy Scaffold, run service create, verify prod/local, and start local dev (default)",
|
|
1188
1195
|
" --no-auto-deploy Scaffold only",
|
|
1189
1196
|
" --no-git Skip default private GitHub repo: anmho/<service_id>",
|
|
1190
1197
|
" --yes, -y Accept defaults without prompts",
|
|
@@ -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"], {
|
|
@@ -2,25 +2,22 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { buildDeploymentVerificationCommands, buildLocalVerificationCommands, buildPostScaffoldCommands } from "./post-scaffold";
|
|
3
3
|
|
|
4
4
|
describe("buildPostScaffoldCommands", () => {
|
|
5
|
-
test("runs create
|
|
5
|
+
test("runs create for HTTP services", () => {
|
|
6
6
|
expect(buildPostScaffoldCommands({ framework: "hono" })).toEqual([
|
|
7
7
|
{ command: "service", args: ["create"] },
|
|
8
|
-
{ command: "service", args: ["deploy"] },
|
|
9
8
|
]);
|
|
10
9
|
});
|
|
11
10
|
|
|
12
|
-
test("builds SDK artifacts before create
|
|
11
|
+
test("builds SDK artifacts before create for ConnectRPC services", () => {
|
|
13
12
|
expect(buildPostScaffoldCommands({ framework: "connectrpc" })).toEqual([
|
|
14
13
|
{ command: "service", args: ["sdk", "build"] },
|
|
15
14
|
{ command: "service", args: ["create"] },
|
|
16
|
-
{ command: "service", args: ["deploy"] },
|
|
17
15
|
]);
|
|
18
16
|
});
|
|
19
17
|
|
|
20
18
|
test("uses the workers service CLI for workers services", () => {
|
|
21
19
|
expect(buildPostScaffoldCommands({ target: "workers", framework: "hono" })).toEqual([
|
|
22
20
|
{ command: "service", args: ["create"] },
|
|
23
|
-
{ command: "service", args: ["deploy"] },
|
|
24
21
|
]);
|
|
25
22
|
});
|
|
26
23
|
});
|
package/src/post-scaffold.ts
CHANGED
|
@@ -38,7 +38,7 @@ export async function runPostScaffoldFlow(config: ScaffoldConfig, cwd: string) {
|
|
|
38
38
|
for (const command of buildLocalVerificationCommands(config)) {
|
|
39
39
|
runWithRetries(command, { cwd, quiet: true }, 18, 5_000);
|
|
40
40
|
}
|
|
41
|
-
return { message: "Dependencies installed, service created,
|
|
41
|
+
return { message: "Dependencies installed, service created, production verified, and local dev started" };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
return { message: "Backend package generated" };
|
|
@@ -225,7 +225,6 @@ export function buildPostScaffoldCommands(
|
|
|
225
225
|
return [
|
|
226
226
|
...(config.target !== "workers" && config.framework === "connectrpc" ? [{ command: "service", args: ["sdk", "build"] }] : []),
|
|
227
227
|
{ command: "service", args: ["create"] },
|
|
228
|
-
{ command: "service", args: ["deploy"] },
|
|
229
228
|
];
|
|
230
229
|
}
|
|
231
230
|
|
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,
|
|
@@ -221,6 +224,7 @@ function buildReplacements(config: ScaffoldConfig) {
|
|
|
221
224
|
COMMAND_GEN: config.runtime === "bun" ? "bun run gen" : "make gen",
|
|
222
225
|
COMMAND_LINT: config.runtime === "bun" ? "bun run lint" : "make lint",
|
|
223
226
|
COMMAND_TEST: config.runtime === "bun" ? "bun run test" : "make test",
|
|
227
|
+
COMMAND_DEV_DOWN: "service dev down",
|
|
224
228
|
COMMAND_BOOTSTRAP: "service create",
|
|
225
229
|
COMMAND_DEPLOY: "service deploy",
|
|
226
230
|
COMMAND_AUTH_RESOURCE: "service auth resource-server",
|
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
import { config } from "./config";
|
|
2
|
-
import { ensureDatabase, getConnectionUri, resolveNeonConfig } from "./neon";
|
|
2
|
+
import { ensureDatabase, getConnectionUri, resolveNeonConfig, type ResolvedNeonConfig } from "./neon";
|
|
3
3
|
import {
|
|
4
4
|
addSecretVersion,
|
|
5
5
|
attachBilling,
|
|
6
6
|
ensureArtifactRepository,
|
|
7
7
|
ensureProject,
|
|
8
8
|
ensureProjectRole,
|
|
9
|
+
ensureRequiredApis,
|
|
9
10
|
ensureSecretAccessor,
|
|
10
11
|
ensureServiceAccount,
|
|
11
|
-
gcloud,
|
|
12
12
|
requireCommand,
|
|
13
13
|
requireGcloudAuth,
|
|
14
14
|
resolveDeploymentTarget,
|
|
15
15
|
resolveTemporalRuntimeConfig,
|
|
16
16
|
runMain,
|
|
17
17
|
runStep,
|
|
18
|
+
type DeploymentTarget,
|
|
18
19
|
} from "./lib";
|
|
19
20
|
|
|
21
|
+
export type BootstrapResult = {
|
|
22
|
+
target: DeploymentTarget;
|
|
23
|
+
neon: ResolvedNeonConfig;
|
|
24
|
+
databaseUrl: string;
|
|
25
|
+
artifactRepositoryReady: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
20
28
|
export async function bootstrap(options: { skipProjectSetup?: boolean } = {}) {
|
|
21
29
|
requireCommand("gcloud");
|
|
22
30
|
requireGcloudAuth();
|
|
@@ -39,7 +47,7 @@ export async function bootstrap(options: { skipProjectSetup?: boolean } = {}) {
|
|
|
39
47
|
const target = resolveDeploymentTarget("main");
|
|
40
48
|
await runStep("Ensuring Neon database", () => ensureDatabase(neon.projectId, neon.baseBranchId, neon.databaseName));
|
|
41
49
|
|
|
42
|
-
await runStep("Publishing database secret", async () => {
|
|
50
|
+
const databaseUrl = await runStep("Publishing database secret", async () => {
|
|
43
51
|
const connectionUri = await getConnectionUri(
|
|
44
52
|
neon.projectId,
|
|
45
53
|
neon.baseBranchId,
|
|
@@ -48,15 +56,25 @@ export async function bootstrap(options: { skipProjectSetup?: boolean } = {}) {
|
|
|
48
56
|
);
|
|
49
57
|
addSecretVersion(target.databaseSecretName, connectionUri);
|
|
50
58
|
ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
|
|
59
|
+
return connectionUri;
|
|
51
60
|
});
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
if (shouldPublishTemporalSecrets()) {
|
|
63
|
+
await runStep("Publishing Temporal secrets", () => publishTemporalSecrets());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
target,
|
|
68
|
+
neon,
|
|
69
|
+
databaseUrl,
|
|
70
|
+
artifactRepositoryReady: true,
|
|
71
|
+
} satisfies BootstrapResult;
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
export async function prepareGcpProject() {
|
|
57
75
|
await runStep("Ensuring GCP project", () => ensureProject());
|
|
58
76
|
await runStep("Attaching billing", () => attachBilling());
|
|
59
|
-
await runStep("Enabling required GCP APIs", () =>
|
|
77
|
+
await runStep("Enabling required GCP APIs", () => ensureRequiredApis());
|
|
60
78
|
}
|
|
61
79
|
|
|
62
80
|
function publishTemporalSecrets() {
|
|
@@ -71,6 +89,11 @@ function publishTemporalSecrets() {
|
|
|
71
89
|
return temporal.apiKeySecretName;
|
|
72
90
|
}
|
|
73
91
|
|
|
92
|
+
function shouldPublishTemporalSecrets() {
|
|
93
|
+
const temporal = resolveTemporalRuntimeConfig();
|
|
94
|
+
return Boolean(process.env.TEMPORAL_API_KEY?.trim() && temporal.apiKeySecretName);
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
if (import.meta.main) {
|
|
75
98
|
await runMain("Bootstrap", async () => {
|
|
76
99
|
await bootstrap();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { confirm, isCancel, log } from "@clack/prompts";
|
|
2
2
|
import { deleteAuthResourceServer } from "../authctl";
|
|
3
|
+
import { buildLocalDevCleanupPlan, stopLocalDev } from "../local-dev";
|
|
3
4
|
import { config } from "./config";
|
|
4
5
|
import { deleteBranch, deleteDatabase, listBranches, resolveNeonConfig } from "./neon";
|
|
5
6
|
import {
|
|
@@ -45,6 +46,7 @@ type DestroyPlan = {
|
|
|
45
46
|
resources: PlannedResource[];
|
|
46
47
|
skipped: PlannedResource[];
|
|
47
48
|
blockers: string[];
|
|
49
|
+
githubRepository?: string;
|
|
48
50
|
hasProductionDomainMapping: boolean;
|
|
49
51
|
serviceNames: string[];
|
|
50
52
|
secretNames: string[];
|
|
@@ -69,6 +71,12 @@ export async function cleanup(args = Bun.argv.slice(2)) {
|
|
|
69
71
|
|
|
70
72
|
await requireDestroyConfirmation(options.force);
|
|
71
73
|
|
|
74
|
+
await runStep("Stopping local dev resources", () => stopLocalDev({ dockerCompose: true, removeVolumes: true }));
|
|
75
|
+
|
|
76
|
+
if (plan.githubRepository) {
|
|
77
|
+
await runStep(`Deleting GitHub repository ${plan.githubRepository}`, () => deleteGitHubRepository(plan.githubRepository!));
|
|
78
|
+
}
|
|
79
|
+
|
|
72
80
|
await runStep(`Deleting auth resource server ${config.serviceName}`, () => deleteAuthResourceServer());
|
|
73
81
|
|
|
74
82
|
if (plan.hasProductionDomainMapping) {
|
|
@@ -125,11 +133,14 @@ async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
|
|
|
125
133
|
],
|
|
126
134
|
skipped: [],
|
|
127
135
|
blockers: [],
|
|
136
|
+
githubRepository: undefined,
|
|
128
137
|
hasProductionDomainMapping: false,
|
|
129
138
|
serviceNames: [],
|
|
130
139
|
secretNames: [],
|
|
131
140
|
};
|
|
132
141
|
|
|
142
|
+
planGitHubRepository(plan);
|
|
143
|
+
await planLocalDev(plan);
|
|
133
144
|
planProductionDomainMapping(plan);
|
|
134
145
|
planCloudRunServices(plan);
|
|
135
146
|
planSecrets(plan);
|
|
@@ -143,6 +154,51 @@ async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
|
|
|
143
154
|
return plan;
|
|
144
155
|
}
|
|
145
156
|
|
|
157
|
+
async function planLocalDev(plan: DestroyPlan) {
|
|
158
|
+
const localDev = await buildLocalDevCleanupPlan({ dockerCompose: true });
|
|
159
|
+
for (const resource of localDev.resources) {
|
|
160
|
+
plan.resources.push({ label: resource, detail: "local" });
|
|
161
|
+
}
|
|
162
|
+
for (const skipped of localDev.skipped) {
|
|
163
|
+
plan.skipped.push({ label: skipped, detail: "local" });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function planGitHubRepository(plan: DestroyPlan) {
|
|
168
|
+
const repository = `${config.git.owner}/${config.git.repository}`;
|
|
169
|
+
if (!config.git.deleteOnDestroy) {
|
|
170
|
+
plan.skipped.push({
|
|
171
|
+
label: `GitHub repository ${repository}`,
|
|
172
|
+
detail: config.git.enabled ? "not created by this service CLI run" : "git disabled",
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!Bun.which("gh")) {
|
|
178
|
+
plan.blockers.push(`GitHub repository ${repository}: missing required command gh`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const auth = run("gh", ["auth", "status"], { allowFailure: true });
|
|
183
|
+
if (!auth.success) {
|
|
184
|
+
plan.blockers.push(`GitHub repository ${repository}: authenticate GitHub CLI with gh auth login`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const view = run("gh", ["repo", "view", repository, "--json", "name"], { allowFailure: true });
|
|
189
|
+
if (!view.success) {
|
|
190
|
+
plan.skipped.push({ label: `GitHub repository ${repository}`, detail: "not found" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
plan.githubRepository = repository;
|
|
195
|
+
plan.resources.push({ label: `GitHub repository ${repository}`, detail: "private generated repo" });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function deleteGitHubRepository(repository: string) {
|
|
199
|
+
run("gh", ["repo", "delete", repository, "--yes"], { allowFailure: true });
|
|
200
|
+
}
|
|
201
|
+
|
|
146
202
|
function planProductionDomainMapping(plan: DestroyPlan) {
|
|
147
203
|
try {
|
|
148
204
|
const mapping = describeProductionDomainMapping();
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { mkdir } from "node:fs/promises";
|
|
4
4
|
import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
|
|
5
|
+
import { stopLocalDev } from "../local-dev";
|
|
5
6
|
import { bootstrap, prepareGcpProject } from "./bootstrap";
|
|
6
7
|
import { cleanup } from "./cleanup";
|
|
7
8
|
import { deploy } from "./deploy";
|
|
@@ -38,11 +39,10 @@ export async function main(argv = Bun.argv.slice(2)) {
|
|
|
38
39
|
await prepareGcpProject();
|
|
39
40
|
await runStep("Registering auth resource server", () => ensureAuthResourceServer());
|
|
40
41
|
await runStep("Provisioning auth client", () => ensureAuthClient());
|
|
41
|
-
await bootstrap({ skipProjectSetup: true });
|
|
42
|
-
const
|
|
43
|
-
const databaseUrl = await runStep("Reading production database URL", () => accessSecretVersion(target.databaseSecretName));
|
|
42
|
+
const bootstrapResult = await bootstrap({ skipProjectSetup: true });
|
|
43
|
+
const databaseUrl = bootstrapResult.databaseUrl;
|
|
44
44
|
await runStep("Applying production migrations", () => runLanguageTask("migrate", { DATABASE_URL: databaseUrl }));
|
|
45
|
-
const origin = await deploy(["--ci"]);
|
|
45
|
+
const origin = await deploy(["--ci"], { bootstrapResult });
|
|
46
46
|
await runOptionalBunScript("seed", { DATABASE_URL: databaseUrl });
|
|
47
47
|
return `Created ${origin}`;
|
|
48
48
|
});
|
|
@@ -69,6 +69,14 @@ export async function main(argv = Bun.argv.slice(2)) {
|
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
if (command === "dev") {
|
|
73
|
+
if (rest[0] !== "down") {
|
|
74
|
+
throw new Error(`Unknown dev command: ${rest[0] || ""}\n\n${formatHelp()}`);
|
|
75
|
+
}
|
|
76
|
+
await runMain("Dev", () => stopLocalDev({ dockerCompose: true, removeVolumes: false }));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
72
80
|
if (command === "dns") {
|
|
73
81
|
await runMain("DNS", () => repairDns());
|
|
74
82
|
return;
|
|
@@ -116,6 +124,7 @@ function formatHelp() {
|
|
|
116
124
|
" auth token Mint a bearer token for protected API checks",
|
|
117
125
|
" sdk Build or publish generated SDK artifacts",
|
|
118
126
|
" dns Repair or inspect DNS mappings",
|
|
127
|
+
" dev down Stop local dev and Docker Compose containers",
|
|
119
128
|
" dashboards Publish Grafana resources",
|
|
120
129
|
" destroy Remove service-managed cloud resources",
|
|
121
130
|
].join("\n");
|
|
@@ -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
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { config } from "./config";
|
|
2
|
-
import { bootstrap } from "./bootstrap";
|
|
2
|
+
import { bootstrap, type BootstrapResult } from "./bootstrap";
|
|
3
3
|
import { deleteBranch, ensureBranch, ensureDatabase, getConnectionUri, listBranches, resolveNeonConfig } from "./neon";
|
|
4
4
|
import {
|
|
5
5
|
addSecretVersion,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ensureProductionDomainMapping,
|
|
9
9
|
ensureSecretAccessor,
|
|
10
10
|
gcloud,
|
|
11
|
+
gcloudStreaming,
|
|
11
12
|
gcloudWithRetry,
|
|
12
13
|
imageUrl,
|
|
13
14
|
parseDeployArgs,
|
|
@@ -19,17 +20,22 @@ import {
|
|
|
19
20
|
writeRenderedManifest,
|
|
20
21
|
} from "./lib";
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
type DeployOptions = {
|
|
24
|
+
bootstrapResult?: BootstrapResult;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export async function deploy(args = Bun.argv.slice(2), deployOptions: DeployOptions = {}) {
|
|
23
28
|
requireCommand("gcloud");
|
|
24
29
|
requireCommand("bun");
|
|
25
30
|
|
|
26
31
|
const options = parseDeployArgs(args);
|
|
27
|
-
|
|
28
|
-
await bootstrap();
|
|
29
|
-
}
|
|
32
|
+
const bootstrapResult = deployOptions.bootstrapResult ?? (!options.ci ? await bootstrap() : undefined);
|
|
30
33
|
|
|
31
|
-
const target =
|
|
32
|
-
|
|
34
|
+
const target =
|
|
35
|
+
bootstrapResult && options.environment === "main" && !options.name
|
|
36
|
+
? bootstrapResult.target
|
|
37
|
+
: resolveDeploymentTarget(options.environment, options.name);
|
|
38
|
+
const neon = bootstrapResult?.neon ?? (await runStep("Resolving Neon defaults", () => resolveNeonConfig()));
|
|
33
39
|
|
|
34
40
|
if (options.destroy) {
|
|
35
41
|
if (options.environment === "main") {
|
|
@@ -47,7 +53,9 @@ export async function deploy(args = Bun.argv.slice(2)) {
|
|
|
47
53
|
return `Destroyed ${target.serviceName}`;
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
|
|
56
|
+
if (!bootstrapResult?.artifactRepositoryReady) {
|
|
57
|
+
await runStep("Ensuring Artifact Registry repository", () => ensureArtifactRepository());
|
|
58
|
+
}
|
|
51
59
|
|
|
52
60
|
let branchId: string = neon.baseBranchId;
|
|
53
61
|
if (options.environment !== "main") {
|
|
@@ -57,15 +65,17 @@ export async function deploy(args = Bun.argv.slice(2)) {
|
|
|
57
65
|
branchId = branch.id;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
await
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
if (!bootstrapResult || target.environment !== "main") {
|
|
69
|
+
await runStep("Publishing environment database secret", async () => {
|
|
70
|
+
await ensureDatabase(neon.projectId, branchId, neon.databaseName);
|
|
71
|
+
const connectionUri = await getConnectionUri(neon.projectId, branchId, neon.databaseName, neon.roleName);
|
|
72
|
+
addSecretVersion(target.databaseSecretName, connectionUri);
|
|
73
|
+
ensureSecretAccessor(target.databaseSecretName, `serviceAccount:${config.runtimeServiceAccount}`);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
66
76
|
const image = imageUrl();
|
|
67
|
-
await runStep("Building container image", () =>
|
|
68
|
-
|
|
77
|
+
await runStep("Building container image in Cloud Build", () =>
|
|
78
|
+
gcloudStreaming(["builds", "submit", "--project", config.project.id, "--region", config.region, "--tag", image])
|
|
69
79
|
);
|
|
70
80
|
|
|
71
81
|
const renderedManifestPath = await runStep("Rendering Cloud Run manifest", () => writeRenderedManifest(image, target));
|
|
@@ -117,6 +117,85 @@ export function gcloud(args: string[], options: CommandOptions = {}) {
|
|
|
117
117
|
return run("gcloud", normalized, options);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
export async function gcloudStreaming(args: string[], options: CommandOptions = {}) {
|
|
121
|
+
const normalized = [...args];
|
|
122
|
+
if (config.project.quotaProjectId && !normalized.includes("--billing-project")) {
|
|
123
|
+
normalized.push("--billing-project", config.project.quotaProjectId);
|
|
124
|
+
}
|
|
125
|
+
return runStreaming("gcloud", normalized, options);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function runStreaming(command: string, args: string[], options: CommandOptions = {}): Promise<CommandResult> {
|
|
129
|
+
const child = Bun.spawn([command, ...args], {
|
|
130
|
+
cwd: process.cwd(),
|
|
131
|
+
env: { ...process.env, ...options.env },
|
|
132
|
+
stdin: options.input === undefined ? undefined : encoder.encode(options.input),
|
|
133
|
+
stdout: "pipe",
|
|
134
|
+
stderr: "pipe",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const output = {
|
|
138
|
+
stdout: "",
|
|
139
|
+
stderr: "",
|
|
140
|
+
buildUrlPrinted: false,
|
|
141
|
+
};
|
|
142
|
+
await Promise.all([
|
|
143
|
+
captureStream(child.stdout, (chunk) => {
|
|
144
|
+
output.stdout += chunk;
|
|
145
|
+
printCloudBuildUrl(chunk, output);
|
|
146
|
+
}),
|
|
147
|
+
captureStream(child.stderr, (chunk) => {
|
|
148
|
+
output.stderr += chunk;
|
|
149
|
+
printCloudBuildUrl(chunk, output);
|
|
150
|
+
}),
|
|
151
|
+
]);
|
|
152
|
+
const exitCode = await child.exited;
|
|
153
|
+
const result: CommandResult = {
|
|
154
|
+
success: exitCode === 0,
|
|
155
|
+
stdout: output.stdout.trim(),
|
|
156
|
+
stderr: output.stderr.trim(),
|
|
157
|
+
exitCode,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (!result.success && !options.allowFailure) {
|
|
161
|
+
throw new CommandError(command, args, result);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function captureStream(stream: ReadableStream<Uint8Array> | null, onChunk: (chunk: string) => void) {
|
|
168
|
+
if (!stream) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const reader = stream.getReader();
|
|
172
|
+
const streamDecoder = new TextDecoder();
|
|
173
|
+
while (true) {
|
|
174
|
+
const { done, value } = await reader.read();
|
|
175
|
+
if (done) {
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
onChunk(streamDecoder.decode(value, { stream: true }));
|
|
179
|
+
}
|
|
180
|
+
const remaining = streamDecoder.decode();
|
|
181
|
+
if (remaining) {
|
|
182
|
+
onChunk(remaining);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function printCloudBuildUrl(chunk: string, output: { stdout: string; stderr: string; buildUrlPrinted: boolean }) {
|
|
187
|
+
if (output.buildUrlPrinted) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const combined = `${output.stdout}\n${output.stderr}\n${chunk}`;
|
|
191
|
+
const match = combined.match(/https:\/\/console\.cloud\.google\.com\/cloud-build\/[^\s)]+/);
|
|
192
|
+
if (!match?.[0]) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
output.buildUrlPrinted = true;
|
|
196
|
+
log.step(`Cloud Build logs: ${match[0]}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
120
199
|
export function gcloudWithRetry(args: string[], options: CommandOptions = {}) {
|
|
121
200
|
let lastError: unknown;
|
|
122
201
|
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
|
@@ -187,6 +266,21 @@ export function attachBilling() {
|
|
|
187
266
|
gcloud(["beta", "billing", "projects", "link", config.project.id, "--billing-account", config.project.billingAccount]);
|
|
188
267
|
}
|
|
189
268
|
|
|
269
|
+
export function ensureRequiredApis() {
|
|
270
|
+
const enabled = new Set(
|
|
271
|
+
gcloud(["services", "list", "--enabled", "--project", config.project.id, "--format=value(config.name)"]).stdout
|
|
272
|
+
.split("\n")
|
|
273
|
+
.map((name) => name.trim())
|
|
274
|
+
.filter(Boolean)
|
|
275
|
+
);
|
|
276
|
+
const missing = config.requiredApis.filter((api: string) => !enabled.has(api));
|
|
277
|
+
if (missing.length === 0) {
|
|
278
|
+
return "Required GCP APIs are already enabled";
|
|
279
|
+
}
|
|
280
|
+
gcloud(["services", "enable", ...missing, "--project", config.project.id]);
|
|
281
|
+
return `Enabled ${missing.join(", ")}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
190
284
|
export function ensureServiceAccount(email: string) {
|
|
191
285
|
if (gcloud(["iam", "service-accounts", "describe", email, "--project", config.project.id], { allowFailure: true }).success) {
|
|
192
286
|
return;
|
|
@@ -202,9 +296,28 @@ export function deleteServiceAccount(email: string) {
|
|
|
202
296
|
}
|
|
203
297
|
|
|
204
298
|
export function ensureProjectRole(member: string, role: string) {
|
|
299
|
+
if (projectHasRole(member, role)) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
205
302
|
gcloudWithRetry(["projects", "add-iam-policy-binding", config.project.id, "--member", member, "--role", role]);
|
|
206
303
|
}
|
|
207
304
|
|
|
305
|
+
function projectHasRole(member: string, role: string) {
|
|
306
|
+
return gcloud(
|
|
307
|
+
[
|
|
308
|
+
"projects",
|
|
309
|
+
"get-iam-policy",
|
|
310
|
+
config.project.id,
|
|
311
|
+
"--flatten=bindings[].members",
|
|
312
|
+
`--filter=bindings.role=${role} AND bindings.members=${member}`,
|
|
313
|
+
"--format=value(bindings.role)",
|
|
314
|
+
],
|
|
315
|
+
{ allowFailure: true }
|
|
316
|
+
)
|
|
317
|
+
.stdout.split("\n")
|
|
318
|
+
.some((line) => line.trim() === role);
|
|
319
|
+
}
|
|
320
|
+
|
|
208
321
|
export function ensureServiceAccountRole(serviceAccount: string, member: string, role: string) {
|
|
209
322
|
gcloudWithRetry([
|
|
210
323
|
"iam",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { buildLocalDevCleanupPlan, stopLocalDev } from "./local-dev";
|
|
6
|
+
|
|
7
|
+
const roots: string[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("local dev cleanup", () => {
|
|
14
|
+
test("is idempotent with a missing pid and missing compose file", async () => {
|
|
15
|
+
const root = await tempRoot();
|
|
16
|
+
const result = await stopLocalDev({ root, dockerCompose: true, removeVolumes: true });
|
|
17
|
+
|
|
18
|
+
expect(result).toContain("No local dev pid file found");
|
|
19
|
+
expect(await Bun.file(join(root, ".service", "local-dev.pid")).exists()).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("removes a stale pid file", async () => {
|
|
23
|
+
const root = await tempRoot();
|
|
24
|
+
await mkdir(join(root, ".service"), { recursive: true });
|
|
25
|
+
await Bun.write(join(root, ".service", "local-dev.pid"), "999999\n");
|
|
26
|
+
|
|
27
|
+
const result = await stopLocalDev({ root, dockerCompose: false });
|
|
28
|
+
|
|
29
|
+
expect(result).toContain("Removed stale local dev pid file for 999999");
|
|
30
|
+
expect(await Bun.file(join(root, ".service", "local-dev.pid")).exists()).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("plans Docker Compose cleanup when compose exists", async () => {
|
|
34
|
+
const root = await tempRoot();
|
|
35
|
+
await Bun.write(join(root, "docker-compose.yml"), "services: {}\n");
|
|
36
|
+
|
|
37
|
+
const plan = await buildLocalDevCleanupPlan({ root, dockerCompose: true });
|
|
38
|
+
|
|
39
|
+
expect(plan.resources).toContain("Docker Compose containers, networks, and volumes");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
async function tempRoot() {
|
|
44
|
+
const root = await mkdtemp(join(tmpdir(), "create-svc-local-dev-"));
|
|
45
|
+
roots.push(root);
|
|
46
|
+
return root;
|
|
47
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
type LocalDevOptions = {
|
|
5
|
+
root?: string;
|
|
6
|
+
dockerCompose?: boolean;
|
|
7
|
+
removeVolumes?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type LocalDevCleanupPlan = {
|
|
11
|
+
pidFile: string;
|
|
12
|
+
hasPidFile: boolean;
|
|
13
|
+
pid?: number;
|
|
14
|
+
hasDockerCompose: boolean;
|
|
15
|
+
resources: string[];
|
|
16
|
+
skipped: string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const decoder = new TextDecoder();
|
|
20
|
+
|
|
21
|
+
export async function buildLocalDevCleanupPlan(options: LocalDevOptions = {}): Promise<LocalDevCleanupPlan> {
|
|
22
|
+
const root = options.root ?? defaultServiceRoot();
|
|
23
|
+
const pidFile = join(root, ".service", "local-dev.pid");
|
|
24
|
+
const hasPidFile = await Bun.file(pidFile).exists();
|
|
25
|
+
const pid = hasPidFile ? parsePid(await Bun.file(pidFile).text()) : undefined;
|
|
26
|
+
const hasDockerCompose = Boolean(options.dockerCompose) && (await Bun.file(join(root, "docker-compose.yml")).exists());
|
|
27
|
+
const resources: string[] = [];
|
|
28
|
+
const skipped: string[] = [];
|
|
29
|
+
|
|
30
|
+
if (hasPidFile) {
|
|
31
|
+
resources.push(`Local dev process from ${pidFile}`);
|
|
32
|
+
} else {
|
|
33
|
+
skipped.push("Local dev process: no .service/local-dev.pid");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (hasDockerCompose) {
|
|
37
|
+
resources.push("Docker Compose containers, networks, and volumes");
|
|
38
|
+
} else if (options.dockerCompose) {
|
|
39
|
+
skipped.push("Docker Compose: no docker-compose.yml");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
pidFile,
|
|
44
|
+
hasPidFile,
|
|
45
|
+
pid,
|
|
46
|
+
hasDockerCompose,
|
|
47
|
+
resources,
|
|
48
|
+
skipped,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function stopLocalDev(options: LocalDevOptions = {}) {
|
|
53
|
+
const root = options.root ?? defaultServiceRoot();
|
|
54
|
+
const plan = await buildLocalDevCleanupPlan({ ...options, root });
|
|
55
|
+
const messages: string[] = [];
|
|
56
|
+
|
|
57
|
+
if (plan.hasPidFile) {
|
|
58
|
+
if (plan.pid) {
|
|
59
|
+
messages.push(stopPid(plan.pid) ? `Stopped local dev process ${plan.pid}` : `Removed stale local dev pid file for ${plan.pid}`);
|
|
60
|
+
} else {
|
|
61
|
+
messages.push(`Removed invalid local dev pid file ${plan.pidFile}`);
|
|
62
|
+
}
|
|
63
|
+
await rm(plan.pidFile, { force: true });
|
|
64
|
+
} else {
|
|
65
|
+
messages.push("No local dev pid file found");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (plan.hasDockerCompose) {
|
|
69
|
+
const result = runDockerComposeDown(root, Boolean(options.removeVolumes));
|
|
70
|
+
messages.push(result);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return messages.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultServiceRoot() {
|
|
77
|
+
return process.env.CREATE_SVC_SERVICE_ROOT?.trim() || process.cwd();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parsePid(raw: string) {
|
|
81
|
+
const pid = Number.parseInt(raw.trim(), 10);
|
|
82
|
+
return Number.isFinite(pid) && pid > 0 ? pid : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stopPid(pid: number) {
|
|
86
|
+
const wasRunning = isRunning(pid);
|
|
87
|
+
tryKill(-pid, "SIGTERM") || tryKill(pid, "SIGTERM");
|
|
88
|
+
Bun.sleepSync(1_000);
|
|
89
|
+
if (isRunning(pid)) {
|
|
90
|
+
tryKill(-pid, "SIGKILL") || tryKill(pid, "SIGKILL");
|
|
91
|
+
}
|
|
92
|
+
return wasRunning;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isRunning(pid: number) {
|
|
96
|
+
try {
|
|
97
|
+
process.kill(pid, 0);
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function tryKill(pid: number, signal: NodeJS.Signals) {
|
|
105
|
+
try {
|
|
106
|
+
process.kill(pid, signal);
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function runDockerComposeDown(root: string, removeVolumes: boolean) {
|
|
114
|
+
if (!Bun.which("docker")) {
|
|
115
|
+
return "Docker is not installed; Docker Compose cleanup skipped";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const args = ["compose", "down", "--remove-orphans"];
|
|
119
|
+
if (removeVolumes) {
|
|
120
|
+
args.push("-v");
|
|
121
|
+
}
|
|
122
|
+
const result = Bun.spawnSync(["docker", ...args], {
|
|
123
|
+
cwd: root,
|
|
124
|
+
env: process.env,
|
|
125
|
+
stdout: "pipe",
|
|
126
|
+
stderr: "pipe",
|
|
127
|
+
});
|
|
128
|
+
if (!result.success) {
|
|
129
|
+
const output = [
|
|
130
|
+
result.stderr ? decoder.decode(result.stderr).trim() : "",
|
|
131
|
+
result.stdout ? decoder.decode(result.stdout).trim() : "",
|
|
132
|
+
]
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.join("\n");
|
|
135
|
+
return `Docker Compose cleanup failed: ${output || `exit ${result.exitCode}`}`;
|
|
136
|
+
}
|
|
137
|
+
return removeVolumes ? "Docker Compose containers and volumes removed" : "Docker Compose containers stopped";
|
|
138
|
+
}
|
|
@@ -4,6 +4,7 @@ import { confirm, intro, isCancel, log, outro } from "@clack/prompts";
|
|
|
4
4
|
import { createApiClient } from "@neondatabase/api-client";
|
|
5
5
|
import { Client } from "pg";
|
|
6
6
|
import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
|
|
7
|
+
import { stopLocalDev } from "../local-dev";
|
|
7
8
|
import { serviceConfig } from "../runtime";
|
|
8
9
|
|
|
9
10
|
const config = {
|
|
@@ -11,6 +12,12 @@ const config = {
|
|
|
11
12
|
hostname: serviceConfig.dns.hostname,
|
|
12
13
|
neonDatabaseName: serviceConfig.neon.database_name,
|
|
13
14
|
neonRoleName: serviceConfig.neon.role_name,
|
|
15
|
+
git: {
|
|
16
|
+
enabled: Boolean(serviceConfig.git?.enabled),
|
|
17
|
+
owner: serviceConfig.git?.owner || "anmho",
|
|
18
|
+
repository: serviceConfig.git?.repository || serviceConfig.service_id,
|
|
19
|
+
deleteOnDestroy: Boolean(serviceConfig.git?.delete_on_destroy),
|
|
20
|
+
},
|
|
14
21
|
};
|
|
15
22
|
|
|
16
23
|
type DoctorStatus = "pass" | "warn" | "fail";
|
|
@@ -65,6 +72,13 @@ export async function main(argv = Bun.argv.slice(2)) {
|
|
|
65
72
|
return runMain("DNS", () => `Workers custom domain is configured in wrangler.toml for ${config.hostname}`);
|
|
66
73
|
}
|
|
67
74
|
|
|
75
|
+
if (command === "dev") {
|
|
76
|
+
if (rest[0] !== "down") {
|
|
77
|
+
throw new Error(`Unknown dev command: ${rest[0] || ""}\n\n${formatHelp()}`);
|
|
78
|
+
}
|
|
79
|
+
return runMain("Dev", () => stopLocalDev({ dockerCompose: false }));
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
if (command === "doctor") {
|
|
69
83
|
return runMain("Doctor", () => runDoctor());
|
|
70
84
|
}
|
|
@@ -81,6 +95,8 @@ export async function main(argv = Bun.argv.slice(2)) {
|
|
|
81
95
|
return runMain("Destroy", async () => {
|
|
82
96
|
await requireDestroyConfirmation(rest.includes("--force"));
|
|
83
97
|
const wranglerArgs = rest.filter((arg) => arg !== "--force");
|
|
98
|
+
await stopLocalDev({ dockerCompose: false });
|
|
99
|
+
deleteGitHubRepositoryIfOwned();
|
|
84
100
|
await deleteHyperdrive();
|
|
85
101
|
run("wrangler", ["delete", "--name", config.serviceName, "--force", ...wranglerArgs]);
|
|
86
102
|
await deleteNeonDatabase();
|
|
@@ -109,12 +125,28 @@ function formatHelp() {
|
|
|
109
125
|
" doctor Check local tools and cloud access",
|
|
110
126
|
" auth Manage auth resource server and clients",
|
|
111
127
|
" auth token Mint a bearer token for protected API checks",
|
|
128
|
+
" dev down Stop local dev",
|
|
112
129
|
" dns Show Workers custom-domain configuration",
|
|
113
130
|
" dashboards Publish Grafana resources",
|
|
114
131
|
" destroy Remove service-managed Worker resources",
|
|
115
132
|
].join("\n");
|
|
116
133
|
}
|
|
117
134
|
|
|
135
|
+
function deleteGitHubRepositoryIfOwned() {
|
|
136
|
+
const repository = `${config.git.owner}/${config.git.repository}`;
|
|
137
|
+
if (!config.git.deleteOnDestroy) {
|
|
138
|
+
log.step(`Skipping GitHub repository ${repository}: ${config.git.enabled ? "not created by this service CLI run" : "git disabled"}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
run("gh", ["auth", "status"], { capture: true });
|
|
142
|
+
const view = run("gh", ["repo", "view", repository, "--json", "name"], { allowFailure: true, capture: true });
|
|
143
|
+
if (!view.success) {
|
|
144
|
+
log.step(`Skipping GitHub repository ${repository}: not found`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
run("gh", ["repo", "delete", repository, "--yes"]);
|
|
148
|
+
}
|
|
149
|
+
|
|
118
150
|
function run(command: string, args: string[], options: { allowFailure?: boolean; capture?: boolean } = {}) {
|
|
119
151
|
if (!Bun.which(command)) {
|
|
120
152
|
throw new Error(`missing required command: ${command}`);
|
|
@@ -30,6 +30,7 @@ console to create and deploy.
|
|
|
30
30
|
{{COMMAND_TEST}}
|
|
31
31
|
{{COMMAND_BOOTSTRAP}}
|
|
32
32
|
{{COMMAND_DEPLOY}}
|
|
33
|
+
{{COMMAND_DEV_DOWN}}
|
|
33
34
|
{{COMMAND_AUTH_RESOURCE}}
|
|
34
35
|
{{COMMAND_AUTH_CLIENT}}
|
|
35
36
|
{{COMMAND_DEPLOY_PERSONAL}}
|
|
@@ -215,7 +216,8 @@ service create {{SERVICE_NAME}} --yes
|
|
|
215
216
|
```
|
|
216
217
|
|
|
217
218
|
That command scaffolds this package, runs `service create`, deploys the production
|
|
218
|
-
Cloud Run service
|
|
219
|
+
Cloud Run service once, verifies production and local endpoints, starts local dev,
|
|
220
|
+
and fails loudly with resumable
|
|
219
221
|
instructions if a required cloud credential is missing. The generated package can also be run
|
|
220
222
|
manually:
|
|
221
223
|
|
|
@@ -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",
|