run402 1.54.3 → 1.55.0

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 (40) hide show
  1. package/README.md +28 -0
  2. package/cli.mjs +7 -0
  3. package/core-dist/allowance-auth.js +42 -22
  4. package/lib/argparse.mjs +41 -0
  5. package/lib/ci.mjs +395 -0
  6. package/lib/deploy-v2.mjs +152 -6
  7. package/lib/functions.mjs +3 -20
  8. package/lib/projects.mjs +5 -3
  9. package/lib/sdk.mjs +2 -2
  10. package/lib/secrets.mjs +2 -0
  11. package/lib/subdomains.mjs +20 -4
  12. package/package.json +1 -1
  13. package/sdk/core-dist/allowance-auth.js +42 -22
  14. package/sdk/dist/ci-credentials.d.ts +22 -0
  15. package/sdk/dist/ci-credentials.d.ts.map +1 -0
  16. package/sdk/dist/ci-credentials.js +103 -0
  17. package/sdk/dist/ci-credentials.js.map +1 -0
  18. package/sdk/dist/index.d.ts +6 -0
  19. package/sdk/dist/index.d.ts.map +1 -1
  20. package/sdk/dist/index.js +5 -0
  21. package/sdk/dist/index.js.map +1 -1
  22. package/sdk/dist/namespaces/ci.d.ts +21 -0
  23. package/sdk/dist/namespaces/ci.d.ts.map +1 -0
  24. package/sdk/dist/namespaces/ci.js +253 -0
  25. package/sdk/dist/namespaces/ci.js.map +1 -0
  26. package/sdk/dist/namespaces/ci.types.d.ts +91 -0
  27. package/sdk/dist/namespaces/ci.types.d.ts.map +1 -0
  28. package/sdk/dist/namespaces/ci.types.js +8 -0
  29. package/sdk/dist/namespaces/ci.types.js.map +1 -0
  30. package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
  31. package/sdk/dist/namespaces/deploy.js +45 -21
  32. package/sdk/dist/namespaces/deploy.js.map +1 -1
  33. package/sdk/dist/node/ci.d.ts +12 -0
  34. package/sdk/dist/node/ci.d.ts.map +1 -0
  35. package/sdk/dist/node/ci.js +30 -0
  36. package/sdk/dist/node/ci.js.map +1 -0
  37. package/sdk/dist/node/index.d.ts +7 -2
  38. package/sdk/dist/node/index.d.ts.map +1 -1
  39. package/sdk/dist/node/index.js +3 -2
  40. package/sdk/dist/node/index.js.map +1 -1
package/README.md CHANGED
@@ -62,6 +62,34 @@ run402 subdomains claim my-app # → my-app.run402.com (auto-reas
62
62
 
63
63
  `deploy-dir` hashes each file client-side and only uploads bytes the gateway doesn't already have. Re-deploying an unchanged tree returns immediately with `bytes_uploaded: 0`. Progress events stream to stderr.
64
64
 
65
+ ### GitHub Actions OIDC deploys
66
+
67
+ Link once from a local shell that has your Run402 allowance, then commit the generated workflow and manifest:
68
+
69
+ ```bash
70
+ run402 ci link github --project prj_... --manifest run402.deploy.json
71
+ run402 ci list --project prj_...
72
+ run402 ci revoke cib_...
73
+ ```
74
+
75
+ `link github` infers `owner/repo` and the current branch, verifies the numeric GitHub repository id, creates a deploy-scoped CI binding, and writes `.github/workflows/run402-deploy.yml` unless you pass `--workflow`. The generated workflow is intentionally just the existing deploy command with OIDC enabled:
76
+
77
+ ```yaml
78
+ permissions:
79
+ contents: read
80
+ id-token: write
81
+
82
+ jobs:
83
+ deploy:
84
+ runs-on: ubuntu-latest
85
+ steps:
86
+ - uses: actions/checkout@v4
87
+ - name: Deploy to run402
88
+ run: npx --yes run402@1.54.4 deploy apply --manifest 'run402.deploy.json' --project 'prj_...'
89
+ ```
90
+
91
+ CI deploys can ship `site`, `functions`, and `database` changes. Keep secrets, domains, subdomains, routes, checks, and non-current base changes in a local `run402 deploy apply` where the full allowance-backed authority is present.
92
+
65
93
  ### Storage (paste-and-go CDN assets)
