create-svc 0.1.62 → 0.1.64

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.
Files changed (67) hide show
  1. package/README.md +9 -6
  2. package/package.json +2 -1
  3. package/src/cli.test.ts +1 -0
  4. package/src/cli.ts +17 -6
  5. package/src/naming.test.ts +6 -0
  6. package/src/naming.ts +1 -1
  7. package/src/scaffold.test.ts +19 -3
  8. package/src/scaffold.ts +10 -0
  9. package/src/service-runtime/authctl.ts +32 -0
  10. package/src/service-runtime/cloudrun/bootstrap.ts +3 -4
  11. package/src/service-runtime/cloudrun/cleanup.ts +6 -1
  12. package/src/service-runtime/cloudrun/cli.ts +52 -12
  13. package/src/service-runtime/cloudrun/config.ts +3 -0
  14. package/src/service-runtime/cloudrun/deploy-args.ts +17 -0
  15. package/src/service-runtime/cloudrun/deploy.ts +25 -0
  16. package/src/service-runtime/cloudrun/lib.test.ts +12 -1
  17. package/src/service-runtime/cloudrun/lib.ts +55 -15
  18. package/src/service-runtime/cloudrun/temporal-config.test.ts +66 -0
  19. package/src/service-runtime/cloudrun/temporal-config.ts +84 -0
  20. package/src/service-runtime/workers/cli.ts +88 -0
  21. package/src/service.test.ts +15 -2
  22. package/src/service.ts +31 -1
  23. package/templates/shared/.env.example +1 -1
  24. package/templates/shared/README.md +41 -9
  25. package/templates/shared/scripts/dev.ts +37 -5
  26. package/templates/shared/service.jsonc +8 -2
  27. package/templates/shared/service.yaml +4 -1
  28. package/templates/targets/workers/.github/workflows/deploy.yml +3 -0
  29. package/templates/targets/workers/.github/workflows/preview.yml +3 -0
  30. package/templates/targets/workers/README.md +28 -0
  31. package/templates/targets/workers/package.json +6 -1
  32. package/templates/targets/workers/src/index.ts +36 -25
  33. package/templates/targets/workers/src/trigger.ts +81 -0
  34. package/templates/targets/workers/test/app.test.ts +46 -1
  35. package/templates/targets/workers/trigger/waitlist-follow-up.ts +24 -0
  36. package/templates/targets/workers/trigger.config.ts +24 -0
  37. package/templates/targets/workers/tsconfig.json +1 -1
  38. package/templates/targets/workers/wrangler.toml +2 -0
  39. package/templates/variants/bun-connectrpc/package.json +1 -1
  40. package/templates/variants/bun-connectrpc/src/index.ts +2 -6
  41. package/templates/variants/bun-connectrpc/src/temporal/client.ts +28 -0
  42. package/templates/variants/bun-connectrpc/src/temporal.ts +56 -0
  43. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +16 -1
  44. package/templates/variants/bun-connectrpc/src/worker.ts +21 -0
  45. package/templates/variants/bun-connectrpc/test/app.test.ts +22 -0
  46. package/templates/variants/bun-hono/package.json +1 -1
  47. package/templates/variants/bun-hono/src/index.ts +2 -6
  48. package/templates/variants/bun-hono/src/temporal/client.ts +28 -0
  49. package/templates/variants/bun-hono/src/temporal.ts +56 -0
  50. package/templates/variants/bun-hono/src/waitlist/service.ts +10 -1
  51. package/templates/variants/bun-hono/src/worker.ts +21 -0
  52. package/templates/variants/go-chi/Dockerfile +2 -0
  53. package/templates/variants/go-chi/Makefile +1 -1
  54. package/templates/variants/go-chi/cmd/server/main.go +5 -4
  55. package/templates/variants/go-chi/cmd/worker/main.go +56 -0
  56. package/templates/variants/go-chi/internal/app/service.go +35 -3
  57. package/templates/variants/go-chi/internal/config/config.go +34 -3
  58. package/templates/variants/go-chi/internal/config/config_test.go +63 -0
  59. package/templates/variants/go-chi/internal/temporal/client.go +55 -0
  60. package/templates/variants/go-connectrpc/Dockerfile +2 -0
  61. package/templates/variants/go-connectrpc/Makefile +1 -1
  62. package/templates/variants/go-connectrpc/cmd/server/main.go +5 -4
  63. package/templates/variants/go-connectrpc/cmd/worker/main.go +56 -0
  64. package/templates/variants/go-connectrpc/internal/app/service.go +35 -3
  65. package/templates/variants/go-connectrpc/internal/config/config.go +34 -3
  66. package/templates/variants/go-connectrpc/internal/config/config_test.go +63 -0
  67. package/templates/variants/go-connectrpc/internal/temporal/client.go +55 -0
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { config } from "./config";
4
4
  import { serviceRoot } from "../runtime";
