create-svc 0.1.77 → 0.1.80
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 +30 -0
- package/package.json +1 -1
- package/src/git-bootstrap.ts +2 -0
- package/src/github-protection.ts +196 -0
- package/src/scaffold.test.ts +12 -0
- package/src/scaffold.ts +1 -0
- package/src/service-diagnostics.test.ts +46 -0
- package/src/service-diagnostics.ts +170 -0
- package/src/service-runtime/local-dev.test.ts +5 -1
- package/src/service-runtime/local-dev.ts +35 -4
- package/src/service.test.ts +7 -0
- package/src/service.ts +68 -1
- package/templates/shared/README.md +11 -0
- package/templates/targets/workers/Makefile +4 -1
- package/templates/targets/workers/README.md +11 -0
- package/templates/targets/workers/package.json +1 -0
- package/templates/variants/bun-connectrpc/Makefile +4 -1
- package/templates/variants/bun-connectrpc/package.json +1 -0
- package/templates/variants/bun-hono/Makefile +4 -1
- package/templates/variants/bun-hono/package.json +1 -0
- package/templates/variants/go-chi/Makefile +4 -1
- package/templates/variants/go-chi/package.json +1 -0
- package/templates/variants/go-connectrpc/Makefile +4 -1
- package/templates/variants/go-connectrpc/package.json +1 -0
package/README.md
CHANGED
|
@@ -17,6 +17,36 @@ Local provisioning intentionally prefers known-good CLIs over SDK-heavy orchestr
|
|
|
17
17
|
|
|
18
18
|
npm: <https://www.npmjs.com/package/create-svc>
|
|
19
19
|
|
|
20
|
+
## Install and update
|
|
21
|
+
|
|
22
|
+
For an installed CLI, use npm as the canonical owner:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g create-svc@latest
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
That installs the `service`, `create-svc`, and `create-service` commands from the same package. Check the binary that is actually running:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
service --version
|
|
32
|
+
service doctor
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`service doctor` reports the active binary path, package root, installed package version, npm latest version, and any other `service` binaries on `PATH`. If a stale Homebrew or manually copied binary is shadowing npm, remove that stale binary and reinstall npm latest:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
which -a service
|
|
39
|
+
rm "/opt/homebrew/bin/service"
|
|
40
|
+
npm install -g create-svc@latest
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If the stale binary came from a global npm install you no longer want, remove and reinstall it through npm instead:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm uninstall -g create-svc
|
|
47
|
+
npm install -g create-svc@latest
|
|
48
|
+
```
|
|
49
|
+
|
|
20
50
|
## Usage
|
|
21
51
|
|
|
22
52
|
```bash
|
package/package.json
CHANGED
package/src/git-bootstrap.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
+
import { protectMainBranch } from "./github-protection";
|
|
4
5
|
|
|
5
6
|
export type GitBootstrapConfig = {
|
|
6
7
|
enabled: boolean;
|
|
@@ -50,6 +51,7 @@ export async function bootstrapGitHubRepository(targetDir: string, config: GitBo
|
|
|
50
51
|
run(["gh", "repo", "create", repository, "--private", "--source", ".", "--remote", "origin", "--push"], targetDir, undefined, {
|
|
51
52
|
quiet: true,
|
|
52
53
|
});
|
|
54
|
+
protectMainBranch({ repo: repository, cwd: targetDir });
|
|
53
55
|
|
|
54
56
|
return {
|
|
55
57
|
status: "created",
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
type CommandOptions = {
|
|
2
|
+
cwd?: string;
|
|
3
|
+
input?: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
type CommandResult = {
|
|
7
|
+
success: boolean;
|
|
8
|
+
stdout: string;
|
|
9
|
+
stderr: string;
|
|
10
|
+
exitCode: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type CommandRunner = (command: string, args: string[], options?: CommandOptions) => CommandResult;
|
|
14
|
+
|
|
15
|
+
export type ProtectionOptions = {
|
|
16
|
+
repo?: string;
|
|
17
|
+
branch?: string;
|
|
18
|
+
requiredChecks?: string[];
|
|
19
|
+
cwd?: string;
|
|
20
|
+
runner?: CommandRunner;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type BranchProtectionRequest = {
|
|
24
|
+
required_status_checks: {
|
|
25
|
+
strict: boolean;
|
|
26
|
+
contexts: string[];
|
|
27
|
+
};
|
|
28
|
+
enforce_admins: boolean;
|
|
29
|
+
required_pull_request_reviews: {
|
|
30
|
+
dismiss_stale_reviews: boolean;
|
|
31
|
+
required_approving_review_count: number;
|
|
32
|
+
};
|
|
33
|
+
restrictions: null;
|
|
34
|
+
required_linear_history: boolean;
|
|
35
|
+
allow_force_pushes: boolean;
|
|
36
|
+
allow_deletions: boolean;
|
|
37
|
+
block_creations: boolean;
|
|
38
|
+
required_conversation_resolution: boolean;
|
|
39
|
+
lock_branch: boolean;
|
|
40
|
+
allow_fork_syncing: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const DEFAULT_BRANCH = "main";
|
|
44
|
+
const DEFAULT_REQUIRED_CHECKS = ["test", "deploy"];
|
|
45
|
+
const decoder = new TextDecoder();
|
|
46
|
+
const encoder = new TextEncoder();
|
|
47
|
+
|
|
48
|
+
export function parseProtectMainArgs(argv: string[]) {
|
|
49
|
+
const parsed: Pick<ProtectionOptions, "repo" | "branch"> = {};
|
|
50
|
+
|
|
51
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
52
|
+
const token = argv[index];
|
|
53
|
+
if (!token) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const next = argv[index + 1];
|
|
58
|
+
const readValue = () => {
|
|
59
|
+
if (!next || next.startsWith("-")) {
|
|
60
|
+
throw new Error(`Missing value for ${token}`);
|
|
61
|
+
}
|
|
62
|
+
index += 1;
|
|
63
|
+
return next;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (token === "--repo") {
|
|
67
|
+
parsed.repo = readValue();
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (token.startsWith("--repo=")) {
|
|
72
|
+
parsed.repo = token.slice("--repo=".length);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (token === "--branch") {
|
|
77
|
+
parsed.branch = readValue();
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (token.startsWith("--branch=")) {
|
|
82
|
+
parsed.branch = token.slice("--branch=".length);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
throw new Error(`Unknown argument for protect-main: ${token}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return parsed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function protectMainBranch(options: ProtectionOptions = {}) {
|
|
93
|
+
const runner = options.runner ?? run;
|
|
94
|
+
const repo = normalizeRepo(options.repo ?? process.env.GITHUB_REPOSITORY ?? discoverRepo(runner, options.cwd));
|
|
95
|
+
const branch = options.branch ?? DEFAULT_BRANCH;
|
|
96
|
+
const requiredChecks = options.requiredChecks ?? DEFAULT_REQUIRED_CHECKS;
|
|
97
|
+
const request = buildBranchProtectionRequest(requiredChecks);
|
|
98
|
+
const endpoint = `/repos/${repo}/branches/${branch}/protection`;
|
|
99
|
+
const result = runner("gh", ["api", "--method", "PUT", endpoint, "--input", "-"], {
|
|
100
|
+
cwd: options.cwd,
|
|
101
|
+
input: `${JSON.stringify(request)}\n`,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!result.success) {
|
|
105
|
+
throw new Error(formatProtectionFailure(repo, branch, result));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
repo,
|
|
110
|
+
branch,
|
|
111
|
+
requiredChecks,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function buildBranchProtectionRequest(requiredChecks = DEFAULT_REQUIRED_CHECKS): BranchProtectionRequest {
|
|
116
|
+
return {
|
|
117
|
+
required_status_checks: {
|
|
118
|
+
strict: true,
|
|
119
|
+
contexts: requiredChecks,
|
|
120
|
+
},
|
|
121
|
+
enforce_admins: true,
|
|
122
|
+
required_pull_request_reviews: {
|
|
123
|
+
dismiss_stale_reviews: true,
|
|
124
|
+
required_approving_review_count: 1,
|
|
125
|
+
},
|
|
126
|
+
restrictions: null,
|
|
127
|
+
required_linear_history: false,
|
|
128
|
+
allow_force_pushes: false,
|
|
129
|
+
allow_deletions: false,
|
|
130
|
+
block_creations: false,
|
|
131
|
+
required_conversation_resolution: true,
|
|
132
|
+
lock_branch: false,
|
|
133
|
+
allow_fork_syncing: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function formatProtectionFailure(repo: string, branch: string, result: CommandResult) {
|
|
138
|
+
const details = [result.stderr, result.stdout].filter(Boolean).join("\n");
|
|
139
|
+
const permissionHint = isPermissionFailure(details)
|
|
140
|
+
? [
|
|
141
|
+
"",
|
|
142
|
+
"The authenticated GitHub token must have repository administration permission for this generated service repo.",
|
|
143
|
+
"Grant repo admin access or a fine-grained token with Administration: write, then rerun `service protect-main`.",
|
|
144
|
+
].join("\n")
|
|
145
|
+
: "";
|
|
146
|
+
|
|
147
|
+
return [`Failed to reconcile ${branch} branch protection for ${repo}.`, details, permissionHint].filter(Boolean).join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function discoverRepo(runner: CommandRunner, cwd?: string) {
|
|
151
|
+
const result = runner("git", ["config", "--get", "remote.origin.url"], { cwd });
|
|
152
|
+
if (!result.success || !result.stdout) {
|
|
153
|
+
throw new Error("Unable to determine GitHub repo. Pass --repo owner/name or set GITHUB_REPOSITORY.");
|
|
154
|
+
}
|
|
155
|
+
return result.stdout;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizeRepo(value: string) {
|
|
159
|
+
const trimmed = value.trim();
|
|
160
|
+
const sshMatch = trimmed.match(/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
161
|
+
if (sshMatch?.[1]) {
|
|
162
|
+
return sshMatch[1];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const urlMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
166
|
+
if (urlMatch?.[1]) {
|
|
167
|
+
return urlMatch[1];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
|
|
171
|
+
return trimmed.replace(/\.git$/, "");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
throw new Error(`Invalid GitHub repo: ${value}. Expected owner/name.`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isPermissionFailure(details: string) {
|
|
178
|
+
return /403|404|admin|administration|resource not accessible|not found|forbidden/i.test(details);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function run(command: string, args: string[], options: CommandOptions = {}): CommandResult {
|
|
182
|
+
const result = Bun.spawnSync([command, ...args], {
|
|
183
|
+
cwd: options.cwd ?? process.cwd(),
|
|
184
|
+
env: process.env,
|
|
185
|
+
stdin: options.input === undefined ? undefined : encoder.encode(options.input),
|
|
186
|
+
stdout: "pipe",
|
|
187
|
+
stderr: "pipe",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
success: result.success,
|
|
192
|
+
stdout: result.stdout ? decoder.decode(result.stdout).trim() : "",
|
|
193
|
+
stderr: result.stderr ? decoder.decode(result.stderr).trim() : "",
|
|
194
|
+
exitCode: result.exitCode,
|
|
195
|
+
};
|
|
196
|
+
}
|
package/src/scaffold.test.ts
CHANGED
|
@@ -195,6 +195,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
195
195
|
expect(packageJson).toContain('"migrate": "service migrate"');
|
|
196
196
|
expect(packageJson).toContain('"create": "service create"');
|
|
197
197
|
expect(packageJson).toContain('"deploy": "service deploy"');
|
|
198
|
+
expect(packageJson).toContain('"protect-main": "service protect-main"');
|
|
198
199
|
expect(packageJson).toContain('"destroy": "service destroy"');
|
|
199
200
|
|
|
200
201
|
const mainGo = await Bun.file(join(generatedRoot, "cmd", "server", "main.go")).text();
|
|
@@ -224,6 +225,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
224
225
|
expect(makefile).toContain("bun run ./scripts/ensure-local-db.ts");
|
|
225
226
|
expect(makefile).toContain("bun run ./scripts/wait-for-db.ts");
|
|
226
227
|
expect(makefile).toContain("bun run ./scripts/dev.ts go run ./cmd/server --worker go run ./cmd/worker");
|
|
228
|
+
expect(makefile).toContain("protect-main:");
|
|
229
|
+
expect(makefile).toContain("$(SERVICE) protect-main");
|
|
227
230
|
expect(await Bun.file(join(generatedRoot, "atlas.hcl")).exists()).toBeTrue();
|
|
228
231
|
const atlasConfig = await Bun.file(join(generatedRoot, "atlas.hcl")).text();
|
|
229
232
|
expect(atlasConfig).toContain('revisions_schema = "public"');
|
|
@@ -241,6 +244,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
241
244
|
expect(packageJson).toContain('"migrate": "service migrate"');
|
|
242
245
|
expect(packageJson).toContain('"create": "service create"');
|
|
243
246
|
expect(packageJson).toContain('"deploy": "service deploy"');
|
|
247
|
+
expect(packageJson).toContain('"protect-main": "service protect-main"');
|
|
244
248
|
expect(packageJson).toContain('"dashboards": "service dashboards"');
|
|
245
249
|
expect(packageJson).toContain('"observability-bootstrap": "service observability-bootstrap"');
|
|
246
250
|
expect(packageJson).toContain('"auth": "service auth"');
|
|
@@ -260,6 +264,7 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
260
264
|
expect(makefile).toContain("SERVICE := service");
|
|
261
265
|
expect(makefile).toContain("dashboards:");
|
|
262
266
|
expect(makefile).toContain("observability-bootstrap:");
|
|
267
|
+
expect(makefile).toContain("protect-main:");
|
|
263
268
|
expect(makefile).toContain("auth:");
|
|
264
269
|
expect(makefile).toContain("bun run dev");
|
|
265
270
|
const devScript = await Bun.file(join(generatedRoot, "scripts", "dev.ts")).text();
|
|
@@ -295,6 +300,9 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
295
300
|
expect(readme).toContain("verifies JWT bearer tokens");
|
|
296
301
|
expect(readme).toContain("prod/apps/auth/authctl/cloudflare-access");
|
|
297
302
|
expect(readme).toContain("service auth resource-server");
|
|
303
|
+
expect(readme).toContain("GitHub main branch protection");
|
|
304
|
+
expect(readme).toContain("service create");
|
|
305
|
+
expect(readme).toContain("service protect-main");
|
|
298
306
|
}
|
|
299
307
|
|
|
300
308
|
}
|
|
@@ -329,6 +337,8 @@ test("scaffolds a backend package cleanly into a nested monorepo-style directory
|
|
|
329
337
|
expect(readme).toContain("waitlist/launch service");
|
|
330
338
|
expect(readme).not.toContain("Neon main, preview, and personal branch provisioning");
|
|
331
339
|
expect(readme).toContain("GitHub Actions deployment");
|
|
340
|
+
expect(readme).toContain("GitHub main branch protection");
|
|
341
|
+
expect(readme).toContain("service protect-main");
|
|
332
342
|
expect(readme).toContain(".github/workflows/preview.yml");
|
|
333
343
|
expect(readme).toContain(".github/workflows/deploy.yml");
|
|
334
344
|
expect(readme).toContain("/deploy preview");
|
|
@@ -367,6 +377,7 @@ test("scaffolds the workers target with wrangler lifecycle commands", async () =
|
|
|
367
377
|
expect(packageJson).toContain('"@anmho/authctl": "0.1.1"');
|
|
368
378
|
expect(packageJson).toContain('"dev": "bun run ./scripts/dev.ts wrangler dev --ip 127.0.0.1 --port 8787 --show-interactive-dev-session=false"');
|
|
369
379
|
expect(packageJson).toContain('"service": "service"');
|
|
380
|
+
expect(packageJson).toContain('"protect-main": "service protect-main"');
|
|
370
381
|
expect(packageJson).toContain('"auth": "service auth"');
|
|
371
382
|
expect(packageJson).toContain('"wrangler"');
|
|
372
383
|
expect(packageJson).toContain('"pg"');
|
|
@@ -413,6 +424,7 @@ test("scaffolds the workers target with wrangler lifecycle commands", async () =
|
|
|
413
424
|
const makefile = await Bun.file(join(generatedRoot, "Makefile")).text();
|
|
414
425
|
expect(makefile).toContain('no generated code for workers');
|
|
415
426
|
expect(makefile).toContain("auth:");
|
|
427
|
+
expect(makefile).toContain("protect-main:");
|
|
416
428
|
expect(makefile).not.toContain("scripts/codegen.ts");
|
|
417
429
|
|
|
418
430
|
expect(await Bun.file(join(generatedRoot, "scripts", "authctl.ts")).exists()).toBeFalse();
|
package/src/scaffold.ts
CHANGED
|
@@ -247,6 +247,7 @@ function buildReplacements(config: ScaffoldConfig) {
|
|
|
247
247
|
COMMAND_DEV_DOWN: "service dev down",
|
|
248
248
|
COMMAND_BOOTSTRAP: "service create",
|
|
249
249
|
COMMAND_DEPLOY: "service deploy",
|
|
250
|
+
COMMAND_PROTECT_MAIN: "service protect-main",
|
|
250
251
|
COMMAND_OBSERVABILITY_BOOTSTRAP:
|
|
251
252
|
config.runtime === "bun" ? "bun run observability-bootstrap" : "make observability-bootstrap",
|
|
252
253
|
WORKFLOW_DEPLOY_MAIN_COMMAND: "service deploy --ci",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { buildServiceDoctorReport, findServiceBinaries } from "./service-diagnostics";
|
|
3
|
+
|
|
4
|
+
test("findServiceBinaries finds service commands on a provided PATH", () => {
|
|
5
|
+
const binaries = findServiceBinaries({
|
|
6
|
+
pathEnv: ["/old/bin", "/fresh/bin", "/missing/bin"].join(":"),
|
|
7
|
+
isExecutable: (path) => path === "/old/bin/service" || path === "/fresh/bin/service",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
expect(binaries).toEqual(["/old/bin/service", "/fresh/bin/service"]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("buildServiceDoctorReport includes the active binary, package root, and version", () => {
|
|
14
|
+
const report = buildServiceDoctorReport({
|
|
15
|
+
activeBinaryPath: "/fresh/bin/service",
|
|
16
|
+
packageRoot: "/fresh/lib/node_modules/create-svc",
|
|
17
|
+
packageVersion: "0.1.77",
|
|
18
|
+
latestVersion: "0.1.77",
|
|
19
|
+
serviceBinaries: ["/fresh/bin/service"],
|
|
20
|
+
getBinaryVersion: () => "0.1.77",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(report.exitCode).toBe(0);
|
|
24
|
+
expect(report.text).toContain("active binary: /fresh/bin/service");
|
|
25
|
+
expect(report.text).toContain("package root: /fresh/lib/node_modules/create-svc");
|
|
26
|
+
expect(report.text).toContain("package version: 0.1.77");
|
|
27
|
+
expect(report.text).toContain("npm latest: 0.1.77");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("buildServiceDoctorReport warns when stale service binaries are also on PATH", () => {
|
|
31
|
+
const report = buildServiceDoctorReport({
|
|
32
|
+
activeBinaryPath: "/fresh/bin/service",
|
|
33
|
+
packageRoot: "/fresh/lib/node_modules/create-svc",
|
|
34
|
+
packageVersion: "0.1.77",
|
|
35
|
+
latestVersion: "0.1.77",
|
|
36
|
+
serviceBinaries: ["/opt/homebrew/bin/service", "/fresh/bin/service"],
|
|
37
|
+
getBinaryVersion: (path) => (path === "/opt/homebrew/bin/service" ? "0.1.10" : "0.1.77"),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(report.exitCode).toBe(1);
|
|
41
|
+
expect(report.text).toContain("warning: multiple service binaries found on PATH");
|
|
42
|
+
expect(report.text).toContain("/opt/homebrew/bin/service");
|
|
43
|
+
expect(report.text).toContain("version: 0.1.10 (stale)");
|
|
44
|
+
expect(report.text).toContain('cleanup: rm "/opt/homebrew/bin/service"');
|
|
45
|
+
expect(report.text).toContain("update: npm install -g create-svc@latest");
|
|
46
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { accessSync, constants, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
type FindServiceBinariesOptions = {
|
|
6
|
+
pathEnv?: string;
|
|
7
|
+
commandName?: string;
|
|
8
|
+
isExecutable?: (path: string) => boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type BuildServiceDoctorReportOptions = {
|
|
12
|
+
activeBinaryPath: string;
|
|
13
|
+
packageRoot: string;
|
|
14
|
+
packageVersion: string;
|
|
15
|
+
latestVersion?: string;
|
|
16
|
+
latestVersionError?: string;
|
|
17
|
+
serviceBinaries: string[];
|
|
18
|
+
getBinaryVersion: (path: string) => string | undefined;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type DoctorReport = {
|
|
22
|
+
exitCode: number;
|
|
23
|
+
text: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const PACKAGE_NAME = "create-svc";
|
|
27
|
+
const SERVICE_COMMAND = "service";
|
|
28
|
+
|
|
29
|
+
export function packageRootFromModuleUrl(moduleUrl: string) {
|
|
30
|
+
return resolve(dirname(fileURLToPath(moduleUrl)), "..");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readPackageVersion(packageRoot: string) {
|
|
34
|
+
const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { version?: string };
|
|
35
|
+
return packageJson.version ?? "unknown";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function findServiceBinaries(options: FindServiceBinariesOptions = {}) {
|
|
39
|
+
const commandName = options.commandName ?? SERVICE_COMMAND;
|
|
40
|
+
const isExecutable = options.isExecutable ?? defaultIsExecutable;
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
const results: string[] = [];
|
|
43
|
+
|
|
44
|
+
for (const entry of (options.pathEnv ?? process.env.PATH ?? "").split(":")) {
|
|
45
|
+
if (!entry) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const candidate = join(entry, commandName);
|
|
50
|
+
if (seen.has(candidate)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
seen.add(candidate);
|
|
54
|
+
|
|
55
|
+
if (isExecutable(candidate)) {
|
|
56
|
+
results.push(candidate);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildServiceDoctorReport(options: BuildServiceDoctorReportOptions): DoctorReport {
|
|
64
|
+
const lines = [
|
|
65
|
+
"service doctor",
|
|
66
|
+
`active binary: ${options.activeBinaryPath}`,
|
|
67
|
+
`package root: ${options.packageRoot}`,
|
|
68
|
+
`package version: ${options.packageVersion}`,
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
if (options.latestVersion) {
|
|
72
|
+
lines.push(`npm latest: ${options.latestVersion}`);
|
|
73
|
+
} else if (options.latestVersionError) {
|
|
74
|
+
lines.push(`npm latest: unavailable (${options.latestVersionError})`);
|
|
75
|
+
} else {
|
|
76
|
+
lines.push("npm latest: unavailable");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const binaries = uniquePaths(options.serviceBinaries);
|
|
80
|
+
const binaryDiagnostics = binaries.map((path) => {
|
|
81
|
+
const version = options.getBinaryVersion(path);
|
|
82
|
+
const stale = Boolean(version && options.latestVersion && compareVersions(version, options.latestVersion) < 0);
|
|
83
|
+
return { path, version, stale };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const staleBinaries = binaryDiagnostics.filter((binary) => binary.stale);
|
|
87
|
+
if (binaryDiagnostics.length > 1) {
|
|
88
|
+
lines.push("");
|
|
89
|
+
lines.push("warning: multiple service binaries found on PATH");
|
|
90
|
+
for (const binary of binaryDiagnostics) {
|
|
91
|
+
const version = binary.version ?? "unknown";
|
|
92
|
+
const state = binary.stale ? "stale" : binary.version && options.latestVersion ? "current" : "unknown";
|
|
93
|
+
lines.push(`- ${binary.path}`);
|
|
94
|
+
lines.push(` version: ${version} (${state})`);
|
|
95
|
+
if (binary.stale) {
|
|
96
|
+
lines.push(` cleanup: rm "${binary.path}"`);
|
|
97
|
+
lines.push(` update: npm install -g ${PACKAGE_NAME}@latest`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
exitCode: staleBinaries.length > 0 ? 1 : 0,
|
|
104
|
+
text: lines.join("\n"),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getInstalledServiceVersion(binaryPath: string) {
|
|
109
|
+
const result = safeSpawn([binaryPath, "--version"]);
|
|
110
|
+
|
|
111
|
+
if (!result || !result.success || !result.stdout) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return new TextDecoder().decode(result.stdout).trim().match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function getNpmLatestVersion() {
|
|
119
|
+
const result = safeSpawn(["npm", "view", `${PACKAGE_NAME}@latest`, "version"]);
|
|
120
|
+
|
|
121
|
+
if (!result || !result.success || !result.stdout) {
|
|
122
|
+
const stderr = result?.stderr ? new TextDecoder().decode(result.stderr).trim() : "npm view failed";
|
|
123
|
+
return { error: stderr || "npm view failed" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { version: new TextDecoder().decode(result.stdout).trim() };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function safeSpawn(command: string[]) {
|
|
130
|
+
try {
|
|
131
|
+
return Bun.spawnSync(command, {
|
|
132
|
+
stdout: "pipe",
|
|
133
|
+
stderr: "pipe",
|
|
134
|
+
});
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function compareVersions(left: string, right: string) {
|
|
141
|
+
const leftParts = parseVersion(left);
|
|
142
|
+
const rightParts = parseVersion(right);
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < 3; i += 1) {
|
|
145
|
+
const diff = (leftParts[i] ?? 0) - (rightParts[i] ?? 0);
|
|
146
|
+
if (diff !== 0) {
|
|
147
|
+
return diff;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseVersion(version: string): [number, number, number] {
|
|
155
|
+
const [major = "0", minor = "0", patch = "0"] = version.split(/[.-]/, 3);
|
|
156
|
+
return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function uniquePaths(paths: string[]) {
|
|
160
|
+
return [...new Set(paths)];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function defaultIsExecutable(path: string) {
|
|
164
|
+
try {
|
|
165
|
+
accessSync(path, constants.X_OK);
|
|
166
|
+
return true;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -31,7 +31,7 @@ describe("local dev cleanup", () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
test("stops service-owned listeners when the pid file is missing", async () => {
|
|
34
|
-
if (!Bun.which("lsof")) {
|
|
34
|
+
if (!Bun.which("lsof") && !(process.platform === "linux" && Bun.which("fuser"))) {
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -126,6 +126,10 @@ async function isReachable(port: number) {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
function listenerHasPid(port: number, pid: number) {
|
|
129
|
+
if (!Bun.which("lsof")) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
129
133
|
const result = Bun.spawnSync(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp"], {
|
|
130
134
|
stdout: "pipe",
|
|
131
135
|
stderr: "pipe",
|
|
@@ -144,10 +144,6 @@ function waitForExit(pid: number, timeoutMs: number) {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
function findServicePortProcesses(root: string, ports: number[], pidFromFile?: number): PortProcess[] {
|
|
147
|
-
if (!Bun.which("lsof")) {
|
|
148
|
-
return [];
|
|
149
|
-
}
|
|
150
|
-
|
|
151
147
|
const resolvedRoot = realpath(root);
|
|
152
148
|
const rootWithSlash = resolvedRoot.endsWith("/") ? resolvedRoot : `${resolvedRoot}/`;
|
|
153
149
|
const seen = new Set<string>();
|
|
@@ -180,6 +176,21 @@ function realpath(path: string) {
|
|
|
180
176
|
}
|
|
181
177
|
|
|
182
178
|
function listeningPids(port: number) {
|
|
179
|
+
const pids = new Set<number>();
|
|
180
|
+
for (const pid of listeningPidsFromLsof(port)) {
|
|
181
|
+
pids.add(pid);
|
|
182
|
+
}
|
|
183
|
+
for (const pid of listeningPidsFromFuser(port)) {
|
|
184
|
+
pids.add(pid);
|
|
185
|
+
}
|
|
186
|
+
return [...pids];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function listeningPidsFromLsof(port: number) {
|
|
190
|
+
if (!Bun.which("lsof")) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
|
|
183
194
|
const result = Bun.spawnSync(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp"], {
|
|
184
195
|
stdout: "pipe",
|
|
185
196
|
stderr: "pipe",
|
|
@@ -194,6 +205,26 @@ function listeningPids(port: number) {
|
|
|
194
205
|
.filter((pid): pid is number => Boolean(pid && Number.isFinite(pid)));
|
|
195
206
|
}
|
|
196
207
|
|
|
208
|
+
function listeningPidsFromFuser(port: number) {
|
|
209
|
+
if (process.platform !== "linux" || !Bun.which("fuser")) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const result = Bun.spawnSync(["fuser", "-n", "tcp", String(port)], {
|
|
214
|
+
stdout: "pipe",
|
|
215
|
+
stderr: "pipe",
|
|
216
|
+
});
|
|
217
|
+
if (!result.success) {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
return decoder
|
|
221
|
+
.decode(result.stdout)
|
|
222
|
+
.trim()
|
|
223
|
+
.split(/\s+/)
|
|
224
|
+
.map((value) => Number.parseInt(value, 10))
|
|
225
|
+
.filter((pid): pid is number => Boolean(pid && Number.isFinite(pid)));
|
|
226
|
+
}
|
|
227
|
+
|
|
197
228
|
function processCwd(pid: number) {
|
|
198
229
|
if (process.platform === "linux") {
|
|
199
230
|
const procCwd = realpath(`/proc/${pid}/cwd`);
|
package/src/service.test.ts
CHANGED
|
@@ -30,6 +30,7 @@ test("createSvcVersion reports the package version", async () => {
|
|
|
30
30
|
test("formatOutsideServiceCommandError rejects repo-local commands outside generated services", () => {
|
|
31
31
|
expect(formatOutsideServiceCommandError("destroy")).toContain("service destroy must be run inside a generated service repo");
|
|
32
32
|
expect(formatOutsideServiceCommandError("deploy")).toContain("No service.jsonc was found");
|
|
33
|
+
expect(formatOutsideServiceCommandError("protect-main")).toContain("service protect-main must be run inside a generated service repo");
|
|
33
34
|
});
|
|
34
35
|
|
|
35
36
|
test("formatOutsideServiceCommandError does not treat positional names as scaffold commands", () => {
|
|
@@ -66,3 +67,9 @@ test("generatedServiceCommandHelp intercepts deploy help before side effects", (
|
|
|
66
67
|
expect(generatedServiceCommandHelp(["deploy"])).toBeUndefined();
|
|
67
68
|
expect(generatedServiceCommandHelp(["destroy", "--help"])).toBeUndefined();
|
|
68
69
|
});
|
|
70
|
+
|
|
71
|
+
test("generatedServiceCommandHelp intercepts protect-main help before side effects", () => {
|
|
72
|
+
expect(generatedServiceCommandHelp(["protect-main", "--help"])).toContain("service protect-main");
|
|
73
|
+
expect(generatedServiceCommandHelp(["protect-main", "-h"])).toContain("--repo owner/name");
|
|
74
|
+
expect(generatedServiceCommandHelp(["protect-main"])).toBeUndefined();
|
|
75
|
+
});
|
package/src/service.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { formatScaffoldHelp, run as runScaffoldCli } from "./cli";
|
|
4
|
+
import { parseProtectMainArgs, protectMainBranch } from "./github-protection";
|
|
4
5
|
import { parseJsonc } from "./jsonc";
|
|
6
|
+
import {
|
|
7
|
+
buildServiceDoctorReport,
|
|
8
|
+
findServiceBinaries,
|
|
9
|
+
getInstalledServiceVersion,
|
|
10
|
+
getNpmLatestVersion,
|
|
11
|
+
packageRootFromModuleUrl,
|
|
12
|
+
} from "./service-diagnostics";
|
|
5
13
|
|
|
6
14
|
const SCAFFOLD_COMMANDS = new Set(["create", "new", "init"]);
|
|
7
15
|
const GENERATED_SERVICE_COMMANDS = new Set([
|
|
@@ -14,6 +22,7 @@ const GENERATED_SERVICE_COMMANDS = new Set([
|
|
|
14
22
|
"dns",
|
|
15
23
|
"doctor",
|
|
16
24
|
"migrate",
|
|
25
|
+
"protect-main",
|
|
17
26
|
"sdk",
|
|
18
27
|
"seed",
|
|
19
28
|
]);
|
|
@@ -41,6 +50,11 @@ export async function runServiceCommand(argv: string[], cwd = process.cwd()) {
|
|
|
41
50
|
return;
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
if (command === "doctor") {
|
|
54
|
+
runGlobalServiceDoctor();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
console.error(formatOutsideServiceCommandError(command));
|
|
45
59
|
process.exit(1);
|
|
46
60
|
}
|
|
@@ -54,6 +68,23 @@ export function createSvcVersion() {
|
|
|
54
68
|
return packageJson.version || "unknown";
|
|
55
69
|
}
|
|
56
70
|
|
|
71
|
+
function runGlobalServiceDoctor() {
|
|
72
|
+
const latest = getNpmLatestVersion();
|
|
73
|
+
const report = buildServiceDoctorReport({
|
|
74
|
+
activeBinaryPath: process.argv[1] || "service",
|
|
75
|
+
packageRoot: packageRootFromModuleUrl(import.meta.url),
|
|
76
|
+
packageVersion: createSvcVersion(),
|
|
77
|
+
latestVersion: latest.version,
|
|
78
|
+
latestVersionError: latest.error,
|
|
79
|
+
serviceBinaries: findServiceBinaries(),
|
|
80
|
+
getBinaryVersion: getInstalledServiceVersion,
|
|
81
|
+
});
|
|
82
|
+
console.log(report.text);
|
|
83
|
+
if (report.exitCode !== 0) {
|
|
84
|
+
process.exit(report.exitCode);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
57
88
|
export function normalizeScaffoldArgs(argv: string[]) {
|
|
58
89
|
const [command, ...rest] = argv;
|
|
59
90
|
if (command && SCAFFOLD_COMMANDS.has(command)) {
|
|
@@ -109,7 +140,25 @@ async function delegateToGeneratedService(serviceRoot: string, argv: string[]) {
|
|
|
109
140
|
process.chdir(serviceRoot);
|
|
110
141
|
process.env.CREATE_SVC_SERVICE_ROOT = serviceRoot;
|
|
111
142
|
|
|
112
|
-
const serviceConfig = parseJsonc(await Bun.file(join(serviceRoot, "service.jsonc")).text()) as {
|
|
143
|
+
const serviceConfig = parseJsonc(await Bun.file(join(serviceRoot, "service.jsonc")).text()) as {
|
|
144
|
+
service_id?: string;
|
|
145
|
+
target?: string;
|
|
146
|
+
git?: {
|
|
147
|
+
owner?: string;
|
|
148
|
+
repository?: string;
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
if (argv[0] === "protect-main") {
|
|
152
|
+
const protectionArgs = parseProtectMainArgs(argv.slice(1));
|
|
153
|
+
const result = protectMainBranch({
|
|
154
|
+
repo: protectionArgs.repo ?? repoFromServiceConfig(serviceConfig),
|
|
155
|
+
branch: protectionArgs.branch,
|
|
156
|
+
cwd: serviceRoot,
|
|
157
|
+
});
|
|
158
|
+
console.log(`Protected ${result.repo} ${result.branch} with required checks: ${result.requiredChecks.join(", ")}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
113
162
|
if (serviceConfig.target === "workers") {
|
|
114
163
|
const { main } = await import("./service-runtime/workers/cli");
|
|
115
164
|
await main(argv);
|
|
@@ -120,6 +169,15 @@ async function delegateToGeneratedService(serviceRoot: string, argv: string[]) {
|
|
|
120
169
|
await main(argv);
|
|
121
170
|
}
|
|
122
171
|
|
|
172
|
+
function repoFromServiceConfig(serviceConfig: { service_id?: string; git?: { owner?: string; repository?: string } }) {
|
|
173
|
+
const owner = serviceConfig.git?.owner || "anmho";
|
|
174
|
+
const repository = serviceConfig.git?.repository || serviceConfig.service_id;
|
|
175
|
+
if (!repository) {
|
|
176
|
+
throw new Error("service.jsonc is missing git.repository and service_id; pass --repo owner/name.");
|
|
177
|
+
}
|
|
178
|
+
return `${owner}/${repository}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
123
181
|
export function generatedDependenciesInstalled(serviceRoot: string) {
|
|
124
182
|
return !existsSync(join(serviceRoot, "package.json")) || existsSync(join(serviceRoot, "node_modules"));
|
|
125
183
|
}
|
|
@@ -146,6 +204,15 @@ function ensureGeneratedDependencies(serviceRoot: string) {
|
|
|
146
204
|
|
|
147
205
|
export function generatedServiceCommandHelp(argv: string[]) {
|
|
148
206
|
const [command, ...rest] = argv;
|
|
207
|
+
if (command === "protect-main" && hasHelpFlag(rest)) {
|
|
208
|
+
return [
|
|
209
|
+
"Usage:",
|
|
210
|
+
" service protect-main [--repo owner/name] [--branch main]",
|
|
211
|
+
"",
|
|
212
|
+
"Reconciles generated service branch protection with required test and deploy checks.",
|
|
213
|
+
].join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
149
216
|
if (command !== "deploy" || !hasHelpFlag(rest)) {
|
|
150
217
|
return undefined;
|
|
151
218
|
}
|
|
@@ -31,6 +31,7 @@ console to create and deploy.
|
|
|
31
31
|
{{COMMAND_TEST}}
|
|
32
32
|
{{COMMAND_BOOTSTRAP}}
|
|
33
33
|
{{COMMAND_DEPLOY}}
|
|
34
|
+
{{COMMAND_PROTECT_MAIN}}
|
|
34
35
|
{{COMMAND_DEV_DOWN}}
|
|
35
36
|
{{COMMAND_AUTH_RESOURCE}}
|
|
36
37
|
{{COMMAND_AUTH_CLIENT}}
|
|
@@ -145,6 +146,16 @@ gcloud auth application-default login
|
|
|
145
146
|
|
|
146
147
|
The generated backend scripts still use `gcloud` as the primary control plane even when ADC is present.
|
|
147
148
|
|
|
149
|
+
## GitHub main branch protection
|
|
150
|
+
|
|
151
|
+
Generated repositories include GitHub Actions workflows for CI and production deploys. `service create` reconciles `main` branch protection after creating and pushing the GitHub repository. To rerun reconciliation for an existing generated repo:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
{{COMMAND_PROTECT_MAIN}}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The protection requires the generated `test` and `deploy` status checks, pull requests, stale-review dismissal, conversation resolution, and admin enforcement. If GitHub permissions are missing, rerun the command with a token that has repo admin access or fine-grained `Administration: write`.
|
|
158
|
+
|
|
148
159
|
Go variants use Atlas for migrations:
|
|
149
160
|
|
|
150
161
|
```bash
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev migrate gen lint test create deploy dashboards auth destroy
|
|
1
|
+
.PHONY: dev migrate gen lint test create deploy protect-main dashboards auth destroy
|
|
2
2
|
|
|
3
3
|
SERVICE := service
|
|
4
4
|
|
|
@@ -23,6 +23,9 @@ create:
|
|
|
23
23
|
deploy:
|
|
24
24
|
$(SERVICE) deploy $(ARGS)
|
|
25
25
|
|
|
26
|
+
protect-main:
|
|
27
|
+
$(SERVICE) protect-main $(ARGS)
|
|
28
|
+
|
|
26
29
|
dashboards:
|
|
27
30
|
$(SERVICE) dashboards
|
|
28
31
|
|
|
@@ -22,6 +22,7 @@ bun run trigger -- --help
|
|
|
22
22
|
bun run trigger:dev
|
|
23
23
|
service create
|
|
24
24
|
service deploy
|
|
25
|
+
{{COMMAND_PROTECT_MAIN}}
|
|
25
26
|
bun run dashboards
|
|
26
27
|
bun run doctor
|
|
27
28
|
bun run destroy
|
|
@@ -70,6 +71,16 @@ Worker deploy and fail clearly if these values are missing.
|
|
|
70
71
|
GitHub Actions deploys require matching repository secrets:
|
|
71
72
|
`TRIGGER_PROJECT_REF`, `TRIGGER_ACCESS_TOKEN`, and `TRIGGER_SECRET_KEY`.
|
|
72
73
|
|
|
74
|
+
## GitHub main branch protection
|
|
75
|
+
|
|
76
|
+
Generated repositories include GitHub Actions workflows for CI and production deploys. `service create` reconciles `main` branch protection after creating and pushing the GitHub repository. To rerun reconciliation for an existing generated repo:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
{{COMMAND_PROTECT_MAIN}}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The protection requires the generated `test` and `deploy` status checks, pull requests, stale-review dismissal, conversation resolution, and admin enforcement. If GitHub permissions are missing, rerun the command with a token that has repo admin access or fine-grained `Administration: write`.
|
|
83
|
+
|
|
73
84
|
The Trigger.dev CLI is installed in this generated package as a dev dependency
|
|
74
85
|
from the `trigger.dev` npm package.
|
|
75
86
|
Use `bun run trigger -- <args>` for ad-hoc commands, `bun run trigger:dev` for
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev migrate gen lint test create deploy dashboards observability-bootstrap auth destroy
|
|
1
|
+
.PHONY: dev migrate gen lint test create deploy protect-main dashboards observability-bootstrap auth destroy
|
|
2
2
|
|
|
3
3
|
SERVICE := service
|
|
4
4
|
|
|
@@ -23,6 +23,9 @@ create:
|
|
|
23
23
|
deploy:
|
|
24
24
|
$(SERVICE) deploy $(ARGS)
|
|
25
25
|
|
|
26
|
+
protect-main:
|
|
27
|
+
$(SERVICE) protect-main $(ARGS)
|
|
28
|
+
|
|
26
29
|
dashboards:
|
|
27
30
|
$(SERVICE) dashboards
|
|
28
31
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev migrate gen lint test create deploy dashboards observability-bootstrap auth destroy
|
|
1
|
+
.PHONY: dev migrate gen lint test create deploy protect-main dashboards observability-bootstrap auth destroy
|
|
2
2
|
|
|
3
3
|
SERVICE := service
|
|
4
4
|
|
|
@@ -23,6 +23,9 @@ create:
|
|
|
23
23
|
deploy:
|
|
24
24
|
$(SERVICE) deploy $(ARGS)
|
|
25
25
|
|
|
26
|
+
protect-main:
|
|
27
|
+
$(SERVICE) protect-main $(ARGS)
|
|
28
|
+
|
|
26
29
|
dashboards:
|
|
27
30
|
$(SERVICE) dashboards
|
|
28
31
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev migrate migrate-lint gen lint test create deploy dashboards observability-bootstrap auth destroy
|
|
1
|
+
.PHONY: dev migrate migrate-lint gen lint test create deploy protect-main dashboards observability-bootstrap auth destroy
|
|
2
2
|
|
|
3
3
|
SERVICE := service
|
|
4
4
|
WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
|
|
@@ -33,6 +33,9 @@ create:
|
|
|
33
33
|
deploy:
|
|
34
34
|
$(SERVICE) deploy $(ARGS)
|
|
35
35
|
|
|
36
|
+
protect-main:
|
|
37
|
+
$(SERVICE) protect-main $(ARGS)
|
|
38
|
+
|
|
36
39
|
dashboards:
|
|
37
40
|
$(SERVICE) dashboards
|
|
38
41
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev migrate migrate-lint gen lint test create deploy dashboards observability-bootstrap auth destroy
|
|
1
|
+
.PHONY: dev migrate migrate-lint gen lint test create deploy protect-main dashboards observability-bootstrap auth destroy
|
|
2
2
|
|
|
3
3
|
SERVICE := service
|
|
4
4
|
WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
|
|
@@ -34,6 +34,9 @@ create:
|
|
|
34
34
|
deploy:
|
|
35
35
|
$(SERVICE) deploy $(ARGS)
|
|
36
36
|
|
|
37
|
+
protect-main:
|
|
38
|
+
$(SERVICE) protect-main $(ARGS)
|
|
39
|
+
|
|
37
40
|
dashboards:
|
|
38
41
|
$(SERVICE) dashboards
|
|
39
42
|
|