66
94
 
67
95
  ```bash
package/cli.mjs CHANGED
@@ -26,6 +26,7 @@ Commands:
26
26
  tier Manage tier subscription (status, set)
27
27
  projects Manage projects (provision, list, query, inspect, delete)
28
28
  deploy Deploy a full-stack app or static site (requires active tier)
29
+ ci Link GitHub Actions OIDC deploy bindings
29
30
  functions Manage serverless functions (deploy, invoke, logs, list, delete)
30
31
  secrets Manage project secrets (set, list, delete)
31
32
  blob Direct-to-S3 blob storage (put, get, ls, rm, sign, diagnose) — up to 5 TiB
@@ -62,6 +63,7 @@ Getting started:
62
63
  run402 init mpp Set up with MPP (Tempo Moderato)
63
64
  run402 tier set prototype Subscribe to a tier
64
65
  run402 deploy --manifest app.json
66
+ run402 ci link github --project prj_... --manifest run402.deploy.json
65
67
  `;
66
68
 
67
69
  if (cmd === '--version' || cmd === '-v') {
@@ -122,6 +124,11 @@ switch (cmd) {
122
124
  await run([sub, ...rest].filter(Boolean));
123
125
  break;
124
126
  }
127
+ case "ci": {
128
+ const { run } = await import("./lib/ci.mjs");
129
+ await run(sub, rest);
130
+ break;
131
+ }
125
132
  case "functions": {
126
133
  const { run } = await import("./lib/functions.mjs");
127
134
  await run(sub, rest);
@@ -70,16 +70,44 @@ export function formatSIWEMessage(opts, address) {
70
70
  opts.statement,
71
71
  "",
72
72
  `URI: ${opts.uri}`,
73
- `Version: ${opts.version}`,
74
- `Chain ID: ${opts.chainId}`,
73
+ `Version: ${opts.version ?? "1"}`,
74
+ `Chain ID: ${messageChainId(opts.chainId)}`,
75
75
  `Nonce: ${opts.nonce}`,
76
76
  `Issued At: ${opts.issuedAt}`,
77
77
  ];
78
78
  if (opts.expirationTime) {
79
79
  lines.push(`Expiration Time: ${opts.expirationTime}`);
80
80
  }
81
+ if (opts.resources && opts.resources.length > 0) {
82
+ lines.push("Resources:");
83
+ for (const resource of opts.resources)
84
+ lines.push(`- ${resource}`);
85
+ }
81
86
  return lines.join("\n");
82
87
  }
88
+ export function buildSIWxAuthHeaders(opts) {
89
+ const message = formatSIWEMessage(opts, opts.allowance.address);
90
+ const signature = personalSign(opts.allowance.privateKey, opts.allowance.address, message);
91
+ const payload = {
92
+ domain: opts.domain,
93
+ address: toChecksumAddress(opts.allowance.address),
94
+ statement: opts.statement,
95
+ uri: opts.uri,
96
+ version: opts.version ?? "1",
97
+ chainId: payloadChainId(opts.chainId),
98
+ type: opts.type ?? "eip191",
99
+ nonce: opts.nonce,
100
+ issuedAt: opts.issuedAt,
101
+ expirationTime: opts.expirationTime,
102
+ signature,
103
+ };
104
+ if (opts.resources !== undefined) {
105
+ payload.resources = opts.resources;
106
+ }
107
+ return {
108
+ "SIGN-IN-WITH-X": Buffer.from(JSON.stringify(payload)).toString("base64"),
109
+ };
110
+ }
83
111
  /**
84
112
  * Get SIWX auth headers for the Run402 API.
85
113
  * Returns null if no allowance is configured.
@@ -103,32 +131,24 @@ export function getAllowanceAuthHeaders(path, allowancePath) {
103
131
  const now = new Date();
104
132
  const issuedAt = now.toISOString();
105
133
  const expirationTime = new Date(now.getTime() + 5 * 60 * 1000).toISOString();
106
- const message = formatSIWEMessage({
134
+ return buildSIWxAuthHeaders({
135
+ allowance,
107
136
  domain,
108
137
  uri,
109
138
  statement: "Sign in to Run402",
110
- version: "1",
111
- chainId: 84532, // Base Sepolia
112
- nonce,
113
- issuedAt,
114
- expirationTime,
115
- }, allowance.address);
116
- const signature = personalSign(allowance.privateKey, allowance.address, message);
117
- const payload = {
118
- domain,
119
- address: toChecksumAddress(allowance.address),
120
- statement: "Sign in to Run402",
121
- uri,
122
- version: "1",
123
139
  chainId: "eip155:84532",
124
- type: "eip191",
125
140
  nonce,
126
141
  issuedAt,
127
142
  expirationTime,
128
- signature,
129
- };
130
- return {
131
- "SIGN-IN-WITH-X": Buffer.from(JSON.stringify(payload)).toString("base64"),
132
- };
143
+ });
144
+ }
145
+ function messageChainId(chainId) {
146
+ if (typeof chainId === "number")
147
+ return String(chainId);
148
+ const match = /^eip155:(\d+)$/.exec(chainId);
149
+ return match ? match[1] : chainId;
150
+ }
151
+ function payloadChainId(chainId) {
152
+ return typeof chainId === "number" ? `eip155:${chainId}` : chainId;
133
153
  }
134
154
  //# sourceMappingURL=allowance-auth.js.map
package/lib/argparse.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync, statSync } from "node:fs";
1
2
  import { fail } from "./sdk-errors.mjs";
2
3
  import { resolveProjectId } from "./config.mjs";
3
4
 
@@ -143,6 +144,46 @@ export function validateWebhookUrl(url, fieldName = "--url") {
143
144
  }
144
145
  }
145
146
 
147
+ /**
148
+ * Validate that a CLI flag pointing at a filesystem path resolves to an
149
+ * existing regular file. Replaces the GH-195 inline pattern that was
150
+ * duplicated across `functions deploy`, `secrets set`, `projects sql`,
151
+ * and `projects apply-expose` (GH-233).
152
+ *
153
+ * Without this guard, `readFileSync` against a missing path leaks a raw
154
+ * `node:fs` ENOENT/EISDIR stack to stderr (with the V8 source pointer),
155
+ * which violates the CLI's structured-error contract.
156
+ *
157
+ * No-op: this helper is meant to be called only when the flag is set.
158
+ * Callers handle the optional/required dichotomy themselves.
159
+ *
160
+ * On failure: `fail()` writes a `FILE_NOT_FOUND` or `NOT_A_FILE` envelope
161
+ * to stderr and exits 1.
162
+ *
163
+ * @param {string} path - The filesystem path captured from the flag.
164
+ * @param {string} fieldName - The flag name for the envelope (default "--file").
165
+ */
166
+ export function validateRegularFile(path, fieldName = "--file") {
167
+ if (!existsSync(path)) {
168
+ fail({
169
+ code: "FILE_NOT_FOUND",
170
+ message: `File not found: ${path}`,
171
+ field: fieldName,
172
+ path,
173
+ hint: `Check that ${fieldName} points to an existing file.`,
174
+ });
175
+ }
176
+ const stat = statSync(path);
177
+ if (!stat.isFile()) {
178
+ fail({
179
+ code: "NOT_A_FILE",
180
+ message: `${fieldName} points to a ${stat.isDirectory() ? "directory" : "non-regular file"}: ${path}`,
181
+ field: fieldName,
182
+ path,
183
+ });
184
+ }
185
+ }
186
+
146
187
  export function positionalArgs(args = [], flagsWithValues = []) {
147
188
  const valueFlags = new Set(flagsWithValues);
148
189
  const out = [];
package/lib/ci.mjs ADDED
@@ -0,0 +1,395 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname, resolve } from "node:path";
5
+ import {
6
+ CI_GITHUB_ACTIONS_PROVIDER,
7
+ DEFAULT_CI_DELEGATION_CHAIN_ID,
8
+ V1_CI_ALLOWED_ACTIONS,
9
+ V1_CI_ALLOWED_EVENTS_DEFAULT,
10
+ signCiDelegation,
11
+ } from "#sdk/node";
12
+ import { API, resolveProjectId } from "./config.mjs";
13
+ import { getSdk } from "./sdk.mjs";
14
+ import { reportSdkError, fail } from "./sdk-errors.mjs";
15
+
16
+ const { version: RUN402_VERSION } = JSON.parse(
17
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
18
+ );
19
+
20
+ const HELP = `run402 ci — Manage CI/OIDC deploy bindings
21
+
22
+ Usage:
23
+ run402 ci link github [--project <id>] [--manifest <path>] [--repo <owner/repo>] [--branch <name> | --environment <name>] [--repository-id <id>] [--workflow <path>] [--expires-at <iso>] [--force]
24
+ run402 ci list [--project <id>]
25
+ run402 ci revoke <binding_id>
26
+
27
+ Subcommands:
28
+ link github Link this repo/branch or environment for GitHub Actions deploys
29
+ list List CI bindings for a project
30
+ revoke Revoke a CI binding
31
+ `;
32
+
33
+ const SUB_HELP = {
34
+ link: `run402 ci link github — Link GitHub Actions OIDC for deploy apply
35
+
36
+ Usage:
37
+ run402 ci link github [--project <id>] [--manifest <path>] [--repo <owner/repo>] [--branch <name> | --environment <name>] [--repository-id <id>] [--workflow <path>] [--expires-at <iso>] [--force]
38
+
39
+ Options:
40
+ --project <id> Project ID (defaults to the active project)
41
+ --manifest <path> Manifest path used by the generated workflow (default: run402.deploy.json)
42
+ --repo <owner/repo> GitHub repo (default: inferred from origin remote)
43
+ --branch <name> Branch subject and push trigger (default: current branch)
44
+ --environment <name> GitHub environment subject; adds job.environment
45
+ --repository-id <id> Numeric GitHub repository id when API lookup is unavailable
46
+ --workflow <path> Workflow path (default: .github/workflows/run402-deploy.yml)
47
+ --expires-at <iso> Optional binding expiration timestamp
48
+ --force Overwrite an existing workflow file
49
+
50
+ Notes:
51
+ - v1 allows only push and workflow_dispatch events.
52
+ - v1 does not expose raw subject, wildcard, or pull-request deploy flags.
53
+ `,
54
+ list: `run402 ci list — List CI bindings
55
+
56
+ Usage:
57
+ run402 ci list [--project <id>]
58
+ `,
59
+ revoke: `run402 ci revoke — Revoke a CI binding
60
+
61
+ Usage:
62
+ run402 ci revoke <binding_id>
63
+ `,
64
+ };
65
+
66
+ function parseFlags(args, allowed) {
67
+ const flags = {};
68
+ const positional = [];
69
+ for (let i = 0; i < args.length; i++) {
70
+ const arg = args[i];
71
+ if (!arg.startsWith("--")) {
72
+ positional.push(arg);
73
+ continue;
74
+ }
75
+ if (!allowed.has(arg)) {
76
+ fail({
77
+ code: "BAD_USAGE",
78
+ message: `Unsupported flag: ${arg}`,
79
+ hint: "Run: run402 ci link github --help",
80
+ details: { flag: arg },
81
+ });
82
+ }
83
+ if (arg === "--force") {
84
+ flags.force = true;
85
+ continue;
86
+ }
87
+ if (!args[i + 1] || args[i + 1].startsWith("--")) {
88
+ fail({
89
+ code: "BAD_USAGE",
90
+ message: `Missing value for ${arg}.`,
91
+ details: { flag: arg },
92
+ });
93
+ }
94
+ flags[arg.slice(2).replace(/-/g, "_")] = args[++i];
95
+ }
96
+ return { flags, positional };
97
+ }
98
+
99
+ function rejectHighRiskFlags(args) {
100
+ const blocked = [
101
+ "--subject",
102
+ "--subject-match",
103
+ "--wildcard",
104
+ "--allow-event",
105
+ "--event",
106
+ "--pull-request",
107
+ "--pr",
108
+ "--no-repository-id",
109
+ ];
110
+ const hit = args.find((arg) => blocked.includes(arg));
111
+ if (hit) {
112
+ fail({
113
+ code: "UNSUPPORTED_CI_FLAG",
114
+ message: `${hit} is intentionally not exposed by run402 ci link github v1.`,
115
+ hint: "Use --branch or --environment. PR deploys, raw subjects, wildcards, and soft repository-id binding are deferred.",
116
+ details: { flag: hit },
117
+ });
118
+ }
119
+ }
120
+
121
+ function git(args) {
122
+ try {
123
+ return execFileSync("git", args, {
124
+ cwd: process.cwd(),
125
+ encoding: "utf8",
126
+ stdio: ["ignore", "pipe", "ignore"],
127
+ }).trim();
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function inferRepo() {
134
+ const remote = git(["remote", "get-url", "origin"]);
135
+ if (!remote) return null;
136
+ return parseGithubRemote(remote);
137
+ }
138
+
139
+ function parseGithubRemote(remote) {
140
+ const cleaned = remote.trim().replace(/\.git$/, "");
141
+ const ssh = cleaned.match(/^git@github\.com:([^/]+)\/(.+)$/);
142
+ if (ssh) return `${ssh[1]}/${ssh[2]}`;
143
+ const https = cleaned.match(/^https:\/\/github\.com\/([^/]+)\/(.+)$/);
144
+ if (https) return `${https[1]}/${https[2]}`;
145
+ const sshUrl = cleaned.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+)$/);
146
+ if (sshUrl) return `${sshUrl[1]}/${sshUrl[2]}`;
147
+ return null;
148
+ }
149
+
150
+ function inferBranch() {
151
+ return git(["branch", "--show-current"]);
152
+ }
153
+
154
+ async function fetchGithubRepositoryId(repo) {
155
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
156
+ const headers = {
157
+ Accept: "application/vnd.github+json",
158
+ "User-Agent": "run402-cli",
159
+ };
160
+ if (token) headers.Authorization = `Bearer ${token}`;
161
+ let res;
162
+ try {
163
+ res = await fetch(`https://api.github.com/repos/${repo}`, { headers });
164
+ } catch {
165
+ return null;
166
+ }
167
+ const body = await res.json().catch(() => null);
168
+ if (!res.ok || typeof body?.id !== "number") return null;
169
+ return String(body.id);
170
+ }
171
+
172
+ function buildSubject(repo, { branch, environment }) {
173
+ if (environment) return `repo:${repo}:environment:${environment}`;
174
+ if (!branch) {
175
+ fail({
176
+ code: "CI_SUBJECT_REQUIRED",
177
+ message: "Could not infer a branch for the GitHub Actions subject.",
178
+ hint: "Pass --branch <name> or --environment <name>.",
179
+ });
180
+ }
181
+ return `repo:${repo}:ref:refs/heads/${branch}`;
182
+ }
183
+
184
+ function shellQuote(value) {
185
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
186
+ }
187
+
188
+ function yamlString(value) {
189
+ return JSON.stringify(String(value));
190
+ }
191
+
192
+ function generateWorkflow({ branch, environment, manifest, projectId }) {
193
+ const pushBlock = branch
194
+ ? ` push:\n branches: [${yamlString(branch)}]\n`
195
+ : ` push:\n`;
196
+ const environmentLine = environment ? ` environment: ${yamlString(environment)}\n` : "";
197
+ return `name: Run402 Deploy
198
+
199
+ on:
200
+ ${pushBlock} workflow_dispatch:
201
+
202
+ permissions:
203
+ contents: read
204
+ id-token: write
205
+
206
+ jobs:
207
+ deploy:
208
+ runs-on: ubuntu-latest
209
+ ${environmentLine} steps:
210
+ - uses: actions/checkout@v4
211
+ - name: Deploy to run402
212
+ run: npx --yes run402@${RUN402_VERSION} deploy apply --manifest ${shellQuote(manifest)} --project ${shellQuote(projectId)}
213
+ `;
214
+ }
215
+
216
+ async function linkGithub(args) {
217
+ rejectHighRiskFlags(args);
218
+ const { flags, positional } = parseFlags(args, new Set([
219
+ "--project",
220
+ "--manifest",
221
+ "--repo",
222
+ "--branch",
223
+ "--environment",
224
+ "--repository-id",
225
+ "--workflow",
226
+ "--expires-at",
227
+ "--force",
228
+ ]));
229
+ if (positional[0] !== "github" || positional.length > 1) {
230
+ fail({
231
+ code: "BAD_USAGE",
232
+ message: "Missing provider: github.",
233
+ hint: "run402 ci link github [--project <id>]",
234
+ });
235
+ }
236
+ if (flags.branch && flags.environment) {
237
+ fail({
238
+ code: "BAD_USAGE",
239
+ message: "Choose either --branch or --environment, not both.",
240
+ });
241
+ }
242
+
243
+ const projectId = resolveProjectId(flags.project);
244
+ const repo = flags.repo || inferRepo();
245
+ if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) {
246
+ fail({
247
+ code: "GITHUB_REPO_REQUIRED",
248
+ message: "Could not infer GitHub owner/repo from git remote origin.",
249
+ hint: "Pass --repo <owner/repo>.",
250
+ });
251
+ }
252
+ const branch = flags.environment ? (flags.branch || inferBranch() || null) : (flags.branch || inferBranch());
253
+ const subject = buildSubject(repo, { branch, environment: flags.environment });
254
+ const repositoryId = flags.repository_id || await fetchGithubRepositoryId(repo);
255
+ if (!repositoryId) {
256
+ fail({
257
+ code: "GITHUB_REPOSITORY_ID_REQUIRED",
258
+ message: `Could not fetch the numeric GitHub repository id for ${repo}.`,
259
+ hint: "Set GITHUB_TOKEN/GH_TOKEN or pass --repository-id <id>.",
260
+ details: { repo },
261
+ });
262
+ }
263
+
264
+ const workflowPath = flags.workflow || ".github/workflows/run402-deploy.yml";
265
+ const absWorkflowPath = resolve(process.cwd(), workflowPath);
266
+ if (existsSync(absWorkflowPath) && !flags.force) {
267
+ fail({
268
+ code: "WORKFLOW_EXISTS",
269
+ message: `Workflow already exists: ${workflowPath}`,
270
+ hint: "Pass --force to overwrite it.",
271
+ details: { workflow_path: workflowPath },
272
+ });
273
+ }
274
+
275
+ const manifest = flags.manifest || "run402.deploy.json";
276
+ const nonce = randomBytes(16).toString("hex");
277
+ const values = {
278
+ project_id: projectId,
279
+ subject_match: subject,
280
+ allowed_actions: V1_CI_ALLOWED_ACTIONS,
281
+ allowed_events: V1_CI_ALLOWED_EVENTS_DEFAULT,
282
+ github_repository_id: repositoryId,
283
+ expires_at: flags.expires_at || null,
284
+ nonce,
285
+ };
286
+
287
+ let signedDelegation;
288
+ try {
289
+ signedDelegation = signCiDelegation(values, { apiBase: API });
290
+ } catch (err) {
291
+ fail({
292
+ code: "NO_ALLOWANCE",
293
+ message: err?.message || "No local allowance configured.",
294
+ hint: "Run: run402 init",
295
+ });
296
+ }
297
+
298
+ const workflow = generateWorkflow({
299
+ branch,
300
+ environment: flags.environment || null,
301
+ manifest,
302
+ projectId,
303
+ });
304
+
305
+ try {
306
+ const binding = await getSdk({ disablePaidFetch: true }).ci.createBinding({
307
+ ...values,
308
+ provider: CI_GITHUB_ACTIONS_PROVIDER,
309
+ signed_delegation: signedDelegation,
310
+ });
311
+ mkdirSync(dirname(absWorkflowPath), { recursive: true });
312
+ writeFileSync(absWorkflowPath, workflow, { encoding: "utf8", mode: 0o644 });
313
+ console.log(JSON.stringify({
314
+ status: "ok",
315
+ binding_id: binding.id,
316
+ project_id: projectId,
317
+ provider: CI_GITHUB_ACTIONS_PROVIDER,
318
+ subject_match: subject,
319
+ allowed_events: [...V1_CI_ALLOWED_EVENTS_DEFAULT],
320
+ allowed_actions: [...V1_CI_ALLOWED_ACTIONS],
321
+ github_repository_id: repositoryId,
322
+ github_repository_id_status: flags.repository_id ? "provided" : "verified",
323
+ workflow_path: workflowPath,
324
+ manifest_path: manifest,
325
+ run402_version: RUN402_VERSION,
326
+ delegation_chain_id: DEFAULT_CI_DELEGATION_CHAIN_ID,
327
+ bootstrap_caveat: "Commit the generated workflow and manifest before expecting GitHub Actions deploys.",
328
+ consent_summary: [
329
+ "This binding lets matching GitHub Actions workflows deploy site, function, and database changes to the project.",
330
+ "It does not allow direct secrets, domains, subdomains, lifecycle, billing, contracts, or faucet API calls.",
331
+ ],
332
+ revocation_residuals: [
333
+ "Revocation stops future CI gateway requests.",
334
+ "Revocation does not undo already-deployed code, stop in-flight deploy operations, rotate exfiltrated keys, or remove deployed functions.",
335
+ ],
336
+ }, null, 2));
337
+ } catch (err) {
338
+ reportSdkError(err);
339
+ }
340
+ }
341
+
342
+ async function list(args) {
343
+ const { flags } = parseFlags(args, new Set(["--project"]));
344
+ const project = resolveProjectId(flags.project);
345
+ try {
346
+ const result = await getSdk({ disablePaidFetch: true }).ci.listBindings({ project });
347
+ console.log(JSON.stringify({ status: "ok", project_id: project, ...result }, null, 2));
348
+ } catch (err) {
349
+ reportSdkError(err);
350
+ }
351
+ }
352
+
353
+ async function revoke(args) {
354
+ const bindingId = args.find((arg) => !arg.startsWith("--"));
355
+ if (!bindingId) {
356
+ fail({
357
+ code: "BAD_USAGE",
358
+ message: "Missing <binding_id>.",
359
+ hint: "run402 ci revoke <binding_id>",
360
+ });
361
+ }
362
+ try {
363
+ const binding = await getSdk({ disablePaidFetch: true }).ci.revokeBinding(bindingId);
364
+ console.log(JSON.stringify({
365
+ status: "ok",
366
+ binding,
367
+ revocation_residuals: [
368
+ "Revocation stops future CI gateway requests.",
369
+ "Revocation does not undo already-deployed code, stop in-flight deploy operations, rotate exfiltrated keys, or remove deployed functions.",
370
+ ],
371
+ }, null, 2));
372
+ } catch (err) {
373
+ reportSdkError(err);
374
+ }
375
+ }
376
+
377
+ export async function run(sub, args) {
378
+ if (!sub || sub === "--help" || sub === "-h") {
379
+ console.log(HELP);
380
+ process.exit(0);
381
+ }
382
+ if (Array.isArray(args) && (args.includes("--help") || args.includes("-h"))) {
383
+ console.log(SUB_HELP[sub] || HELP);
384
+ process.exit(0);
385
+ }
386
+ switch (sub) {
387
+ case "link": await linkGithub(args); break;
388
+ case "list": await list(args); break;
389
+ case "revoke": await revoke(args); break;
390
+ default:
391
+ console.error(`Unknown subcommand: ${sub}\n`);
392
+ console.log(HELP);
393
+ process.exit(1);
394
+ }
395
+ }