shipsafe-routeforge 0.1.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.
- package/bin/routeforge.mjs +3 -0
- package/package.json +25 -0
- package/src/cli.mjs +30 -0
- package/src/commands/init.mjs +85 -0
- package/src/commands/status.mjs +30 -0
- package/src/oauth.mjs +63 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shipsafe-routeforge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "RouteForge CLI \u2014 init and manage your GitLab AI safety gate",
|
|
5
|
+
"bin": {
|
|
6
|
+
"routeforge": "./bin/routeforge.mjs"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"test": "vitest"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"commander": "^12.1.0",
|
|
15
|
+
"ora": "^8.0.1",
|
|
16
|
+
"chalk": "^5.3.0",
|
|
17
|
+
"open": "^10.1.0",
|
|
18
|
+
"node-fetch": "^3.3.2"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^20.14.0",
|
|
22
|
+
"typescript": "^5.4.5",
|
|
23
|
+
"vitest": "^1.6.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { initCommand } from "./commands/init.mjs";
|
|
5
|
+
import { statusCommand } from "./commands/status.mjs";
|
|
6
|
+
|
|
7
|
+
export function createCLI() {
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name("routeforge")
|
|
12
|
+
.description(chalk.hex("#F97316")("RouteForge") + " — AI safety gate for GitLab MRs")
|
|
13
|
+
.version("0.1.0");
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.command("init")
|
|
17
|
+
.description("Initialize RouteForge: OAuth flow, store secrets, deploy to Cloud Run")
|
|
18
|
+
.option("--project <id>", "GCP project ID")
|
|
19
|
+
.option("--gitlab-project <id>", "GitLab project ID", "82762386")
|
|
20
|
+
.option("--client-id <id>", "GitLab OAuth Application ID (from gitlab.com/-/profile/applications)")
|
|
21
|
+
.action(initCommand);
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command("status")
|
|
25
|
+
.description("Show recent verdicts from the RouteForge agent")
|
|
26
|
+
.option("--limit <n>", "Number of verdicts to show", "10")
|
|
27
|
+
.action(statusCommand);
|
|
28
|
+
|
|
29
|
+
return program;
|
|
30
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import open from "open";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* npx routeforge init
|
|
7
|
+
*
|
|
8
|
+
* 1. Start local callback server on :9876
|
|
9
|
+
* 2. Open GitLab OAuth URL in browser
|
|
10
|
+
* 3. Capture token from callback
|
|
11
|
+
* 4. Store token in GCP Secret Manager via gcloud CLI
|
|
12
|
+
* 5. Print next steps (deploy webhook URL to GitLab)
|
|
13
|
+
*/
|
|
14
|
+
export async function initCommand(options) {
|
|
15
|
+
const gcpProject = options.project;
|
|
16
|
+
const gitlabProject = options.gitlabProject;
|
|
17
|
+
const clientId = options.clientId;
|
|
18
|
+
|
|
19
|
+
if (!gcpProject) {
|
|
20
|
+
console.error(chalk.red("Error: --project <gcp-project-id> is required"));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!clientId) {
|
|
25
|
+
console.error(chalk.red("Error: --client-id <gitlab-oauth-app-id> is required"));
|
|
26
|
+
console.error(chalk.yellow("Create one at: https://gitlab.com/-/profile/applications"));
|
|
27
|
+
console.error(chalk.yellow(" Redirect URI: http://localhost:9876/callback"));
|
|
28
|
+
console.error(chalk.yellow(" Scopes: read_api read_repository"));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(chalk.hex("#F97316").bold("\nRouteForge init\n"));
|
|
33
|
+
|
|
34
|
+
const spinner = ora("Starting OAuth callback server on :9876...").start();
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// Dynamic import to avoid top-level await issues
|
|
38
|
+
const { startOAuthFlow } = await import("../oauth.mjs");
|
|
39
|
+
spinner.succeed("Callback server ready");
|
|
40
|
+
|
|
41
|
+
spinner.start("Opening GitLab OAuth consent page...");
|
|
42
|
+
const authUrl = buildAuthUrl(gitlabProject, clientId);
|
|
43
|
+
await open(authUrl);
|
|
44
|
+
spinner.succeed("Browser opened — complete the GitLab OAuth flow");
|
|
45
|
+
|
|
46
|
+
spinner.start("Waiting for OAuth callback...");
|
|
47
|
+
const token = await startOAuthFlow(clientId);
|
|
48
|
+
spinner.succeed("OAuth token received");
|
|
49
|
+
|
|
50
|
+
spinner.start("Storing token in GCP Secret Manager...");
|
|
51
|
+
await storeSecret(gcpProject, "GITLAB_MCP_OAUTH_TOKEN", token);
|
|
52
|
+
spinner.succeed("Secret stored: GITLAB_MCP_OAUTH_TOKEN");
|
|
53
|
+
|
|
54
|
+
console.log(chalk.green("\n✓ RouteForge initialized successfully!\n"));
|
|
55
|
+
console.log("Next steps:");
|
|
56
|
+
console.log(
|
|
57
|
+
" 1. Deploy to Cloud Run: " +
|
|
58
|
+
chalk.cyan(`gcloud run deploy routeforge --project ${gcpProject}`)
|
|
59
|
+
);
|
|
60
|
+
console.log(
|
|
61
|
+
" 2. Add webhook in GitLab: Settings → Webhooks → <Cloud Run URL>/webhooks/gitlab"
|
|
62
|
+
);
|
|
63
|
+
console.log(" 3. Set X-Gitlab-Token to the value of GITLAB_WEBHOOK_SECRET in Secret Manager\n");
|
|
64
|
+
} catch (err) {
|
|
65
|
+
spinner.fail(`Init failed: ${err.message}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildAuthUrl(gitlabProjectId, clientId) {
|
|
71
|
+
const params = new URLSearchParams({
|
|
72
|
+
client_id: clientId,
|
|
73
|
+
redirect_uri: "http://localhost:9876/callback",
|
|
74
|
+
response_type: "code",
|
|
75
|
+
scope: "mcp api read_api read_repository",
|
|
76
|
+
state: "routeforge-init",
|
|
77
|
+
});
|
|
78
|
+
return `https://gitlab.com/oauth/authorize?${params}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function storeSecret(gcpProject, secretId, value) {
|
|
82
|
+
const { execSync } = await import("child_process");
|
|
83
|
+
const cmd = `printf '%s' '${value}' | gcloud secrets versions add ${secretId} --data-file=- --project=${gcpProject}`;
|
|
84
|
+
execSync(cmd, { stdio: "pipe" });
|
|
85
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export async function statusCommand(options) {
|
|
4
|
+
const limit = parseInt(options.limit, 10);
|
|
5
|
+
const baseUrl = process.env.ROUTEFORGE_URL || "http://localhost:8080";
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const resp = await fetch(`${baseUrl}/verdicts?limit=${limit}`);
|
|
9
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
10
|
+
const verdicts = await resp.json();
|
|
11
|
+
|
|
12
|
+
console.log(chalk.hex("#F97316").bold("\nRouteForge — Recent Verdicts\n"));
|
|
13
|
+
|
|
14
|
+
if (!verdicts.length) {
|
|
15
|
+
console.log(chalk.gray("No verdicts yet."));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const v of verdicts) {
|
|
20
|
+
const icon = v.verdict === "BLOCK" ? chalk.red("🚫 BLOCK") : chalk.green("✅ PASS");
|
|
21
|
+
const conf = chalk.gray(`(${Math.round(v.confidence * 100)}%)`);
|
|
22
|
+
console.log(` MR !${v.mr_iid} ${icon} ${conf} ${v.mr_title}`);
|
|
23
|
+
}
|
|
24
|
+
console.log();
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
27
|
+
console.error(chalk.gray("Set ROUTEFORGE_URL env var to your Cloud Run service URL"));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/oauth.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spin up a localhost:9876 server, wait for GitLab OAuth callback,
|
|
5
|
+
* exchange code for token, return token string.
|
|
6
|
+
*/
|
|
7
|
+
export function startOAuthFlow(clientId) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const server = http.createServer(async (req, res) => {
|
|
10
|
+
const url = new URL(req.url, "http://localhost:9876");
|
|
11
|
+
if (url.pathname !== "/callback") {
|
|
12
|
+
res.end("Not found");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const code = url.searchParams.get("code");
|
|
17
|
+
if (!code) {
|
|
18
|
+
res.end("Missing code");
|
|
19
|
+
reject(new Error("No OAuth code in callback"));
|
|
20
|
+
server.close();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const token = await exchangeCode(code, clientId);
|
|
26
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
27
|
+
res.end("<h2>RouteForge authorized ✓ — you can close this tab.</h2>");
|
|
28
|
+
resolve(token);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
res.writeHead(500);
|
|
31
|
+
res.end("Token exchange failed");
|
|
32
|
+
reject(err);
|
|
33
|
+
} finally {
|
|
34
|
+
server.close();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
server.listen(9876, "127.0.0.1", () => {});
|
|
39
|
+
server.on("error", reject);
|
|
40
|
+
|
|
41
|
+
// Timeout after 5 minutes
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
server.close();
|
|
44
|
+
reject(new Error("OAuth flow timed out after 5 minutes"));
|
|
45
|
+
}, 5 * 60 * 1000);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function exchangeCode(code, clientId) {
|
|
50
|
+
const resp = await fetch("https://gitlab.com/oauth/token", {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "Content-Type": "application/json" },
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
client_id: clientId,
|
|
55
|
+
code,
|
|
56
|
+
grant_type: "authorization_code",
|
|
57
|
+
redirect_uri: "http://localhost:9876/callback",
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
if (!resp.ok) throw new Error(`Token exchange failed: HTTP ${resp.status}`);
|
|
61
|
+
const data = await resp.json();
|
|
62
|
+
return data.access_token;
|
|
63
|
+
}
|