5
5
  import { localDockerBuildArgs, parseDeployArgs, type DeployArgs } from "./deploy-args";
6
+ import { resolveTemporalRuntimeConfigValues } from "./temporal-config";
6
7
 
7
8
  type CommandOptions = {
8
9
  allowFailure?: boolean;
@@ -36,6 +37,8 @@ type CommandResult = {
36
37
  exitCode: number;
37
38
  };
38
39
 
40
+ type CloudRunProcess = "api" | "worker";
41
+
39
42
  const decoder = new TextDecoder();
40
43
  const encoder = new TextEncoder();
41
44
  const CLOUDFLARE_DNS_TTL_AUTO = 1;
@@ -506,12 +509,16 @@ export function resolveDeploymentTarget(environment: DeployArgs["environment"],
506
509
  };
507
510
  }
508
511
 
509
- export async function renderManifest(image: string, target: DeploymentTarget) {
512
+ export async function renderManifest(image: string, target: DeploymentTarget, process: CloudRunProcess = "api") {
510
513
  const template = await Bun.file(join(serviceRoot, "service.yaml")).text();
511
514
  const temporal = resolveTemporalRuntimeConfig();
515
+ const serviceName = process === "worker" ? `${target.serviceName}-worker` : target.serviceName;
512
516
  const values = {
513
- SERVICE_NAME: target.serviceName,
517
+ SERVICE_NAME: serviceName,
514
518
  SERVICE_ID: config.serviceName,
519
+ SERVICE_ROLE: process,
520
+ SERVICE_INGRESS: process === "worker" ? "internal" : "all",
521
+ CONTAINER_COMMAND: renderContainerCommand(process),
515
522
  RUNTIME_SERVICE_ACCOUNT: config.runtimeServiceAccount,
516
523
  IMAGE_URL: image,
517
524
  DATABASE_URL_SECRET: target.databaseSecretName,
@@ -545,24 +552,40 @@ export async function renderManifest(image: string, target: DeploymentTarget) {
545
552
  }
546
553
 
547
554
  export function resolveTemporalRuntimeConfig() {
548
- const enabledOverride = process.env.TEMPORAL_ENABLED?.trim();
549
- const address = process.env.TEMPORAL_ADDRESS?.trim() || config.temporal.address;
550
- const namespace = process.env.TEMPORAL_NAMESPACE?.trim() || config.temporal.namespace;
551
- const taskQueue = process.env.TEMPORAL_TASK_QUEUE?.trim() || config.temporal.taskQueue;
552
- const apiKeySecretName = process.env.TEMPORAL_API_KEY_SECRET?.trim() || (process.env.TEMPORAL_API_KEY?.trim() ? config.temporal.apiKeySecretName : "");
553
- const enabled = enabledOverride
554
- ? ["1", "true", "yes", "on"].includes(enabledOverride.toLowerCase())
555
- : Boolean(process.env.TEMPORAL_ADDRESS?.trim() || process.env.TEMPORAL_API_KEY?.trim() || process.env.TEMPORAL_API_KEY_SECRET?.trim());
555
+ return resolveTemporalRuntimeConfigValues(config.temporal, process.env, readTemporalProviderFields);
556
+ }
556
557
 
558
+ function readTemporalProviderFields(mount: string, path: string) {
557
559
  return {
558
- enabled,
559
- address,
560
- namespace,
561
- taskQueue,
562
- apiKeySecretName,
560
+ address: readVaultField(mount, path, ["TEMPORAL_ADDRESS", "address"]),
561
+ namespace: readVaultField(mount, path, ["TEMPORAL_NAMESPACE", "namespace"]),
562
+ apiKey: readVaultField(mount, path, ["TEMPORAL_API_KEY", "api_key"]),
563
563
  };
564
564
  }
565
565
 
566
+ function readVaultField(mount: string, path: string, fields: string[]) {
567
+ const vault = Bun.which("vault");
568
+ if (!vault || !path) {
569
+ return "";
570
+ }
571
+
572
+ for (const field of fields) {
573
+ const result = Bun.spawnSync([vault, "kv", "get", `-mount=${mount}`, `-field=${field}`, path], {
574
+ cwd: process.cwd(),
575
+ env: process.env,
576
+ stdout: "pipe",
577
+ stderr: "pipe",
578
+ });
579
+ if (result.success && result.stdout) {
580
+ const value = decoder.decode(result.stdout).trim();
581
+ if (value) {
582
+ return value;
583
+ }
584
+ }
585
+ }
586
+ return "";
587
+ }
588
+
566
589
  export async function writeRenderedManifest(image: string, target: DeploymentTarget) {
567
590
  const rendered = await renderManifest(image, target);
568
591
  const path = new URL("../../.cloudrun.rendered.yaml", import.meta.url);
@@ -570,6 +593,23 @@ export async function writeRenderedManifest(image: string, target: DeploymentTar
570
593
  return path;
571
594
  }
572
595
 
596
+ export async function writeRenderedWorkerManifest(image: string, target: DeploymentTarget) {
597
+ const rendered = await renderManifest(image, target, "worker");
598
+ const path = new URL("../../.cloudrun.worker.rendered.yaml", import.meta.url);
599
+ await Bun.write(path, rendered);
600
+ return path;
601
+ }
602
+
603
+ function renderContainerCommand(process: CloudRunProcess) {
604
+ if (process === "api") {
605
+ return "";
606
+ }
607
+ if (config.runtime === "bun") {
608
+ return [" command:", " - bun", " args:", " - run", " - ./src/worker.ts"].join("\n");
609
+ }
610
+ return [" command:", " - /app/worker"].join("\n");
611
+ }
612
+
573
613
  export function serviceUrl(serviceName: string) {
574
614
  return gcloud(
575
615
  ["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=value(status.url)"]
@@ -0,0 +1,66 @@
1
+ import { expect, test } from "bun:test";
2
+ import { resolveTemporalRuntimeConfigValues } from "./temporal-config";
3
+
4
+ const baseConfig = {
5
+ enabled: true,
6
+ address: "localhost:7233",
7
+ namespace: "default",
8
+ taskQueue: "orders",
9
+ apiKeySecretName: "orders-temporal-api-key",
10
+ vaultMount: "secret",
11
+ vaultPath: "prod/providers/temporal",
12
+ };
13
+
14
+ test("resolveTemporalRuntimeConfigValues reads production Temporal config from Vault fields", () => {
15
+ const resolved = resolveTemporalRuntimeConfigValues(baseConfig, {}, () => ({
16
+ address: "temporal.example.tmprl.cloud:7233",
17
+ namespace: "anmho.prod",
18
+ apiKey: "secret-key",
19
+ }));
20
+
21
+ expect(resolved).toEqual({
22
+ enabled: true,
23
+ address: "temporal.example.tmprl.cloud:7233",
24
+ namespace: "anmho.prod",
25
+ taskQueue: "orders",
26
+ apiKeySecretName: "orders-temporal-api-key",
27
+ apiKey: "secret-key",
28
+ });
29
+ });
30
+
31
+ test("resolveTemporalRuntimeConfigValues prefers explicit environment overrides", () => {
32
+ const resolved = resolveTemporalRuntimeConfigValues(
33
+ baseConfig,
34
+ {
35
+ TEMPORAL_ADDRESS: "env.temporal:7233",
36
+ TEMPORAL_NAMESPACE: "env.namespace",
37
+ TEMPORAL_TASK_QUEUE: "env-task-queue",
38
+ TEMPORAL_API_KEY: "env-key",
39
+ TEMPORAL_API_KEY_SECRET: "env-secret-name",
40
+ },
41
+ () => ({
42
+ address: "vault.temporal:7233",
43
+ namespace: "vault.namespace",
44
+ apiKey: "vault-key",
45
+ })
46
+ );
47
+
48
+ expect(resolved.address).toBe("env.temporal:7233");
49
+ expect(resolved.namespace).toBe("env.namespace");
50
+ expect(resolved.taskQueue).toBe("env-task-queue");
51
+ expect(resolved.apiKey).toBe("env-key");
52
+ expect(resolved.apiKeySecretName).toBe("env-secret-name");
53
+ });
54
+
55
+ test("resolveTemporalRuntimeConfigValues fails clearly when enabled Temporal resolves to localhost", () => {
56
+ expect(() => resolveTemporalRuntimeConfigValues(baseConfig, {}, () => ({}))).toThrow(
57
+ "Temporal is enabled for this Cloud Run service, but the resolved Temporal address is local"
58
+ );
59
+ });
60
+
61
+ test("resolveTemporalRuntimeConfigValues allows explicit Temporal disable", () => {
62
+ const resolved = resolveTemporalRuntimeConfigValues(baseConfig, { TEMPORAL_ENABLED: "false" }, () => ({}));
63
+
64
+ expect(resolved.enabled).toBeFalse();
65
+ expect(resolved.apiKeySecretName).toBe("");
66
+ });
@@ -0,0 +1,84 @@
1
+ type TemporalConfigInput = {
2
+ enabled: boolean;
3
+ address: string;
4
+ namespace: string;
5
+ taskQueue: string;
6
+ apiKeySecretName: string;
7
+ vaultMount: string;
8
+ vaultPath: string;
9
+ };
10
+
11
+ type TemporalProviderFields = {
12
+ address?: string;
13
+ namespace?: string;
14
+ apiKey?: string;
15
+ };
16
+
17
+ export type TemporalRuntimeConfig = {
18
+ enabled: boolean;
19
+ address: string;
20
+ namespace: string;
21
+ taskQueue: string;
22
+ apiKeySecretName: string;
23
+ apiKey: string;
24
+ };
25
+
26
+ export function resolveTemporalRuntimeConfigValues(
27
+ config: TemporalConfigInput,
28
+ env: Record<string, string | undefined>,
29
+ readProviderFields: (mount: string, path: string) => TemporalProviderFields
30
+ ): TemporalRuntimeConfig {
31
+ const enabledOverride = env.TEMPORAL_ENABLED?.trim();
32
+ const enabled = enabledOverride ? isTruthy(enabledOverride) : config.enabled;
33
+ const taskQueue = env.TEMPORAL_TASK_QUEUE?.trim() || config.taskQueue;
34
+
35
+ if (!enabled) {
36
+ return {
37
+ enabled: false,
38
+ address: env.TEMPORAL_ADDRESS?.trim() || config.address,
39
+ namespace: env.TEMPORAL_NAMESPACE?.trim() || config.namespace,
40
+ taskQueue,
41
+ apiKeySecretName: "",
42
+ apiKey: "",
43
+ };
44
+ }
45
+
46
+ const provider = readProviderFields(config.vaultMount, config.vaultPath);
47
+ const address = env.TEMPORAL_ADDRESS?.trim() || provider.address || config.address;
48
+ const namespace = env.TEMPORAL_NAMESPACE?.trim() || provider.namespace || config.namespace;
49
+ const apiKey = env.TEMPORAL_API_KEY?.trim() || provider.apiKey || "";
50
+ const apiKeySecretName = env.TEMPORAL_API_KEY_SECRET?.trim() || (apiKey ? config.apiKeySecretName : "");
51
+
52
+ if (isLocalTemporalAddress(address)) {
53
+ throw new Error(
54
+ [
55
+ "Temporal is enabled for this Cloud Run service, but the resolved Temporal address is local.",
56
+ `Set TEMPORAL_ADDRESS, TEMPORAL_NAMESPACE, and TEMPORAL_API_KEY, or populate Vault at ${config.vaultMount}/${config.vaultPath}`,
57
+ "with TEMPORAL_ADDRESS, TEMPORAL_NAMESPACE, and TEMPORAL_API_KEY before running service create or service deploy.",
58
+ "Set TEMPORAL_ENABLED=false only for services that should deploy without Temporal.",
59
+ ].join(" ")
60
+ );
61
+ }
62
+
63
+ if (!namespace) {
64
+ throw new Error(`Temporal is enabled but TEMPORAL_NAMESPACE is missing; set it in env or Vault at ${config.vaultMount}/${config.vaultPath}`);
65
+ }
66
+
67
+ return {
68
+ enabled,
69
+ address,
70
+ namespace,
71
+ taskQueue,
72
+ apiKeySecretName,
73
+ apiKey,
74
+ };
75
+ }
76
+
77
+ function isTruthy(value: string) {
78
+ return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
79
+ }
80
+
81
+ function isLocalTemporalAddress(address: string) {
82
+ const value = address.trim().toLowerCase();
83
+ return value === "" || value.startsWith("localhost:") || value.startsWith("127.0.0.1:") || value.startsWith("[::1]:");
84
+ }
@@ -15,6 +15,12 @@ const config = {
15
15
  hostname: serviceConfig.dns.hostname,
16
16
  neonDatabaseName: serviceConfig.neon.database_name,
17
17
  neonRoleName: serviceConfig.neon.role_name,
18
+ triggerDev: {
19
+ projectRefEnv: serviceConfig.workers?.trigger_dev?.project_ref_env || "TRIGGER_PROJECT_REF",
20
+ accessTokenEnv: serviceConfig.workers?.trigger_dev?.access_token_env || "TRIGGER_ACCESS_TOKEN",
21
+ secretKeyEnv: serviceConfig.workers?.trigger_dev?.secret_key_env || "TRIGGER_SECRET_KEY",
22
+ waitlistTaskId: serviceConfig.workers?.trigger_dev?.waitlist_task_id || `${serviceConfig.service_id}-waitlist-follow-up`,
23
+ },
18
24
  git: {
19
25
  enabled: Boolean(serviceConfig.git?.enabled),
20
26
  owner: serviceConfig.git?.owner || "anmho",
@@ -35,18 +41,28 @@ export async function main(argv = Bun.argv.slice(2)) {
35
41
 
36
42
  if (command === "create") {
37
43
  return runMain("Create", async () => {
44
+ ensureTriggerDevConfig();
38
45
  ensureAuthResourceServer();
39
46
  ensureAuthClient();
40
47
  const databaseUrl = await resolveDatabaseUrl({ preferRemote: true });
41
48
  await applySchemaWithRetries(databaseUrl);
42
49
  await ensureHyperdrive(databaseUrl);
50
+ deployTriggerDevTasks();
51
+ publishTriggerDevSecret();
43
52
  run("wrangler", ["deploy"]);
44
53
  return `Created https://${config.hostname}`;
45
54
  });
46
55
  }
47
56
 
48
57
  if (command === "deploy") {
58
+ if (hasHelpFlag(rest)) {
59
+ console.log(formatHelp());
60
+ return;
61
+ }
49
62
  return runMain("Deploy", () => {
63
+ ensureTriggerDevConfig();
64
+ deployTriggerDevTasks();
65
+ publishTriggerDevSecret();
50
66
  run("wrangler", ["deploy", ...rest]);
51
67
  return `Deployed https://${config.hostname}`;
52
68
  });
@@ -148,6 +164,10 @@ export async function main(argv = Bun.argv.slice(2)) {
148
164
  throw new Error(`Unknown command: ${command}\n\n${formatHelp()}`);
149
165
  }
150
166
 
167
+ function hasHelpFlag(args: string[]) {
168
+ return args.includes("--help") || args.includes("-h") || args.includes("help");
169
+ }
170
+
151
171
  function formatHelp() {
152
172
  return [
153
173
  "Usage:",
@@ -507,6 +527,11 @@ async function runDoctor() {
507
527
  return "dashboard directory found";
508
528
  });
509
529
  await record(results, "authctl", "warn", () => runAuthDoctor().detail);
530
+ await record(results, "Trigger.dev config", "fail", () => {
531
+ ensureTriggerDevConfig();
532
+ return `${config.triggerDev.waitlistTaskId} configured`;
533
+ });
534
+ await record(results, "Trigger.dev CLI", "warn", () => checkCommand("trigger"));
510
535
  await record(results, "deployed health", "warn", async () => {
511
536
  const response = await fetch(`https://${config.hostname}/healthz`, { signal: AbortSignal.timeout(5_000) });
512
537
  if (!response.ok) {
@@ -523,6 +548,69 @@ async function runDoctor() {
523
548
  return output;
524
549
  }
525
550
 
551
+ function ensureTriggerDevConfig() {
552
+ const missing = [];
553
+ if (!process.env[config.triggerDev.projectRefEnv]?.trim()) {
554
+ missing.push(config.triggerDev.projectRefEnv);
555
+ }
556
+ if (!process.env[config.triggerDev.accessTokenEnv]?.trim()) {
557
+ missing.push(config.triggerDev.accessTokenEnv);
558
+ }
559
+ if (!process.env[config.triggerDev.secretKeyEnv]?.trim()) {
560
+ missing.push(config.triggerDev.secretKeyEnv);
561
+ }
562
+ if (missing.length > 0) {
563
+ throw new Error(`${formatList(missing)} required for Workers Trigger.dev task deployment and dispatch`);
564
+ }
565
+ }
566
+
567
+ function formatList(values: string[]) {
568
+ if (values.length <= 1) {
569
+ return values[0] ?? "";
570
+ }
571
+ if (values.length === 2) {
572
+ return values.join(" and ");
573
+ }
574
+ return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`;
575
+ }
576
+
577
+ function deployTriggerDevTasks() {
578
+ run("trigger", ["deploy", "--project-ref", process.env[config.triggerDev.projectRefEnv]?.trim() || ""]);
579
+ }
580
+
581
+ function publishTriggerDevSecret() {
582
+ const secret = process.env[config.triggerDev.secretKeyEnv]?.trim();
583
+ if (!secret) {
584
+ throw new Error(`${config.triggerDev.secretKeyEnv} required to publish the Workers Trigger.dev secret`);
585
+ }
586
+ const wrangler = resolveCommandPath("wrangler");
587
+ if (!wrangler) {
588
+ throw new Error("missing required command: wrangler");
589
+ }
590
+ runShell(`printf %s "$${config.triggerDev.secretKeyEnv}" | ${shellQuote(wrangler)} secret put TRIGGER_SECRET_KEY --name ${shellQuote(config.serviceName)}`);
591
+ }
592
+
593
+ function runShell(script: string) {
594
+ const shell = Bun.which("sh");
595
+ if (!shell) {
596
+ throw new Error("missing required command: sh");
597
+ }
598
+ const result = Bun.spawnSync([shell, "-c", script], {
599
+ cwd: process.cwd(),
600
+ env: process.env,
601
+ stdin: "inherit",
602
+ stdout: "inherit",
603
+ stderr: "inherit",
604
+ });
605
+ if (!result.success) {
606
+ throw new Error(`shell command failed with exit code ${result.exitCode}: ${script}`);
607
+ }
608
+ }
609
+
610
+ function shellQuote(value: string) {
611
+ return `'${value.replace(/'/g, `'\\''`)}'`;
612
+ }
613
+
526
614
  async function record(
527
615
  results: Array<{ name: string; status: DoctorStatus; detail: string }>,
528
616
  name: string,
@@ -2,7 +2,13 @@ import { expect, test } from "bun:test";
2
2
  import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { findGeneratedServiceRoot, formatOutsideServiceCommandError, generatedDependenciesInstalled, normalizeScaffoldArgs } from "./service";
5
+ import {
6
+ findGeneratedServiceRoot,
7
+ formatOutsideServiceCommandError,
8
+ generatedDependenciesInstalled,
9
+ generatedServiceCommandHelp,
10
+ normalizeScaffoldArgs,
11
+ } from "./service";
6
12
 
7
13
  test("normalizeScaffoldArgs treats explicit scaffold commands as generator commands", () => {
8
14
  expect(normalizeScaffoldArgs(["create", "launch-api", "--yes"])).toEqual(["launch-api", "--yes"]);
@@ -23,7 +29,7 @@ test("formatOutsideServiceCommandError rejects repo-local commands outside gener
23
29
  test("formatOutsideServiceCommandError does not treat positional names as scaffold commands", () => {
24
30
  const message = formatOutsideServiceCommandError("launch-api");
25
31
  expect(message).toContain("Unknown command: launch-api");
26
- expect(message).toContain("service create <service_id>");
32
+ expect(message).toContain("service new <service_id>");
27
33
  });
28
34
 
29
35
  test("findGeneratedServiceRoot detects generated service context from nested directories", async () => {
@@ -47,3 +53,10 @@ test("generatedDependenciesInstalled requires node_modules when package.json exi
47
53
  await mkdir(join(root, "node_modules"));
48
54
  expect(generatedDependenciesInstalled(root)).toBeTrue();
49
55
  });
56
+
57
+ test("generatedServiceCommandHelp intercepts deploy help before side effects", () => {
58
+ expect(generatedServiceCommandHelp(["deploy", "--help"])).toContain("service deploy");
59
+ expect(generatedServiceCommandHelp(["deploy", "-h"])).toContain("--environment");
60
+ expect(generatedServiceCommandHelp(["deploy"])).toBeUndefined();
61
+ expect(generatedServiceCommandHelp(["destroy", "--help"])).toBeUndefined();
62
+ });
package/src/service.ts CHANGED
@@ -58,7 +58,7 @@ export function formatOutsideServiceCommandError(command: string) {
58
58
  "",
59
59
  "No service.jsonc was found in this directory or its parents.",
60
60
  "To create a new service, run:",
61
- " service create <service_id>",
61
+ " service new <service_id>",
62
62
  ].join("\n");
63
63
  }
64
64
 
@@ -85,6 +85,12 @@ function isGeneratedServiceRoot(path: string) {
85
85
  }
86
86
 
87
87
  async function delegateToGeneratedService(serviceRoot: string, argv: string[]) {
88
+ const commandHelp = generatedServiceCommandHelp(argv);
89
+ if (commandHelp) {
90
+ console.log(commandHelp);
91
+ return;
92
+ }
93
+
88
94
  ensureGeneratedDependencies(serviceRoot);
89
95
  process.chdir(serviceRoot);
90
96
  process.env.CREATE_SVC_SERVICE_ROOT = serviceRoot;
@@ -123,3 +129,27 @@ function ensureGeneratedDependencies(serviceRoot: string) {
123
129
  process.exit(result.exitCode || 1);
124
130
  }
125
131
  }
132
+
133
+ export function generatedServiceCommandHelp(argv: string[]) {
134
+ const [command, ...rest] = argv;
135
+ if (command !== "deploy" || !hasHelpFlag(rest)) {
136
+ return undefined;
137
+ }
138
+
139
+ return [
140
+ "Usage:",
141
+ " service deploy [--ci] [--environment main|preview|personal] [--name <name>]",
142
+ "",
143
+ "Options:",
144
+ " --ci Run without interactive prompts",
145
+ " --environment <environment> Deploy main, preview, or personal",
146
+ " --name <name> Name preview or personal environment",
147
+ " --build <local|cloudbuild> Select image build strategy",
148
+ " --cloud-build Use Cloud Build",
149
+ " --destroy Destroy a non-main deployment environment",
150
+ ].join("\n");
151
+ }
152
+
153
+ function hasHelpFlag(args: string[]) {
154
+ return args.includes("--help") || args.includes("-h") || args.includes("help");
155
+ }
@@ -3,7 +3,7 @@
3
3
 
4
4
  DATABASE_URL=postgres://{{LOCAL_DATABASE_USER}}:{{LOCAL_DATABASE_PASSWORD}}@127.0.0.1:{{LOCAL_DATABASE_PORT}}/{{LOCAL_DATABASE_NAME}}?sslmode=disable
5
5
 
6
- TEMPORAL_ENABLED=false
6
+ TEMPORAL_ENABLED=true
7
7
  TEMPORAL_ADDRESS=localhost:7233
8
8
  TEMPORAL_NAMESPACE=default
9
9
  TEMPORAL_TASK_QUEUE={{SERVICE_ID}}
@@ -55,10 +55,49 @@ Local runtime uses:
55
55
 
56
56
  - `DATABASE_URL` from `.env.local`, pointed at Docker Compose Postgres
57
57
  - `{{COMMAND_MIGRATE}}` and `{{COMMAND_DEV}}`, which open Docker Desktop if needed, wait for Docker readiness, and start Docker Compose Postgres
58
- - `TEMPORAL_ENABLED=false` by default; set Temporal env vars locally only when you want to run against a real Temporal server
58
+ - `TEMPORAL_ENABLED=true` by default with `localhost:7233` and `default`; set `TEMPORAL_ENABLED=false` when you are not running a local Temporal server
59
59
 
60
60
  No cloud credentials are required for local HTTP development after Docker and Postgres are running.
61
61
 
62
+ ## Temporal
63
+
64
+ Temporal is enabled by default for generated services.
65
+
66
+ Local development uses these generated defaults:
67
+
68
+ - `TEMPORAL_ENABLED=true`
69
+ - `TEMPORAL_ADDRESS=localhost:7233`
70
+ - `TEMPORAL_NAMESPACE=default`
71
+ - `TEMPORAL_TASK_QUEUE={{TEMPORAL_TASK_QUEUE}}`
72
+
73
+ The generated app deploys a Cloud Run API service and, when Temporal is enabled,
74
+ a separate Cloud Run worker service named `{{SERVICE_ID}}-worker`.
75
+ When `POST /v1/triggers/waitlist` or the ConnectRPC `RecordTrigger` method
76
+ creates a trigger, the API service starts `waitlistFollowUpWorkflow` /
77
+ `WaitlistFollowUpWorkflow` asynchronously on the service task queue. The API
78
+ request only waits for the trigger record; workflow completion happens through
79
+ Temporal and is polled by the worker service.
80
+ Local `{{COMMAND_DEV}}` starts the API process and the worker process together.
81
+
82
+ Production and preview deploys render `TEMPORAL_ENABLED=true` into the Cloud Run
83
+ manifest unless you override it. For Temporal Cloud, replace the local defaults
84
+ with real connection settings before the worker can run:
85
+
86
+ - `TEMPORAL_ADDRESS`
87
+ - `TEMPORAL_NAMESPACE`
88
+ - any TLS certificate/key or API-key settings used by your worker code
89
+
90
+ If a service does not need Temporal, opt out with:
91
+
92
+ ```bash
93
+ TEMPORAL_ENABLED=false {{COMMAND_DEV}}
94
+ TEMPORAL_ENABLED=false {{COMMAND_DEPLOY}}
95
+ ```
96
+
97
+ When Cloud Run starts with Temporal enabled but without address or namespace
98
+ values, the generated runtime fails during startup with an explicit
99
+ configuration error instead of silently running a broken worker.
100
+
62
101
  ## Remote provisioning
63
102
 
64
103
  The generated service config lives in [service.jsonc](service.jsonc).
@@ -158,14 +197,7 @@ Optional remote-only Vault overrides for Neon admin key lookup:
158
197
 
159
198
  The generator only stores application-facing connection material in Secret Manager. Neon admin credentials stay local to create and deploy.
160
199
 
161
- ## Temporal
162
-
163
- Cloud Run variants include an in-process Temporal worker in the service process.
164
- Production Temporal is enabled when you set `TEMPORAL_ENABLED=true`, or when
165
- `TEMPORAL_ADDRESS`, `TEMPORAL_API_KEY`, or `TEMPORAL_API_KEY_SECRET` is present
166
- during deploy rendering.
167
-
168
- For Temporal Cloud, provide:
200
+ For production Temporal Cloud, provide:
169
201
 
170
202
  ```bash
171
203
  TEMPORAL_ENABLED=true
@@ -1,10 +1,10 @@
1
1
  import { readLocalEnv } from "./local-env";
2
2
  import { ensureLocalPostgres } from "./local-docker";
3
3
 
4
- const command = Bun.argv.slice(2);
4
+ const { apiCommand, workerCommand } = parseCommands(Bun.argv.slice(2));
5
5
 
6
- if (command.length === 0) {
7
- throw new Error("Usage: bun run ./scripts/dev.ts <command...>");
6
+ if (apiCommand.length === 0) {
7
+ throw new Error("Usage: bun run ./scripts/dev.ts <api-command...> [--worker <worker-command...>]");
8
8
  }
9
9
 
10
10
  await ensureLocalPostgres();
@@ -17,11 +17,43 @@ if (env.DATABASE_URL && !env.CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPER
17
17
  env.CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE = env.DATABASE_URL;
18
18
  }
19
19
 
20
- const child = Bun.spawn(command, {
20
+ const api = Bun.spawn(apiCommand, {
21
21
  stdin: "inherit",
22
22
  stdout: "inherit",
23
23
  stderr: "inherit",
24
24
  env,
25
25
  });
26
26
 
27
- process.exit(await child.exited);
27
+ const worker = workerCommand
28
+ ? Bun.spawn(workerCommand, {
29
+ stdin: "ignore",
30
+ stdout: "inherit",
31
+ stderr: "inherit",
32
+ env: {
33
+ ...env,
34
+ PORT: env.TEMPORAL_WORKER_PORT || "0",
35
+ },
36
+ })
37
+ : undefined;
38
+
39
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
40
+ process.on(signal, () => {
41
+ api.kill(signal);
42
+ worker?.kill(signal);
43
+ });
44
+ }
45
+
46
+ const exitCode = await Promise.race([api.exited, worker?.exited ?? new Promise<never>(() => {})]);
47
+ api.kill();
48
+ worker?.kill();
49
+ process.exit(exitCode);
50
+
51
+ function parseCommands(argv: string[]) {
52
+ const separator = argv.indexOf("--worker");
53
+ if (separator === -1) {
54
+ return { apiCommand: argv, workerCommand: undefined };
55
+ }
56
+ const apiCommand = argv.slice(0, separator);
57
+ const workerCommand = argv.slice(separator + 1);
58
+ return { apiCommand, workerCommand: workerCommand.length > 0 ? workerCommand : undefined };
59
+ }
@@ -53,7 +53,7 @@
53
53
  },
54
54
 
55
55
  "temporal": {
56
- "enabled": false,
56
+ "enabled": true,
57
57
  "address": "localhost:7233",
58
58
  "namespace": "default",
59
59
  "task_queue": "{{SERVICE_ID}}",
@@ -117,6 +117,12 @@
117
117
  "workers": {
118
118
  "script_name": "{{SERVICE_ID}}",
119
119
  "hyperdrive_binding": "HYPERDRIVE",
120
- "cron": "*/15 * * * *"
120
+ "trigger_dev": {
121
+ "project_ref_env": "TRIGGER_PROJECT_REF",
122
+ "access_token_env": "TRIGGER_ACCESS_TOKEN",
123
+ "secret_key_env": "TRIGGER_SECRET_KEY",
124
+ "api_url": "https://api.trigger.dev",
125
+ "waitlist_task_id": "{{SERVICE_ID}}-waitlist-follow-up"
126
+ }
121
127
  }
122
128
  }
@@ -5,18 +5,21 @@ metadata:
5
5
  labels:
6
6
  managed_by: create-service
7
7
  service_id: ${SERVICE_ID}
8
+ service_role: ${SERVICE_ROLE}
8
9
  annotations:
9
- run.googleapis.com/ingress: all
10
+ run.googleapis.com/ingress: ${SERVICE_INGRESS}
10
11
  spec:
11
12
  template:
12
13
  metadata:
13
14
  labels:
14
15
  managed_by: create-service
15
16
  service_id: ${SERVICE_ID}
17
+ service_role: ${SERVICE_ROLE}
16
18
  spec:
17
19
  serviceAccountName: ${RUNTIME_SERVICE_ACCOUNT}
18
20
  containers:
19
21
  - image: ${IMAGE_URL}
22
+ ${CONTAINER_COMMAND}
20
23
  ports:
21
24
  - containerPort: 8080
22
25
  env:
@@ -16,5 +16,8 @@ jobs:
16
16
  - run: bun run deploy
17
17
  env:
18
18
  CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
19
+ TRIGGER_PROJECT_REF: ${{ secrets.TRIGGER_PROJECT_REF }}
20
+ TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
21
+ TRIGGER_SECRET_KEY: ${{ secrets.TRIGGER_SECRET_KEY }}
19
22
  - if: ${{ vars.GCX_ENABLED == 'true' }}
20
23
  run: bun run dashboards