create-svc 0.1.33 → 0.1.35
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 +1 -1
- package/src/post-scaffold.test.ts +2 -5
- package/src/post-scaffold.ts +1 -2
- package/src/scaffold.ts +1 -0
- package/src/service-runtime/cloudrun/bootstrap.ts +28 -5
- package/src/service-runtime/cloudrun/cleanup.ts +41 -0
- package/src/service-runtime/cloudrun/cli.ts +13 -4
- package/src/service-runtime/cloudrun/deploy.ts +26 -16
- package/src/service-runtime/cloudrun/lib.ts +140 -1
- 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 +10 -0
- package/templates/shared/README.md +3 -1
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
|
@@ -1191,7 +1191,7 @@ export function formatScaffoldHelp() {
|
|
|
1191
1191
|
" --billing-account <name> Billing account resource name",
|
|
1192
1192
|
" --quota-project <id> Billing quota project for gcloud calls",
|
|
1193
1193
|
" --region <region> Cloud Run region",
|
|
1194
|
-
" --auto-deploy Scaffold, run service create,
|
|
1194
|
+
" --auto-deploy Scaffold, run service create, verify prod/local, and start local dev (default)",
|
|
1195
1195
|
" --no-auto-deploy Scaffold only",
|
|
1196
1196
|
" --no-git Skip default private GitHub repo: anmho/<service_id>",
|
|
1197
1197
|
" --yes, -y Accept defaults without prompts",
|
|
@@ -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.ts
CHANGED
|
@@ -224,6 +224,7 @@ function buildReplacements(config: ScaffoldConfig) {
|
|
|
224
224
|
COMMAND_GEN: config.runtime === "bun" ? "bun run gen" : "make gen",
|
|
225
225
|
COMMAND_LINT: config.runtime === "bun" ? "bun run lint" : "make lint",
|
|
226
226
|
COMMAND_TEST: config.runtime === "bun" ? "bun run test" : "make test",
|
|
227
|
+
COMMAND_DEV_DOWN: "service dev down",
|
|
227
228
|
COMMAND_BOOTSTRAP: "service create",
|
|
228
229
|
COMMAND_DEPLOY: "service deploy",
|
|
229
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,9 +1,11 @@
|
|
|
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 {
|
|
6
7
|
assertOwnedResource,
|
|
8
|
+
deleteArtifactImage,
|
|
7
9
|
deleteProject,
|
|
8
10
|
deleteProductionDomainMapping,
|
|
9
11
|
deleteSecret,
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
describeSecret,
|
|
15
17
|
formatError,
|
|
16
18
|
listCloudRunServices,
|
|
19
|
+
listArtifactImages,
|
|
17
20
|
listSecrets,
|
|
18
21
|
parseCleanupArgs,
|
|
19
22
|
requireCommand,
|
|
@@ -49,6 +52,7 @@ type DestroyPlan = {
|
|
|
49
52
|
hasProductionDomainMapping: boolean;
|
|
50
53
|
serviceNames: string[];
|
|
51
54
|
secretNames: string[];
|
|
55
|
+
artifactImages: string[];
|
|
52
56
|
neon?: {
|
|
53
57
|
projectId: string;
|
|
54
58
|
baseBranchId: string;
|
|
@@ -70,6 +74,8 @@ export async function cleanup(args = Bun.argv.slice(2)) {
|
|
|
70
74
|
|
|
71
75
|
await requireDestroyConfirmation(options.force);
|
|
72
76
|
|
|
77
|
+
await runStep("Stopping local dev resources", () => stopLocalDev({ dockerCompose: true, removeVolumes: true }));
|
|
78
|
+
|
|
73
79
|
if (plan.githubRepository) {
|
|
74
80
|
await runStep(`Deleting GitHub repository ${plan.githubRepository}`, () => deleteGitHubRepository(plan.githubRepository!));
|
|
75
81
|
}
|
|
@@ -88,6 +94,13 @@ export async function cleanup(args = Bun.argv.slice(2)) {
|
|
|
88
94
|
}
|
|
89
95
|
});
|
|
90
96
|
|
|
97
|
+
const artifactImages = plan.artifactImages;
|
|
98
|
+
await runStep("Deleting Artifact Registry images", () => {
|
|
99
|
+
for (const image of artifactImages) {
|
|
100
|
+
deleteArtifactImage(image);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
91
104
|
const secretNames = plan.secretNames;
|
|
92
105
|
await runStep("Deleting service secrets", () => {
|
|
93
106
|
for (const secretName of secretNames) {
|
|
@@ -134,11 +147,14 @@ async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
|
|
|
134
147
|
hasProductionDomainMapping: false,
|
|
135
148
|
serviceNames: [],
|
|
136
149
|
secretNames: [],
|
|
150
|
+
artifactImages: [],
|
|
137
151
|
};
|
|
138
152
|
|
|
139
153
|
planGitHubRepository(plan);
|
|
154
|
+
await planLocalDev(plan);
|
|
140
155
|
planProductionDomainMapping(plan);
|
|
141
156
|
planCloudRunServices(plan);
|
|
157
|
+
planArtifactImages(plan);
|
|
142
158
|
planSecrets(plan);
|
|
143
159
|
await planNeon(plan);
|
|
144
160
|
await planGrafana(plan);
|
|
@@ -150,6 +166,16 @@ async function buildDestroyPlan(destroyProject: boolean): Promise<DestroyPlan> {
|
|
|
150
166
|
return plan;
|
|
151
167
|
}
|
|
152
168
|
|
|
169
|
+
async function planLocalDev(plan: DestroyPlan) {
|
|
170
|
+
const localDev = await buildLocalDevCleanupPlan({ dockerCompose: true });
|
|
171
|
+
for (const resource of localDev.resources) {
|
|
172
|
+
plan.resources.push({ label: resource, detail: "local" });
|
|
173
|
+
}
|
|
174
|
+
for (const skipped of localDev.skipped) {
|
|
175
|
+
plan.skipped.push({ label: skipped, detail: "local" });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
153
179
|
function planGitHubRepository(plan: DestroyPlan) {
|
|
154
180
|
const repository = `${config.git.owner}/${config.git.repository}`;
|
|
155
181
|
if (!config.git.deleteOnDestroy) {
|
|
@@ -222,6 +248,21 @@ function planCloudRunServices(plan: DestroyPlan) {
|
|
|
222
248
|
}
|
|
223
249
|
}
|
|
224
250
|
|
|
251
|
+
function planArtifactImages(plan: DestroyPlan) {
|
|
252
|
+
try {
|
|
253
|
+
plan.artifactImages = listArtifactImages();
|
|
254
|
+
if (plan.artifactImages.length === 0) {
|
|
255
|
+
plan.skipped.push({ label: `Artifact Registry images for ${config.serviceName}`, detail: "none matched" });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
for (const image of plan.artifactImages) {
|
|
259
|
+
plan.resources.push({ label: `Artifact Registry image ${image}`, detail: `${config.project.id}/${config.region}` });
|
|
260
|
+
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
plan.blockers.push(`Artifact Registry images for ${config.serviceName}: ${formatError(error)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
225
266
|
function planSecrets(plan: DestroyPlan) {
|
|
226
267
|
try {
|
|
227
268
|
plan.secretNames = listSecrets().filter(matchesSecretResource);
|
|
@@ -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");
|
|
@@ -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",
|
|
@@ -302,6 +415,32 @@ export function ensureArtifactRepository() {
|
|
|
302
415
|
]);
|
|
303
416
|
}
|
|
304
417
|
|
|
418
|
+
export function artifactImageBase() {
|
|
419
|
+
return `${config.region}-docker.pkg.dev/${config.project.id}/${config.artifactRepository}/${config.serviceName}`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function listArtifactImages() {
|
|
423
|
+
const result = gcloud(
|
|
424
|
+
["artifacts", "docker", "images", "list", artifactImageBase(), "--include-tags", "--project", config.project.id, "--format=json"],
|
|
425
|
+
{ allowFailure: true }
|
|
426
|
+
);
|
|
427
|
+
if (!result.success || !result.stdout) {
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
return (JSON.parse(result.stdout) as Array<{ package?: string; version?: string }>)
|
|
433
|
+
.map((image) => (image.package && image.version ? `${image.package}@${image.version}` : ""))
|
|
434
|
+
.filter(Boolean);
|
|
435
|
+
} catch {
|
|
436
|
+
throw new Error(`Unable to parse Artifact Registry images for ${artifactImageBase()}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function deleteArtifactImage(image: string) {
|
|
441
|
+
gcloud(["artifacts", "docker", "images", "delete", image, "--delete-tags", "--project", config.project.id, "--quiet"], { allowFailure: true });
|
|
442
|
+
}
|
|
443
|
+
|
|
305
444
|
export function projectNumber() {
|
|
306
445
|
return gcloud(["projects", "describe", config.project.id, "--format=value(projectNumber)"]).stdout;
|
|
307
446
|
}
|
|
@@ -312,7 +451,7 @@ export function imageTag() {
|
|
|
312
451
|
}
|
|
313
452
|
|
|
314
453
|
export function imageUrl(tag = imageTag()) {
|
|
315
|
-
return `${
|
|
454
|
+
return `${artifactImageBase()}:${tag}`;
|
|
316
455
|
}
|
|
317
456
|
|
|
318
457
|
export function parseDeployArgs(argv: string[]): DeployArgs {
|
|
@@ -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 = {
|
|
@@ -71,6 +72,13 @@ export async function main(argv = Bun.argv.slice(2)) {
|
|
|
71
72
|
return runMain("DNS", () => `Workers custom domain is configured in wrangler.toml for ${config.hostname}`);
|
|
72
73
|
}
|
|
73
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
|
+
|
|
74
82
|
if (command === "doctor") {
|
|
75
83
|
return runMain("Doctor", () => runDoctor());
|
|
76
84
|
}
|
|
@@ -87,6 +95,7 @@ export async function main(argv = Bun.argv.slice(2)) {
|
|
|
87
95
|
return runMain("Destroy", async () => {
|
|
88
96
|
await requireDestroyConfirmation(rest.includes("--force"));
|
|
89
97
|
const wranglerArgs = rest.filter((arg) => arg !== "--force");
|
|
98
|
+
await stopLocalDev({ dockerCompose: false });
|
|
90
99
|
deleteGitHubRepositoryIfOwned();
|
|
91
100
|
await deleteHyperdrive();
|
|
92
101
|
run("wrangler", ["delete", "--name", config.serviceName, "--force", ...wranglerArgs]);
|
|
@@ -116,6 +125,7 @@ function formatHelp() {
|
|
|
116
125
|
" doctor Check local tools and cloud access",
|
|
117
126
|
" auth Manage auth resource server and clients",
|
|
118
127
|
" auth token Mint a bearer token for protected API checks",
|
|
128
|
+
" dev down Stop local dev",
|
|
119
129
|
" dns Show Workers custom-domain configuration",
|
|
120
130
|
" dashboards Publish Grafana resources",
|
|
121
131
|
" destroy Remove service-managed Worker resources",
|
|
@@ -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
|
|