duoops 0.1.9 → 0.2.1

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 (55) hide show
  1. package/README.md +151 -63
  2. package/data/aws_machine_power_profiles.json +54 -0
  3. package/data/cpu_physical_specs.json +105 -0
  4. package/data/cpu_power_profiles.json +275 -0
  5. package/data/gcp_machine_power_profiles.json +1802 -0
  6. package/data/runtime-pue-mappings.json +183 -0
  7. package/dist/commands/autofix-ci.d.ts +13 -0
  8. package/dist/commands/autofix-ci.js +114 -0
  9. package/dist/commands/autofix.d.ts +5 -0
  10. package/dist/commands/autofix.js +11 -0
  11. package/dist/commands/init.js +104 -33
  12. package/dist/commands/mcp/deploy.d.ts +13 -0
  13. package/dist/commands/mcp/deploy.js +139 -0
  14. package/dist/commands/measure/calculate.js +2 -2
  15. package/dist/commands/portal.js +421 -6
  16. package/dist/lib/ai/agent.js +1 -0
  17. package/dist/lib/ai/tools/editing.js +28 -13
  18. package/dist/lib/ai/tools/gitlab.js +8 -4
  19. package/dist/lib/config.d.ts +10 -0
  20. package/dist/lib/gcloud.d.ts +7 -0
  21. package/dist/lib/gcloud.js +105 -0
  22. package/dist/lib/gitlab/pipelines-service.d.ts +23 -0
  23. package/dist/lib/gitlab/pipelines-service.js +146 -0
  24. package/dist/lib/gitlab/runner-service.d.ts +11 -0
  25. package/dist/lib/gitlab/runner-service.js +15 -0
  26. package/dist/lib/portal/settings.d.ts +3 -0
  27. package/dist/lib/portal/settings.js +48 -0
  28. package/dist/lib/scaffold.d.ts +5 -0
  29. package/dist/lib/scaffold.js +32 -0
  30. package/dist/portal/assets/HomeDashboard-DlkwSyKx.js +1 -0
  31. package/dist/portal/assets/JobDetailsDrawer-7kXXMSH8.js +1 -0
  32. package/dist/portal/assets/JobsDashboard-D4pNc9TM.js +1 -0
  33. package/dist/portal/assets/MetricsDashboard-BcgzvzBz.js +1 -0
  34. package/dist/portal/assets/PipelinesDashboard-BNrSM9GB.js +1 -0
  35. package/dist/portal/assets/allPaths-CXDKahbk.js +1 -0
  36. package/dist/portal/assets/allPathsLoader-BF5PAx2c.js +2 -0
  37. package/dist/portal/assets/cache-YerT0Slh.js +6 -0
  38. package/dist/portal/assets/core-Cz8f3oSB.js +19 -0
  39. package/dist/portal/assets/{index-C54ZhVUo.js → index-B9sNUqEC.js} +1 -1
  40. package/dist/portal/assets/index-BWa_E8Y7.css +1 -0
  41. package/dist/portal/assets/index-Bp4RqK05.js +1 -0
  42. package/dist/portal/assets/index-DW6Qp0d6.js +64 -0
  43. package/dist/portal/assets/index-Uc4Xhv31.js +1 -0
  44. package/dist/portal/assets/progressBar-C4SmnGeZ.js +1 -0
  45. package/dist/portal/assets/splitPathsBySizeLoader-C-T9_API.js +1 -0
  46. package/dist/portal/index.html +2 -2
  47. package/oclif.manifest.json +147 -2
  48. package/package.json +2 -1
  49. package/templates/.gitlab/duo/flows/duoops.yaml +114 -0
  50. package/templates/agents/agent.yml +45 -0
  51. package/templates/duoops-autofix-component.yml +52 -0
  52. package/templates/flows/flow.yml +283 -0
  53. package/dist/portal/assets/MetricsDashboard-Bnj-jtu6.js +0 -27
  54. package/dist/portal/assets/index-B1SGDQNX.css +0 -1
  55. package/dist/portal/assets/index-Bk8OVV7a.js +0 -106
