create-svc 0.1.81 → 0.1.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/service-runtime/cloudrun/cli.ts +3 -215
- package/src/service-runtime/cloudrun/deploy-args.ts +4 -0
- package/src/service-runtime/cloudrun/deploy.ts +4 -2
- package/src/service-runtime/cloudrun/lib.test.ts +5 -1
- package/src/service-runtime/cloudrun/sdk.test.ts +1 -1
- package/src/service-runtime/connect-sdk.ts +293 -0
- package/src/service-runtime/local-dev.test.ts +13 -0
- package/src/service.ts +9 -0
- /package/src/service-runtime/{cloudrun/sdk-state.ts → connect-sdk-state.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
4
|
import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
|
|
5
|
+
import { recordConnectSdkDoctorChecks } from "../connect-sdk";
|
|
5
6
|
import { stopLocalDev } from "../local-dev";
|
|
6
7
|
import { bootstrap, prepareGcpProject } from "./bootstrap";
|
|
7
8
|
import { cleanup } from "./cleanup";
|
|
8
9
|
import { deploy } from "./deploy";
|
|
9
10
|
import { observabilityBootstrap } from "./observability";
|
|
10
11
|
import { config } from "./config";
|
|
11
|
-
import { formatSdkModeDetail, type SdkState } from "./sdk-state";
|
|
12
12
|
import {
|
|
13
13
|
accessSecretVersion,
|
|
14
14
|
assertProductionDomainAvailable,
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
formatError,
|
|
18
18
|
gcloud,
|
|
19
19
|
ensureProductionDomainMapping,
|
|
20
|
-
readVaultField,
|
|
21
20
|
requireCommand,
|
|
22
21
|
requireGcloudAuth,
|
|
23
22
|
resolveDeploymentTarget,
|
|
@@ -115,11 +114,6 @@ export async function main(argv = Bun.argv.slice(2)) {
|
|
|
115
114
|
return;
|
|
116
115
|
}
|
|
117
116
|
|
|
118
|
-
if (command === "sdk") {
|
|
119
|
-
await runMain("SDK", () => runSdk(rest));
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
117
|
throw new Error(`Unknown command: ${command}\n\n${formatHelp()}`);
|
|
124
118
|
}
|
|
125
119
|
|
|
@@ -276,46 +270,7 @@ async function runDoctor() {
|
|
|
276
270
|
return "Temporal worker config present";
|
|
277
271
|
});
|
|
278
272
|
|
|
279
|
-
|
|
280
|
-
await record(results, "ConnectRPC proto", "fail", async () => {
|
|
281
|
-
if (!(await Bun.file("./buf.yaml").exists())) {
|
|
282
|
-
throw new Error("missing buf.yaml");
|
|
283
|
-
}
|
|
284
|
-
const protoFiles = await findFiles("./protos", ".proto");
|
|
285
|
-
if (protoFiles.length === 0) {
|
|
286
|
-
throw new Error("missing ConnectRPC proto");
|
|
287
|
-
}
|
|
288
|
-
return `${protoFiles.length} proto file(s) present`;
|
|
289
|
-
});
|
|
290
|
-
await record(results, "Buf CLI", "warn", () => checkCommand("buf"));
|
|
291
|
-
await record(results, "generated SDK artifacts", "warn", async () => {
|
|
292
|
-
const artifacts = await findGeneratedSdkArtifacts();
|
|
293
|
-
if (artifacts.length === 0) {
|
|
294
|
-
throw new Error("generated SDK artifacts are missing; run service sdk build");
|
|
295
|
-
}
|
|
296
|
-
return "local generated artifacts present";
|
|
297
|
-
});
|
|
298
|
-
await record(results, "SDK mode", "warn", async () => {
|
|
299
|
-
const text = await Bun.file(".service/sdk.json").text();
|
|
300
|
-
const state = JSON.parse(text) as SdkState;
|
|
301
|
-
return formatSdkModeDetail(state, bufModule());
|
|
302
|
-
});
|
|
303
|
-
await record(results, "SDK remote publish", "warn", async () => {
|
|
304
|
-
const text = await Bun.file(".service/sdk.json").text();
|
|
305
|
-
const state = JSON.parse(text) as SdkState;
|
|
306
|
-
const module = state.module || bufModule();
|
|
307
|
-
if (state.mode !== "remote") {
|
|
308
|
-
throw new Error(`SDK is in ${state.mode} mode; run service sdk publish to publish ${module}`);
|
|
309
|
-
}
|
|
310
|
-
const authEnv = resolveBufAuthEnv();
|
|
311
|
-
run("buf", ["registry", "module", "info", module], { env: authEnv });
|
|
312
|
-
const published = resolvePublishedSdk(authEnv);
|
|
313
|
-
if (state.remote?.commit && published.commit !== state.remote.commit) {
|
|
314
|
-
return `remote module readable; latest ${published.commit}, recorded ${state.remote.commit}`;
|
|
315
|
-
}
|
|
316
|
-
return `remote module readable at ${module}@${published.commit}`;
|
|
317
|
-
});
|
|
318
|
-
}
|
|
273
|
+
await recordConnectSdkDoctorChecks((name, failureStatus, check) => record(results, name, failureStatus, check));
|
|
319
274
|
|
|
320
275
|
const output = results.map(formatDoctorResult).join("\n");
|
|
321
276
|
const failures = results.filter((result) => result.status === "fail");
|
|
@@ -355,173 +310,6 @@ function formatDoctorResult(result: { name: string; status: "pass" | "warn" | "f
|
|
|
355
310
|
return `[${marker}] ${result.name}: ${result.detail}`;
|
|
356
311
|
}
|
|
357
312
|
|
|
358
|
-
async function runSdk(args: string[]) {
|
|
359
|
-
if ((config.framework as string) !== "connectrpc") {
|
|
360
|
-
throw new Error("SDK commands are only available for ConnectRPC services");
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const [subcommand] = args;
|
|
364
|
-
if (subcommand === "publish") {
|
|
365
|
-
requireCommand("buf");
|
|
366
|
-
const authEnv = resolveBufAuthEnv();
|
|
367
|
-
ensureBufModule(authEnv);
|
|
368
|
-
run("buf", ["push"], { env: authEnv });
|
|
369
|
-
const published = resolvePublishedSdk(authEnv);
|
|
370
|
-
await writeSdkMode("remote", published);
|
|
371
|
-
return `Schema pushed to Buf Schema Registry and recorded for consumers: ${published.commit}`;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
if (subcommand === "build") {
|
|
375
|
-
if (config.runtime === "bun") {
|
|
376
|
-
run("bun", ["run", "gen"]);
|
|
377
|
-
} else {
|
|
378
|
-
run("make", ["gen"]);
|
|
379
|
-
}
|
|
380
|
-
await writeSdkMode("local");
|
|
381
|
-
return "Local SDK artifacts generated and recorded";
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
if (subcommand === "use-local") {
|
|
385
|
-
await assertLocalSdkArtifacts();
|
|
386
|
-
await writeSdkMode("local");
|
|
387
|
-
return "Local SDK artifacts recorded";
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (subcommand === "use-remote") {
|
|
391
|
-
requireCommand("buf");
|
|
392
|
-
const authEnv = resolveBufAuthEnv();
|
|
393
|
-
const published = resolvePublishedSdk(authEnv);
|
|
394
|
-
await writeSdkMode("remote", published);
|
|
395
|
-
return `Remote Buf SDK recorded for consumers: ${bufModule()}@${published.commit}`;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
throw new Error("Usage: service sdk <build|publish|use-local|use-remote>");
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
async function assertLocalSdkArtifacts() {
|
|
402
|
-
const artifacts = await findGeneratedSdkArtifacts();
|
|
403
|
-
if (artifacts.length === 0) {
|
|
404
|
-
throw new Error("Local SDK artifacts are missing. Run `service sdk build` first.");
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
type PublishedSdk = {
|
|
409
|
-
commit: string;
|
|
410
|
-
digest?: string;
|
|
411
|
-
createTime?: string;
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
function resolvePublishedSdk(authEnv: Record<string, string> = {}): PublishedSdk {
|
|
415
|
-
const module = bufModule();
|
|
416
|
-
const result = run("buf", ["registry", "module", "commit", "list", module, "--format", "json", "--page-size", "1"], { env: authEnv });
|
|
417
|
-
const parsed = JSON.parse(result.stdout) as {
|
|
418
|
-
commits?: Array<Record<string, unknown>>;
|
|
419
|
-
commit?: Record<string, unknown>;
|
|
420
|
-
};
|
|
421
|
-
const commit = parsed.commits?.[0] ?? parsed.commit;
|
|
422
|
-
if (!commit) {
|
|
423
|
-
throw new Error(`Could not resolve the published Buf commit for ${module}`);
|
|
424
|
-
}
|
|
425
|
-
const name = stringField(commit, "name") ?? stringField(commit, "commit") ?? stringField(commit, "id");
|
|
426
|
-
if (!name) {
|
|
427
|
-
throw new Error(`Buf commit response for ${module} did not include a commit identifier`);
|
|
428
|
-
}
|
|
429
|
-
return {
|
|
430
|
-
commit: name.includes(":") ? name.slice(name.lastIndexOf(":") + 1) : name,
|
|
431
|
-
digest: stringField(commit, "digest"),
|
|
432
|
-
createTime: stringField(commit, "create_time") ?? stringField(commit, "createTime"),
|
|
433
|
-
};
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
function stringField(source: Record<string, unknown>, key: string) {
|
|
437
|
-
const value = source[key];
|
|
438
|
-
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
async function writeSdkMode(mode: "local" | "remote", published?: PublishedSdk) {
|
|
442
|
-
await mkdir(".service", { recursive: true });
|
|
443
|
-
const localPath = await resolveLocalSdkPath();
|
|
444
|
-
await Bun.write(
|
|
445
|
-
".service/sdk.json",
|
|
446
|
-
`${JSON.stringify(
|
|
447
|
-
{
|
|
448
|
-
mode,
|
|
449
|
-
module: bufModule(),
|
|
450
|
-
localPath,
|
|
451
|
-
...(published
|
|
452
|
-
? {
|
|
453
|
-
remote: {
|
|
454
|
-
commit: published.commit,
|
|
455
|
-
digest: published.digest,
|
|
456
|
-
createTime: published.createTime,
|
|
457
|
-
},
|
|
458
|
-
}
|
|
459
|
-
: {}),
|
|
460
|
-
updatedAt: new Date().toISOString(),
|
|
461
|
-
},
|
|
462
|
-
null,
|
|
463
|
-
2
|
|
464
|
-
)}\n`
|
|
465
|
-
);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function bufModule() {
|
|
469
|
-
return config.buf.module || `buf.build/anmho-services/${config.serviceName}`;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function ensureBufModule(authEnv: Record<string, string>) {
|
|
473
|
-
const module = bufModule();
|
|
474
|
-
const existing = run("buf", ["registry", "module", "info", module], { env: authEnv, allowFailure: true });
|
|
475
|
-
if (existing.success) {
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
run("buf", ["registry", "module", "create", module, "--visibility", "private"], { env: authEnv });
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function resolveBufAuthEnv(): Record<string, string> {
|
|
482
|
-
const token =
|
|
483
|
-
process.env.BUF_TOKEN?.trim() ||
|
|
484
|
-
readVaultField(config.buf.vaultMount, config.buf.vaultPath, ["BUF_TOKEN", "buf.api_token", "buf_token", "api_token", "token"]);
|
|
485
|
-
if (!token) {
|
|
486
|
-
return {};
|
|
487
|
-
}
|
|
488
|
-
return { BUF_TOKEN: token };
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
async function resolveLocalSdkPath() {
|
|
492
|
-
const artifacts = await findGeneratedSdkArtifacts();
|
|
493
|
-
if (artifacts.length === 0) {
|
|
494
|
-
return config.runtime === "bun" ? "./gen/protos" : "./gen";
|
|
495
|
-
}
|
|
496
|
-
const artifact = artifacts[0] || "./gen";
|
|
497
|
-
return artifact.split("/").slice(0, -1).join("/") || "./gen";
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
async function findGeneratedSdkArtifacts() {
|
|
501
|
-
const suffixes = config.runtime === "bun" ? ["_pb.ts", "_pb.js"] : [".pb.go"];
|
|
502
|
-
const files = await findFiles("./gen");
|
|
503
|
-
return files.filter((file) => suffixes.some((suffix) => file.endsWith(suffix)));
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
async function findFiles(root: string, suffix = ""): Promise<string[]> {
|
|
507
|
-
let entries;
|
|
508
|
-
try {
|
|
509
|
-
entries = await readdir(root, { withFileTypes: true });
|
|
510
|
-
} catch {
|
|
511
|
-
return [];
|
|
512
|
-
}
|
|
513
|
-
const files: string[] = [];
|
|
514
|
-
for (const entry of entries) {
|
|
515
|
-
const path = `${root}/${entry.name}`;
|
|
516
|
-
if (entry.isDirectory()) {
|
|
517
|
-
files.push(...(await findFiles(path, suffix)));
|
|
518
|
-
} else if (!suffix || path.endsWith(suffix)) {
|
|
519
|
-
files.push(path);
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
return files;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
313
|
async function directoryExists(path: string) {
|
|
526
314
|
try {
|
|
527
315
|
return (await stat(path)).isDirectory();
|
|
@@ -110,3 +110,7 @@ export function migrationCommandForRuntime(runtime: string): RuntimeMigrationCom
|
|
|
110
110
|
|
|
111
111
|
throw new Error(`migrate is not available for ${runtime}`);
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
export function cloudRunServiceNamesForDestroy(serviceName: string) {
|
|
115
|
+
return [serviceName, `${serviceName}-worker`];
|
|
116
|
+
}
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
writeRenderedManifest,
|
|
25
25
|
writeRenderedWorkerManifest,
|
|
26
26
|
} from "./lib";
|
|
27
|
-
import { migrationCommandForRuntime } from "./deploy-args";
|
|
27
|
+
import { cloudRunServiceNamesForDestroy, migrationCommandForRuntime } from "./deploy-args";
|
|
28
28
|
|
|
29
29
|
type DeployOptions = {
|
|
30
30
|
bootstrapResult?: BootstrapResult;
|
|
@@ -49,7 +49,9 @@ export async function deploy(args = Bun.argv.slice(2), deployOptions: DeployOpti
|
|
|
49
49
|
throw new Error("Refusing to destroy the main environment");
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
for (const serviceName of cloudRunServiceNamesForDestroy(target.serviceName)) {
|
|
53
|
+
await runStep(`Deleting Cloud Run service ${serviceName}`, () => deleteService(serviceName));
|
|
54
|
+
}
|
|
53
55
|
await runStep(`Deleting Neon branch ${target.branchName}`, async () => {
|
|
54
56
|
const branches = await listBranches(neon.projectId);
|
|
55
57
|
const branch = branches.find((candidate: { name: string }) => candidate.name === target.branchName);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, expect, test } from "bun:test";
|
|
2
|
-
import { localDockerBuildArgs, migrationCommandForRuntime, parseDeployArgs } from "./deploy-args";
|
|
2
|
+
import { cloudRunServiceNamesForDestroy, localDockerBuildArgs, migrationCommandForRuntime, parseDeployArgs } from "./deploy-args";
|
|
3
3
|
|
|
4
4
|
const originalBuild = process.env.SERVICE_BUILD;
|
|
5
5
|
const originalBuildStrategy = process.env.SERVICE_BUILD_STRATEGY;
|
|
@@ -49,3 +49,7 @@ test("migrationCommandForRuntime uses generated migration tooling", () => {
|
|
|
49
49
|
args: ["migrate", "apply", "--env", "local"],
|
|
50
50
|
});
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
test("cloudRunServiceNamesForDestroy includes api and worker services", () => {
|
|
54
|
+
expect(cloudRunServiceNamesForDestroy("omnichannel-pr-6")).toEqual(["omnichannel-pr-6", "omnichannel-pr-6-worker"]);
|
|
55
|
+
});
|
|
@@ -3,7 +3,7 @@ import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { scaffoldProject, type ScaffoldConfig } from "../../scaffold";
|
|
6
|
-
import { formatSdkModeDetail } from "
|
|
6
|
+
import { formatSdkModeDetail } from "../connect-sdk-state";
|
|
7
7
|
|
|
8
8
|
function baseConfig(directory: string): ScaffoldConfig {
|
|
9
9
|
return {
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
2
|
+
import { serviceConfig } from "./runtime";
|
|
3
|
+
import { formatSdkModeDetail, type SdkState } from "./connect-sdk-state";
|
|
4
|
+
|
|
5
|
+
type CommandResult = {
|
|
6
|
+
success: boolean;
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
exitCode: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type PublishedSdk = {
|
|
13
|
+
commit: string;
|
|
14
|
+
digest?: string;
|
|
15
|
+
createTime?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type DoctorRecorder = (
|
|
19
|
+
name: string,
|
|
20
|
+
failureStatus: "warn" | "fail",
|
|
21
|
+
check: () => string | Promise<string>
|
|
22
|
+
) => Promise<void>;
|
|
23
|
+
|
|
24
|
+
const decoder = new TextDecoder();
|
|
25
|
+
|
|
26
|
+
export async function runConnectSdk(args: string[]) {
|
|
27
|
+
if ((serviceConfig.framework as string) !== "connectrpc") {
|
|
28
|
+
throw new Error("SDK commands are only available for ConnectRPC services");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const [subcommand] = args;
|
|
32
|
+
if (subcommand === "publish") {
|
|
33
|
+
requireCommand("buf");
|
|
34
|
+
const authEnv = resolveBufAuthEnv();
|
|
35
|
+
ensureBufModule(authEnv);
|
|
36
|
+
run("buf", ["push"], { env: authEnv });
|
|
37
|
+
const published = resolvePublishedSdk(authEnv);
|
|
38
|
+
await writeSdkMode("remote", published);
|
|
39
|
+
return `Schema pushed to Buf Schema Registry and recorded for consumers: ${published.commit}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (subcommand === "build") {
|
|
43
|
+
if (serviceConfig.runtime === "bun") {
|
|
44
|
+
run("bun", ["run", "gen"]);
|
|
45
|
+
} else {
|
|
46
|
+
run("make", ["gen"]);
|
|
47
|
+
}
|
|
48
|
+
await writeSdkMode("local");
|
|
49
|
+
return "Local SDK artifacts generated and recorded";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (subcommand === "use-local") {
|
|
53
|
+
await assertLocalSdkArtifacts();
|
|
54
|
+
await writeSdkMode("local");
|
|
55
|
+
return "Local SDK artifacts recorded";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (subcommand === "use-remote") {
|
|
59
|
+
requireCommand("buf");
|
|
60
|
+
const authEnv = resolveBufAuthEnv();
|
|
61
|
+
const published = resolvePublishedSdk(authEnv);
|
|
62
|
+
await writeSdkMode("remote", published);
|
|
63
|
+
return `Remote Buf SDK recorded for consumers: ${bufModule()}@${published.commit}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw new Error("Usage: service sdk <build|publish|use-local|use-remote>");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function recordConnectSdkDoctorChecks(record: DoctorRecorder) {
|
|
70
|
+
if ((serviceConfig.framework as string) !== "connectrpc") {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await record("ConnectRPC proto", "fail", async () => {
|
|
75
|
+
if (!(await Bun.file("./buf.yaml").exists())) {
|
|
76
|
+
throw new Error("missing buf.yaml");
|
|
77
|
+
}
|
|
78
|
+
const protoFiles = await findFiles("./protos", ".proto");
|
|
79
|
+
if (protoFiles.length === 0) {
|
|
80
|
+
throw new Error("missing ConnectRPC proto");
|
|
81
|
+
}
|
|
82
|
+
return `${protoFiles.length} proto file(s) present`;
|
|
83
|
+
});
|
|
84
|
+
await record("Buf CLI", "warn", () => checkCommand("buf"));
|
|
85
|
+
await record("generated SDK artifacts", "warn", async () => {
|
|
86
|
+
const artifacts = await findGeneratedSdkArtifacts();
|
|
87
|
+
if (artifacts.length === 0) {
|
|
88
|
+
throw new Error("generated SDK artifacts are missing; run service sdk build");
|
|
89
|
+
}
|
|
90
|
+
return "local generated artifacts present";
|
|
91
|
+
});
|
|
92
|
+
await record("SDK mode", "warn", async () => {
|
|
93
|
+
const text = await Bun.file(".service/sdk.json").text();
|
|
94
|
+
const state = JSON.parse(text) as SdkState;
|
|
95
|
+
return formatSdkModeDetail(state, bufModule());
|
|
96
|
+
});
|
|
97
|
+
await record("SDK remote publish", "warn", async () => {
|
|
98
|
+
const text = await Bun.file(".service/sdk.json").text();
|
|
99
|
+
const state = JSON.parse(text) as SdkState;
|
|
100
|
+
const module = state.module || bufModule();
|
|
101
|
+
if (state.mode !== "remote") {
|
|
102
|
+
throw new Error(`SDK is in ${state.mode} mode; run service sdk publish to publish ${module}`);
|
|
103
|
+
}
|
|
104
|
+
const authEnv = resolveBufAuthEnv();
|
|
105
|
+
run("buf", ["registry", "module", "info", module], { env: authEnv });
|
|
106
|
+
const published = resolvePublishedSdk(authEnv);
|
|
107
|
+
if (state.remote?.commit && published.commit !== state.remote.commit) {
|
|
108
|
+
return `remote module readable; latest ${published.commit}, recorded ${state.remote.commit}`;
|
|
109
|
+
}
|
|
110
|
+
return `remote module readable at ${module}@${published.commit}`;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function assertLocalSdkArtifacts() {
|
|
115
|
+
const artifacts = await findGeneratedSdkArtifacts();
|
|
116
|
+
if (artifacts.length === 0) {
|
|
117
|
+
throw new Error("Local SDK artifacts are missing. Run `service sdk build` first.");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolvePublishedSdk(authEnv: Record<string, string> = {}): PublishedSdk {
|
|
122
|
+
const module = bufModule();
|
|
123
|
+
const result = run("buf", ["registry", "module", "commit", "list", module, "--format", "json", "--page-size", "1"], { env: authEnv });
|
|
124
|
+
const parsed = JSON.parse(result.stdout) as {
|
|
125
|
+
commits?: Array<Record<string, unknown>>;
|
|
126
|
+
commit?: Record<string, unknown>;
|
|
127
|
+
};
|
|
128
|
+
const commit = parsed.commits?.[0] ?? parsed.commit;
|
|
129
|
+
if (!commit) {
|
|
130
|
+
throw new Error(`Could not resolve the published Buf commit for ${module}`);
|
|
131
|
+
}
|
|
132
|
+
const name = stringField(commit, "name") ?? stringField(commit, "commit") ?? stringField(commit, "id");
|
|
133
|
+
if (!name) {
|
|
134
|
+
throw new Error(`Buf commit response for ${module} did not include a commit identifier`);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
commit: name.includes(":") ? name.slice(name.lastIndexOf(":") + 1) : name,
|
|
138
|
+
digest: stringField(commit, "digest"),
|
|
139
|
+
createTime: stringField(commit, "create_time") ?? stringField(commit, "createTime"),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function stringField(source: Record<string, unknown>, key: string) {
|
|
144
|
+
const value = source[key];
|
|
145
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function writeSdkMode(mode: "local" | "remote", published?: PublishedSdk) {
|
|
149
|
+
await mkdir(".service", { recursive: true });
|
|
150
|
+
const localPath = await resolveLocalSdkPath();
|
|
151
|
+
await Bun.write(
|
|
152
|
+
".service/sdk.json",
|
|
153
|
+
`${JSON.stringify(
|
|
154
|
+
{
|
|
155
|
+
mode,
|
|
156
|
+
module: bufModule(),
|
|
157
|
+
localPath,
|
|
158
|
+
...(published
|
|
159
|
+
? {
|
|
160
|
+
remote: {
|
|
161
|
+
commit: published.commit,
|
|
162
|
+
digest: published.digest,
|
|
163
|
+
createTime: published.createTime,
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
: {}),
|
|
167
|
+
updatedAt: new Date().toISOString(),
|
|
168
|
+
},
|
|
169
|
+
null,
|
|
170
|
+
2
|
|
171
|
+
)}\n`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function bufModule() {
|
|
176
|
+
return serviceConfig.buf?.module || `buf.build/anmho-services/${serviceConfig.service_id}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function ensureBufModule(authEnv: Record<string, string>) {
|
|
180
|
+
const module = bufModule();
|
|
181
|
+
const existing = run("buf", ["registry", "module", "info", module], { env: authEnv, allowFailure: true });
|
|
182
|
+
if (existing.success) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
run("buf", ["registry", "module", "create", module, "--visibility", "private"], { env: authEnv });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function resolveBufAuthEnv(): Record<string, string> {
|
|
189
|
+
const vault = serviceConfig.providers?.vault ?? {};
|
|
190
|
+
const token =
|
|
191
|
+
process.env.BUF_TOKEN?.trim() ||
|
|
192
|
+
readVaultField(vault.mount || "secret", vault.buf_path || "prod/providers/buf", [
|
|
193
|
+
"BUF_TOKEN",
|
|
194
|
+
"buf.api_token",
|
|
195
|
+
"buf_token",
|
|
196
|
+
"api_token",
|
|
197
|
+
"token",
|
|
198
|
+
]);
|
|
199
|
+
if (!token) {
|
|
200
|
+
return {};
|
|
201
|
+
}
|
|
202
|
+
return { BUF_TOKEN: token };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function resolveLocalSdkPath() {
|
|
206
|
+
const artifacts = await findGeneratedSdkArtifacts();
|
|
207
|
+
if (artifacts.length === 0) {
|
|
208
|
+
return serviceConfig.runtime === "bun" ? "./gen/protos" : "./gen";
|
|
209
|
+
}
|
|
210
|
+
const artifact = artifacts[0] || "./gen";
|
|
211
|
+
return artifact.split("/").slice(0, -1).join("/") || "./gen";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function findGeneratedSdkArtifacts() {
|
|
215
|
+
const suffixes = serviceConfig.runtime === "bun" ? ["_pb.ts", "_pb.js"] : [".pb.go"];
|
|
216
|
+
const files = await findFiles("./gen");
|
|
217
|
+
return files.filter((file) => suffixes.some((suffix) => file.endsWith(suffix)));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function findFiles(root: string, suffix = ""): Promise<string[]> {
|
|
221
|
+
let entries;
|
|
222
|
+
try {
|
|
223
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
224
|
+
} catch {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
const files: string[] = [];
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
const path = `${root}/${entry.name}`;
|
|
230
|
+
if (entry.isDirectory()) {
|
|
231
|
+
files.push(...(await findFiles(path, suffix)));
|
|
232
|
+
} else if (!suffix || path.endsWith(suffix)) {
|
|
233
|
+
files.push(path);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return files;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function requireCommand(name: string) {
|
|
240
|
+
if (!Bun.which(name)) {
|
|
241
|
+
throw new Error(`missing required command: ${name}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function checkCommand(name: string) {
|
|
246
|
+
const path = Bun.which(name);
|
|
247
|
+
if (!path) {
|
|
248
|
+
throw new Error(`${name} is not installed`);
|
|
249
|
+
}
|
|
250
|
+
return path;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function run(command: string, args: string[], options: { allowFailure?: boolean; env?: Record<string, string | undefined> } = {}): CommandResult {
|
|
254
|
+
const result = Bun.spawnSync([command, ...args], {
|
|
255
|
+
cwd: process.cwd(),
|
|
256
|
+
env: { ...process.env, ...options.env },
|
|
257
|
+
stdout: "pipe",
|
|
258
|
+
stderr: "pipe",
|
|
259
|
+
});
|
|
260
|
+
const commandResult = {
|
|
261
|
+
success: result.success,
|
|
262
|
+
stdout: result.stdout ? decoder.decode(result.stdout).trim() : "",
|
|
263
|
+
stderr: result.stderr ? decoder.decode(result.stderr).trim() : "",
|
|
264
|
+
exitCode: result.exitCode,
|
|
265
|
+
};
|
|
266
|
+
if (!commandResult.success && !options.allowFailure) {
|
|
267
|
+
throw new Error(`command failed: ${command} ${args.join(" ")}`);
|
|
268
|
+
}
|
|
269
|
+
return commandResult;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function readVaultField(mount: string, path: string, fields: string[]) {
|
|
273
|
+
const vault = Bun.which("vault");
|
|
274
|
+
if (!vault || !path) {
|
|
275
|
+
return "";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const field of fields) {
|
|
279
|
+
const result = Bun.spawnSync([vault, "kv", "get", `-mount=${mount}`, `-field=${field}`, path], {
|
|
280
|
+
cwd: process.cwd(),
|
|
281
|
+
env: process.env,
|
|
282
|
+
stdout: "pipe",
|
|
283
|
+
stderr: "pipe",
|
|
284
|
+
});
|
|
285
|
+
if (result.success && result.stdout) {
|
|
286
|
+
const value = decoder.decode(result.stdout).trim();
|
|
287
|
+
if (value) {
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return "";
|
|
293
|
+
}
|
|
@@ -56,6 +56,7 @@ describe("local dev cleanup", () => {
|
|
|
56
56
|
|
|
57
57
|
try {
|
|
58
58
|
await waitForServer(port);
|
|
59
|
+
await waitForServiceOwnedListener(root, port, child.pid);
|
|
59
60
|
|
|
60
61
|
const result = await stopLocalDev({ root, dockerCompose: false, ports: [port] });
|
|
61
62
|
|
|
@@ -115,6 +116,18 @@ async function waitForListenerStop(port: number, pid: number) {
|
|
|
115
116
|
throw new Error(`process ${pid} on port ${port} did not stop listening`);
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
async function waitForServiceOwnedListener(root: string, port: number, pid: number) {
|
|
120
|
+
const deadline = Date.now() + 5_000;
|
|
121
|
+
while (Date.now() < deadline) {
|
|
122
|
+
const plan = await buildLocalDevCleanupPlan({ root, dockerCompose: false, ports: [port] });
|
|
123
|
+
if (plan.portProcesses.some((process) => process.pid === pid && process.port === port)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await Bun.sleep(50);
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`process ${pid} on port ${port} was not detected as service-owned`);
|
|
129
|
+
}
|
|
130
|
+
|
|
118
131
|
async function isReachable(port: number) {
|
|
119
132
|
try {
|
|
120
133
|
const response = await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(250) });
|
package/src/service.ts
CHANGED
|
@@ -159,6 +159,15 @@ async function delegateToGeneratedService(serviceRoot: string, argv: string[]) {
|
|
|
159
159
|
return;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
if (argv[0] === "sdk") {
|
|
163
|
+
const { intro, outro } = await import("@clack/prompts");
|
|
164
|
+
const { runConnectSdk } = await import("./service-runtime/connect-sdk");
|
|
165
|
+
intro("SDK");
|
|
166
|
+
const result = await runConnectSdk(argv.slice(1));
|
|
167
|
+
outro(result);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
162
171
|
if (serviceConfig.target === "workers") {
|
|
163
172
|
const { main } = await import("./service-runtime/workers/cli");
|
|
164
173
|
await main(argv);
|
|
File without changes
|