@@ -0,0 +1,183 @@
1
+ {
2
+ "metadata": {
3
+ "generatedAt": "2025-06-14T16:09:07.455Z",
4
+ "methodology": "Real-time Electricity Maps API with industry standard PUE",
5
+ "availableZones": [
6
+ "US-CENT-SWPP"
7
+ ]
8
+ },
9
+ "regions": {
10
+ "us-central1": {
11
+ "zone": "US-CENT-SWPP",
12
+ "industryStandardPUE": 1.1,
13
+ "realTimeCarbonIntensity": 492,
14
+ "googleOfficialIntensity": 430,
15
+ "cfe": 0.95
16
+ },
17
+ "us-east1": {
18
+ "zone": "FALLBACK",
19
+ "industryStandardPUE": 1.1,
20
+ "error": "No matching zone found in available zones"
21
+ },
22
+ "us-east4": {
23
+ "zone": "FALLBACK",
24
+ "industryStandardPUE": 1.1,
25
+ "error": "No matching zone found in available zones"
26
+ },
27
+ "us-west1": {
28
+ "zone": "FALLBACK",
29
+ "industryStandardPUE": 1.1,
30
+ "error": "No matching zone found in available zones"
31
+ },
32
+ "us-west2": {
33
+ "zone": "FALLBACK",
34
+ "industryStandardPUE": 1.1,
35
+ "error": "No matching zone found in available zones"
36
+ },
37
+ "us-west3": {
38
+ "zone": "FALLBACK",
39
+ "industryStandardPUE": 1.1,
40
+ "error": "No matching zone found in available zones"
41
+ },
42
+ "us-west4": {
43
+ "zone": "FALLBACK",
44
+ "industryStandardPUE": 1.1,
45
+ "error": "No matching zone found in available zones"
46
+ },
47
+ "us-south1": {
48
+ "zone": "FALLBACK",
49
+ "industryStandardPUE": 1.1,
50
+ "error": "No matching zone found in available zones"
51
+ },
52
+ "europe-west1": {
53
+ "zone": "FALLBACK",
54
+ "industryStandardPUE": 1.1,
55
+ "error": "No matching zone found in available zones"
56
+ },
57
+ "europe-west2": {
58
+ "zone": "FALLBACK",
59
+ "industryStandardPUE": 1.1,
60
+ "error": "No matching zone found in available zones"
61
+ },
62
+ "europe-west3": {
63
+ "zone": "FALLBACK",
64
+ "industryStandardPUE": 1.1,
65
+ "error": "No matching zone found in available zones"
66
+ },
67
+ "europe-west4": {
68
+ "zone": "FALLBACK",
69
+ "industryStandardPUE": 1.1,
70
+ "error": "No matching zone found in available zones"
71
+ },
72
+ "europe-west6": {
73
+ "zone": "FALLBACK",
74
+ "industryStandardPUE": 1.1,
75
+ "error": "No matching zone found in available zones"
76
+ },
77
+ "europe-west8": {
78
+ "zone": "FALLBACK",
79
+ "industryStandardPUE": 1.1,
80
+ "error": "No matching zone found in available zones"
81
+ },
82
+ "europe-west9": {
83
+ "zone": "FALLBACK",
84
+ "industryStandardPUE": 1.1,
85
+ "error": "No matching zone found in available zones"
86
+ },
87
+ "europe-west10": {
88
+ "zone": "FALLBACK",
89
+ "industryStandardPUE": 1.1,
90
+ "error": "No matching zone found in available zones"
91
+ },
92
+ "europe-west12": {
93
+ "zone": "FALLBACK",
94
+ "industryStandardPUE": 1.1,
95
+ "error": "No matching zone found in available zones"
96
+ },
97
+ "europe-north1": {
98
+ "zone": "FALLBACK",
99
+ "industryStandardPUE": 1.1,
100
+ "error": "No matching zone found in available zones"
101
+ },
102
+ "europe-central2": {
103
+ "zone": "FALLBACK",
104
+ "industryStandardPUE": 1.1,
105
+ "error": "No matching zone found in available zones"
106
+ },
107
+ "europe-southwest1": {
108
+ "zone": "FALLBACK",
109
+ "industryStandardPUE": 1.1,
110
+ "error": "No matching zone found in available zones"
111
+ },
112
+ "asia-east1": {
113
+ "zone": "FALLBACK",
114
+ "industryStandardPUE": 1.1,
115
+ "error": "No matching zone found in available zones"
116
+ },
117
+ "asia-east2": {
118
+ "zone": "FALLBACK",
119
+ "industryStandardPUE": 1.1,
120
+ "error": "No matching zone found in available zones"
121
+ },
122
+ "asia-northeast1": {
123
+ "zone": "FALLBACK",
124
+ "industryStandardPUE": 1.1,
125
+ "error": "No matching zone found in available zones"
126
+ },
127
+ "asia-northeast2": {
128
+ "zone": "FALLBACK",
129
+ "industryStandardPUE": 1.1,
130
+ "error": "No matching zone found in available zones"
131
+ },
132
+ "asia-northeast3": {
133
+ "zone": "FALLBACK",
134
+ "industryStandardPUE": 1.1,
135
+ "error": "No matching zone found in available zones"
136
+ },
137
+ "asia-south1": {
138
+ "zone": "FALLBACK",
139
+ "industryStandardPUE": 1.1,
140
+ "error": "No matching zone found in available zones"
141
+ },
142
+ "asia-south2": {
143
+ "zone": "FALLBACK",
144
+ "industryStandardPUE": 1.1,
145
+ "error": "No matching zone found in available zones"
146
+ },
147
+ "asia-southeast1": {
148
+ "zone": "FALLBACK",
149
+ "industryStandardPUE": 1.1,
150
+ "error": "No matching zone found in available zones"
151
+ },
152
+ "asia-southeast2": {
153
+ "zone": "FALLBACK",
154
+ "industryStandardPUE": 1.1,
155
+ "error": "No matching zone found in available zones"
156
+ },
157
+ "australia-southeast1": {
158
+ "zone": "FALLBACK",
159
+ "industryStandardPUE": 1.1,
160
+ "error": "No matching zone found in available zones"
161
+ },
162
+ "australia-southeast2": {
163
+ "zone": "FALLBACK",
164
+ "industryStandardPUE": 1.1,
165
+ "error": "No matching zone found in available zones"
166
+ },
167
+ "southamerica-east1": {
168
+ "zone": "FALLBACK",
169
+ "industryStandardPUE": 1.1,
170
+ "error": "No matching zone found in available zones"
171
+ },
172
+ "northamerica-northeast1": {
173
+ "zone": "FALLBACK",
174
+ "industryStandardPUE": 1.1,
175
+ "error": "No matching zone found in available zones"
176
+ },
177
+ "northamerica-northeast2": {
178
+ "zone": "FALLBACK",
179
+ "industryStandardPUE": 1.1,
180
+ "error": "No matching zone found in available zones"
181
+ }
182
+ }
183
+ }
@@ -0,0 +1,13 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class AutofixCi extends Command {
3
+ static args: {
4
+ 'project-id': import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ mr: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ pipeline: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ };
12
+ run(): Promise<void>;
13
+ }
@@ -0,0 +1,114 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import { runAgent } from '../lib/ai/agent.js';
3
+ import { configManager } from '../lib/config.js';
4
+ import { createGitlabClient } from '../lib/gitlab/client.js';
5
+ import { getPipelineProvider } from '../lib/gitlab/index.js';
6
+ /* eslint-disable @typescript-eslint/no-explicit-any */
7
+ export default class AutofixCi extends Command {
8
+ static args = {
9
+ 'project-id': Args.string({
10
+ description: 'GitLab project ID or path (falls back to DUOOPS_PROJECT_ID, CI_PROJECT_ID, or duoops init default)',
11
+ required: false,
12
+ }),
13
+ };
14
+ static description = 'Headless autofix: analyze the latest failing pipeline and post results to an MR or stdout';
15
+ static examples = [
16
+ '<%= config.bin %> <%= command.id %>',
17
+ '<%= config.bin %> <%= command.id %> 12345 --mr 42',
18
+ '<%= config.bin %> <%= command.id %> my-group/my-project --pipeline 99999',
19
+ ];
20
+ static flags = {
21
+ mr: Flags.integer({
22
+ description: 'Merge request IID to comment the analysis on',
23
+ }),
24
+ pipeline: Flags.integer({
25
+ description: 'Pipeline ID to analyze (defaults to latest failed)',
26
+ }),
27
+ };
28
+ async run() {
29
+ const { args, flags } = await this.parse(AutofixCi);
30
+ const config = configManager.get();
31
+ const projectId = args['project-id'] ??
32
+ process.env.DUOOPS_PROJECT_ID ??
33
+ process.env.CI_PROJECT_ID ??
34
+ config.defaultProjectId;
35
+ if (!projectId) {
36
+ this.error('Project ID is required. Pass it as an argument, set DUOOPS_PROJECT_ID / CI_PROJECT_ID, or run "duoops init".');
37
+ }
38
+ const provider = getPipelineProvider();
39
+ let pipeline = null;
40
+ if (flags.pipeline) {
41
+ pipeline = await provider.getPipeline(projectId, flags.pipeline);
42
+ }
43
+ else {
44
+ this.log('Looking for the latest failing pipeline...');
45
+ const pipelines = await provider.listPipelines(projectId, { perPage: 10, status: 'failed' });
46
+ pipeline = pipelines.find((p) => p.status === 'failed') ?? null;
47
+ }
48
+ if (!pipeline) {
49
+ this.log('No failing pipelines found. Nothing to fix.');
50
+ return;
51
+ }
52
+ this.log(`Analyzing pipeline ${pipeline.id} (${pipeline.status}) on ref ${pipeline.ref}...`);
53
+ const jobs = await provider.listJobs(projectId, pipeline.id);
54
+ const prompt = buildAutofixPrompt(pipeline, jobs);
55
+ this.log('Running agent analysis...');
56
+ const analysis = await runAgent(prompt, { projectId });
57
+ if (flags.mr) {
58
+ this.log(`Posting analysis to MR !${flags.mr}...`);
59
+ await postMrNote(projectId, flags.mr, analysis, pipeline);
60
+ this.log('Done. Analysis posted to the merge request.');
61
+ }
62
+ else if (process.env.CI_MERGE_REQUEST_IID) {
63
+ const mrIid = Number(process.env.CI_MERGE_REQUEST_IID);
64
+ this.log(`Posting analysis to MR !${mrIid} (from CI_MERGE_REQUEST_IID)...`);
65
+ await postMrNote(projectId, mrIid, analysis, pipeline);
66
+ this.log('Done. Analysis posted to the merge request.');
67
+ }
68
+ else {
69
+ this.log('\n' + analysis);
70
+ }
71
+ }
72
+ }
73
+ async function postMrNote(projectId, mrIid, analysis, pipeline) {
74
+ const client = createGitlabClient();
75
+ const body = `## DuoOps Autofix Analysis
76
+
77
+ **Pipeline:** [#${pipeline.id}](${pipeline.webUrl ?? ''}) | **Ref:** \`${pipeline.ref}\` | **Status:** ${pipeline.status}
78
+
79
+ ---
80
+
81
+ ${analysis}
82
+
83
+ ---
84
+ *Generated by [DuoOps](https://gitlab.com/youneslaaroussi/duoops) autofix agent*`;
85
+ await client.MergeRequestNotes.create(projectId, mrIid, body);
86
+ }
87
+ function buildAutofixPrompt(pipeline, jobs) {
88
+ const jobSummary = jobs.length === 0
89
+ ? 'No jobs were returned for this pipeline.'
90
+ : jobs
91
+ .map((job) => `• Job ${job.id} (${job.name}) in stage ${job.stage} - status: ${job.status}${job.duration ? `, duration: ${job.duration}s` : ''}`)
92
+ .join('\n');
93
+ return `You are DuoOps, an AI pair engineer focused on CI/CD reliability and sustainability.
94
+ Inspect the following failing pipeline and propose actionable fixes.
95
+
96
+ Pipeline:
97
+ - ID: ${pipeline.id}
98
+ - Ref: ${pipeline.ref}
99
+ - Status: ${pipeline.status}
100
+ - SHA: ${pipeline.sha}
101
+ - URL: ${pipeline.webUrl ?? 'n/a'}
102
+
103
+ Jobs:
104
+ ${jobSummary}
105
+
106
+ Instructions:
107
+ 1. Use the get_job_logs tool to fetch logs for ALL failed jobs. This is critical.
108
+ 2. Identify the root cause of the failure based on the logs and job statuses.
109
+ 3. Propose concrete remediation steps, referencing jobs and stages explicitly.
110
+ 4. Suggest any GitLab CI configuration changes or code adjustments to prevent this regression.
111
+ 5. Outline validation steps once the fix is applied.
112
+
113
+ Respond in Markdown with clear sections: Root Cause, Fix Plan, Validation.`;
114
+ }
@@ -0,0 +1,5 @@
1
+ import Portal from './portal.js';
2
+ export default class Autofix extends Portal {
3
+ static description: string;
4
+ run(): Promise<void>;
5
+ }
@@ -0,0 +1,11 @@
1
+ import Portal from './portal.js';
2
+ const AUTO_MESSAGE = 'Please inspect the most recent failing pipeline, diagnose the issue, and propose a fix.';
3
+ export default class Autofix extends Portal {
4
+ static description = 'Launch the DuoOps portal and pre-fill an autofix request to the agent';
5
+ async run() {
6
+ process.env.DUOOPS_PORTAL_DEFAULT_TAB = 'console';
7
+ process.env.DUOOPS_PORTAL_PRESET_MESSAGE = AUTO_MESSAGE;
8
+ this.log('Launching DuoOps portal with an autofix prompt...');
9
+ await super.run();
10
+ }
11
+ }
@@ -9,6 +9,8 @@ import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { configManager } from '../lib/config.js';
12
+ import { detectGcpProject, enableApis, validateProjectAccess } from '../lib/gcloud.js';
13
+ import McpDeploy from './mcp/deploy.js';
12
14
  const hasBinary = (command) => {
13
15
  try {
14
16
  execSync(`${command} --version`, { stdio: 'ignore' });
@@ -18,6 +20,29 @@ const hasBinary = (command) => {
18
20
  return false;
19
21
  }
20
22
  };
23
+ const detectGlabToken = (host = 'gitlab.com') => {
24
+ const candidates = [
25
+ path.join(os.homedir(), '.config', 'glab-cli', 'config.yml'),
26
+ path.join(os.homedir(), 'Library', 'Application Support', 'glab-cli', 'config.yml'),
27
+ ];
28
+ for (const configPath of candidates) {
29
+ try {
30
+ if (!fs.existsSync(configPath))
31
+ continue;
32
+ const content = fs.readFileSync(configPath, 'utf8');
33
+ const hostSection = content.split(`${host}:`)[1];
34
+ if (!hostSection)
35
+ continue;
36
+ const isOauth = hostSection.match(/is_oauth2:\s*"?true"?/);
37
+ if (isOauth)
38
+ continue;
39
+ const tokenMatch = hostSection.match(/token:\s*(?:!!null\s+)?(\S+)/);
40
+ if (tokenMatch?.[1] && tokenMatch[1].startsWith('glpat-'))
41
+ return tokenMatch[1];
42
+ }
43
+ catch { /* ignore */ }
44
+ }
45
+ };
21
46
  const detectGitRemotePath = () => {
22
47
  try {
23
48
  const remote = execSync('git remote get-url origin', { encoding: 'utf8' }).trim();
@@ -32,13 +57,6 @@ const detectGitRemotePath = () => {
32
57
  }
33
58
  catch { /* ignore */ }
34
59
  };
35
- const detectGcpProject = () => {
36
- try {
37
- const value = execSync('gcloud config get-value project', { encoding: 'utf8' }).trim();
38
- return value && value !== '(unset)' ? value : undefined;
39
- }
40
- catch { }
41
- };
42
60
  const normalizeProjectPath = (project) => project.replace(/^\/+/, '');
43
61
  const normalizeGitLabUrl = (url) => url.replace(/\/+$/, '');
44
62
  const setGitlabVariable = async (auth, projectPath, variable) => {
@@ -79,23 +97,6 @@ const createServiceAccountKey = (projectId, serviceAccount) => {
79
97
  fs.unlinkSync(tmpPath);
80
98
  return contents.toString('base64');
81
99
  };
82
- const ensureServiceEnabled = (projectId, service, label, log) => {
83
- try {
84
- log?.(gray(`Ensuring ${label} API is enabled...`));
85
- execSync(`gcloud services enable ${service} --project=${projectId} --quiet`, {
86
- stdio: 'ignore',
87
- });
88
- }
89
- catch {
90
- log?.(gray(`${label} API enablement skipped (it may already be enabled).`));
91
- }
92
- };
93
- const ensureComputeApiEnabled = (projectId, log) => {
94
- ensureServiceEnabled(projectId, 'compute.googleapis.com', 'Compute Engine', log);
95
- };
96
- const ensureMonitoringApiEnabled = (projectId, log) => {
97
- ensureServiceEnabled(projectId, 'monitoring.googleapis.com', 'Cloud Monitoring', log);
98
- };
99
100
  const sleep = (ms) => new Promise((resolve) => {
100
101
  setTimeout(resolve, ms);
101
102
  });
@@ -270,16 +271,41 @@ export default class Init extends Command {
270
271
  static description = 'Initialize DuoOps, optionally wiring a GitLab project and GCP runner';
271
272
  async run() {
272
273
  this.log(bold('Welcome to DuoOps!'));
273
- this.log('You will need a GitLab Personal Access Token with the "api" scope.');
274
- this.log('Create one at https://gitlab.com/-/user_settings/personal_access_tokens\n');
275
274
  const gitlabUrl = await input({
276
275
  default: 'https://gitlab.com',
277
276
  message: 'GitLab URL',
278
277
  });
279
- const gitlabToken = await password({
280
- mask: '*',
281
- message: 'GitLab Personal Access Token',
282
- });
278
+ const { host } = new URL(gitlabUrl);
279
+ const glabToken = detectGlabToken(host);
280
+ const envToken = process.env.GITLAB_TOKEN || process.env.GL_TOKEN;
281
+ let gitlabToken;
282
+ if (glabToken || envToken) {
283
+ const source = glabToken ? 'glab CLI' : 'environment';
284
+ const detected = glabToken || envToken;
285
+ const masked = detected.slice(0, 6) + '...' + detected.slice(-4);
286
+ const useDetected = await confirm({
287
+ default: true,
288
+ message: `Found GitLab token from ${source} (${masked}). Use it?`,
289
+ });
290
+ if (useDetected) {
291
+ gitlabToken = detected;
292
+ }
293
+ else {
294
+ this.log('Create one at https://gitlab.com/-/user_settings/personal_access_tokens\n');
295
+ gitlabToken = await password({
296
+ mask: '*',
297
+ message: 'GitLab Personal Access Token',
298
+ });
299
+ }
300
+ }
301
+ else {
302
+ this.log('You will need a GitLab Personal Access Token with the "api" scope.');
303
+ this.log('Create one at https://gitlab.com/-/user_settings/personal_access_tokens\n');
304
+ gitlabToken = await password({
305
+ mask: '*',
306
+ message: 'GitLab Personal Access Token',
307
+ });
308
+ }
283
309
  const enableMeasure = await confirm({
284
310
  message: 'Enable Measure/Sustainability Tracking (BigQuery)?',
285
311
  });
@@ -354,7 +380,40 @@ export default class Init extends Command {
354
380
  };
355
381
  configManager.set(config);
356
382
  this.log(green('Configuration saved.'));
357
- this.log(`Try '${this.config.bin} pipelines:list <project>' to verify GitLab access.\n`);
383
+ const setupAgents = await confirm({
384
+ default: true,
385
+ message: 'Set up DuoOps agents and flows in this project?',
386
+ });
387
+ if (setupAgents) {
388
+ const { scaffoldProject } = await import('../lib/scaffold.js');
389
+ const result = scaffoldProject(process.cwd());
390
+ for (const f of result.created) {
391
+ this.log(green(` ✓ Created ${f}`));
392
+ }
393
+ for (const f of result.skipped) {
394
+ this.log(gray(` · Skipped ${f} (already exists)`));
395
+ }
396
+ }
397
+ this.log(`\nTry '${this.config.bin} pipelines:list <project>' to verify GitLab access.\n`);
398
+ if (enableMeasure) {
399
+ const deployMcp = await confirm({
400
+ default: true,
401
+ message: 'Deploy the DuoOps MCP Server to Cloud Run now?',
402
+ });
403
+ if (deployMcp && googleProjectId && bigqueryDataset && bigqueryTable) {
404
+ try {
405
+ await McpDeploy.run([
406
+ '--gcp-project', googleProjectId,
407
+ '--bq-dataset', bigqueryDataset,
408
+ '--bq-table', bigqueryTable,
409
+ '--gitlab-url', gitlabUrl,
410
+ ]);
411
+ }
412
+ catch (error) {
413
+ this.warn(`MCP Deployment failed: ${error}`);
414
+ }
415
+ }
416
+ }
358
417
  const configureProject = await confirm({
359
418
  default: true,
360
419
  message: 'Configure a GitLab project (CI variables + optional GCP runner) now?',
@@ -418,6 +477,14 @@ export default class Init extends Command {
418
477
  this.warn('GCP Project ID is required to collect metrics.');
419
478
  return setAsDefault ? projectPath : undefined;
420
479
  }
480
+ try {
481
+ validateProjectAccess(gcpProjectId);
482
+ this.log(green(` ✓ Verified access to ${gcpProjectId}`));
483
+ }
484
+ catch (error) {
485
+ this.warn(error.message);
486
+ return setAsDefault ? projectPath : undefined;
487
+ }
421
488
  let serviceAccountEmail = await input({
422
489
  default: `duoops-runner@${gcpProjectId}.iam.gserviceaccount.com`,
423
490
  message: 'Service account email for DuoOps measurements',
@@ -522,8 +589,12 @@ export default class Init extends Command {
522
589
  default: 'gcp',
523
590
  message: 'Runner tag (GitLab jobs will use this)',
524
591
  });
525
- ensureComputeApiEnabled(gcpProjectId, (message) => this.log(message));
526
- ensureMonitoringApiEnabled(gcpProjectId, (message) => this.log(message));
592
+ try {
593
+ enableApis(gcpProjectId, ['compute.googleapis.com', 'monitoring.googleapis.com'], (msg) => this.log(gray(msg)));
594
+ }
595
+ catch (error) {
596
+ this.warn(error.message);
597
+ }
527
598
  let runnerToken = tryCreateRunnerToken(projectPath, vmName, runnerTag);
528
599
  if (!runnerToken) {
529
600
  this.log(gray('Unable to create a runner token automatically. Generate one in your GitLab project (Settings → CI/CD → Runners) and paste it below.'));
@@ -0,0 +1,13 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class McpDeploy extends Command {
3
+ static description: string;
4
+ static flags: {
5
+ 'bq-dataset': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
+ 'bq-table': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ 'gcp-project': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ 'gitlab-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ region: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ source: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ };
12
+ run(): Promise<void>;
13
+ }
@@ -0,0 +1,139 @@
1
+ import { confirm, input } from '@inquirer/prompts';
2
+ import { Command, Flags } from '@oclif/core';
3
+ import { bold, cyan, gray, green } from 'kleur/colors';
4
+ import { execSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { configManager } from '../../lib/config.js';
9
+ import { detectGcpProject, enableApis, ensureCloudBuildServiceAccount, getActiveAccount, requireGcloud, validateProjectAccess } from '../../lib/gcloud.js';
10
+ export default class McpDeploy extends Command {
11
+ static description = 'Deploy the DuoOps MCP server to Google Cloud Run';
12
+ static flags = {
13
+ 'bq-dataset': Flags.string({ description: 'BigQuery Dataset ID' }),
14
+ 'bq-table': Flags.string({ description: 'BigQuery Table ID' }),
15
+ 'gcp-project': Flags.string({ char: 'p', description: 'Google Cloud Project ID' }),
16
+ 'gitlab-url': Flags.string({ default: 'https://gitlab.com', description: 'GitLab instance URL' }),
17
+ region: Flags.string({ char: 'r', default: 'us-central1', description: 'Cloud Run region' }),
18
+ 'source': Flags.string({ default: '', description: 'Path to MCP server source directory' }),
19
+ };
20
+ async run() {
21
+ const { flags } = await this.parse(McpDeploy);
22
+ const config = configManager.get();
23
+ const gcpProject = flags['gcp-project'] || config.measure?.googleProjectId || await input({
24
+ default: detectGcpProject(),
25
+ message: 'Google Cloud Project ID',
26
+ });
27
+ const bqDataset = flags['bq-dataset'] || config.measure?.bigqueryDataset || await input({
28
+ default: 'measure_data',
29
+ message: 'BigQuery Dataset',
30
+ });
31
+ const bqTable = flags['bq-table'] || config.measure?.bigqueryTable || await input({
32
+ default: 'emissions',
33
+ message: 'BigQuery Table',
34
+ });
35
+ const region = flags.region || await input({
36
+ default: 'us-central1',
37
+ message: 'Cloud Run region',
38
+ });
39
+ const gitlabUrl = flags['gitlab-url'] || config.gitlabUrl || await input({
40
+ default: 'https://gitlab.com',
41
+ message: 'GitLab instance URL',
42
+ });
43
+ if (!gcpProject) {
44
+ this.error('Google Cloud Project ID is required.');
45
+ }
46
+ this.log(bold('Deploying DuoOps MCP Server...'));
47
+ this.log(gray(`Project: ${gcpProject}`));
48
+ this.log(gray(`Region: ${region}`));
49
+ this.log(gray(`Dataset: ${bqDataset}.${bqTable}`));
50
+ try {
51
+ requireGcloud();
52
+ }
53
+ catch (error) {
54
+ this.error(error.message);
55
+ }
56
+ const account = getActiveAccount();
57
+ this.log(gray(`Account: ${account}`));
58
+ try {
59
+ validateProjectAccess(gcpProject);
60
+ this.log(green(` ✓ Verified access to ${gcpProject}`));
61
+ }
62
+ catch (error) {
63
+ this.error(error.message);
64
+ }
65
+ this.log(gray('\nEnabling required GCP APIs...'));
66
+ try {
67
+ enableApis(gcpProject, [
68
+ 'run.googleapis.com',
69
+ 'artifactregistry.googleapis.com',
70
+ 'cloudbuild.googleapis.com',
71
+ ], (msg) => this.log(gray(msg)));
72
+ }
73
+ catch (error) {
74
+ this.error(error.message);
75
+ }
76
+ this.log(gray('\nConfiguring Cloud Build service account...'));
77
+ ensureCloudBuildServiceAccount(gcpProject, (msg) => this.log(gray(msg)));
78
+ const mcpServerDir = flags.source || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../mcp-server');
79
+ if (!fs.existsSync(path.join(mcpServerDir, 'Dockerfile'))) {
80
+ this.error(`MCP server source not found at ${mcpServerDir}. Provide --source flag or run from the DuoOps repo.`);
81
+ }
82
+ const serviceName = 'duoops-mcp';
83
+ const cmd = `gcloud run deploy ${serviceName} \
84
+ --source ${mcpServerDir} \
85
+ --project ${gcpProject} \
86
+ --region ${region} \
87
+ --platform managed \
88
+ --allow-unauthenticated \
89
+ --set-env-vars "GCP_PROJECT_ID=${gcpProject},BQ_DATASET=${bqDataset},BQ_TABLE=${bqTable},GITLAB_URL=${gitlabUrl}" \
90
+ --format="value(status.url)"`;
91
+ this.log(gray(`\nBuilding and deploying from ${mcpServerDir}...`));
92
+ this.log(gray('(This uses Cloud Build and may take 2-3 minutes)\n'));
93
+ let serviceUrl = '';
94
+ try {
95
+ serviceUrl = execSync(cmd, { encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'] }).trim();
96
+ }
97
+ catch (error) {
98
+ const err = error;
99
+ this.error(`Deployment failed: ${err.stdout || ''}\n${err.stderr || error.message}`);
100
+ }
101
+ if (!serviceUrl) {
102
+ this.error('Deployment succeeded but failed to retrieve service URL.');
103
+ }
104
+ this.log(green(`\n✔ Successfully deployed to: ${bold(serviceUrl)}`));
105
+ this.log(gray('Running health check...'));
106
+ try {
107
+ const health = execSync(`curl -sf ${serviceUrl}/health`, { encoding: 'utf8', timeout: 10_000 }).trim();
108
+ this.log(green(` ✓ ${health}`));
109
+ }
110
+ catch {
111
+ this.warn('Health check failed — server may still be starting up. Try: curl ' + serviceUrl + '/health');
112
+ }
113
+ const mcpConfig = {
114
+ mcpServers: {
115
+ "duoops-carbon": {
116
+ approvedTools: true,
117
+ type: "http",
118
+ url: `${serviceUrl}/mcp`
119
+ }
120
+ }
121
+ };
122
+ const configStr = JSON.stringify(mcpConfig, null, 2);
123
+ this.log(bold('\nMCP Configuration:'));
124
+ this.log(cyan(configStr));
125
+ const writeConfig = await confirm({
126
+ default: true,
127
+ message: 'Write mcp.json to .gitlab/duo/mcp.json?',
128
+ });
129
+ if (writeConfig) {
130
+ const configDir = path.join(process.cwd(), '.gitlab', 'duo');
131
+ const configPath = path.join(configDir, 'mcp.json');
132
+ if (!fs.existsSync(configDir)) {
133
+ fs.mkdirSync(configDir, { recursive: true });
134
+ }
135
+ fs.writeFileSync(configPath, configStr);
136
+ this.log(green(`Wrote configuration to ${configPath}`));
137
+ }
138
+ }
139
+ }