duoops 0.0.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 (79) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +181 -0
  3. package/bin/dev.cmd +3 -0
  4. package/bin/dev.js +7 -0
  5. package/bin/run.cmd +3 -0
  6. package/bin/run.js +8 -0
  7. package/dist/commands/act.d.ts +12 -0
  8. package/dist/commands/act.js +61 -0
  9. package/dist/commands/ask.d.ts +8 -0
  10. package/dist/commands/ask.js +22 -0
  11. package/dist/commands/init.d.ts +5 -0
  12. package/dist/commands/init.js +97 -0
  13. package/dist/commands/job/logs.d.ts +13 -0
  14. package/dist/commands/job/logs.js +26 -0
  15. package/dist/commands/measure/calculate.d.ts +19 -0
  16. package/dist/commands/measure/calculate.js +208 -0
  17. package/dist/commands/measure/component.d.ts +5 -0
  18. package/dist/commands/measure/component.js +23 -0
  19. package/dist/commands/measure/seed.d.ts +5 -0
  20. package/dist/commands/measure/seed.js +62 -0
  21. package/dist/commands/pipelines/list.d.ts +14 -0
  22. package/dist/commands/pipelines/list.js +62 -0
  23. package/dist/commands/pipelines/show.d.ts +13 -0
  24. package/dist/commands/pipelines/show.js +68 -0
  25. package/dist/commands/portal.d.ts +8 -0
  26. package/dist/commands/portal.js +139 -0
  27. package/dist/commands/undo.d.ts +5 -0
  28. package/dist/commands/undo.js +35 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/lib/ai/agent.d.ts +6 -0
  32. package/dist/lib/ai/agent.js +139 -0
  33. package/dist/lib/ai/model.d.ts +2 -0
  34. package/dist/lib/ai/model.js +22 -0
  35. package/dist/lib/ai/tools/editing.d.ts +3 -0
  36. package/dist/lib/ai/tools/editing.js +61 -0
  37. package/dist/lib/ai/tools/filesystem.d.ts +4 -0
  38. package/dist/lib/ai/tools/filesystem.js +44 -0
  39. package/dist/lib/ai/tools/gitlab.d.ts +4 -0
  40. package/dist/lib/ai/tools/gitlab.js +81 -0
  41. package/dist/lib/ai/tools/measure.d.ts +3 -0
  42. package/dist/lib/ai/tools/measure.js +26 -0
  43. package/dist/lib/config.d.ts +18 -0
  44. package/dist/lib/config.js +72 -0
  45. package/dist/lib/gitlab/client.d.ts +6 -0
  46. package/dist/lib/gitlab/client.js +18 -0
  47. package/dist/lib/gitlab/index.d.ts +6 -0
  48. package/dist/lib/gitlab/index.js +49 -0
  49. package/dist/lib/gitlab/provider.d.ts +14 -0
  50. package/dist/lib/gitlab/provider.js +72 -0
  51. package/dist/lib/gitlab/types.d.ts +34 -0
  52. package/dist/lib/gitlab/types.js +5 -0
  53. package/dist/lib/integrations/bigquery-sink.d.ts +12 -0
  54. package/dist/lib/integrations/bigquery-sink.js +47 -0
  55. package/dist/lib/logger.d.ts +2 -0
  56. package/dist/lib/logger.js +11 -0
  57. package/dist/lib/measure/bigquery-service.d.ts +2 -0
  58. package/dist/lib/measure/bigquery-service.js +54 -0
  59. package/dist/lib/measure/carbon-calculator.d.ts +13 -0
  60. package/dist/lib/measure/carbon-calculator.js +125 -0
  61. package/dist/lib/measure/cli-utils.d.ts +2 -0
  62. package/dist/lib/measure/cli-utils.js +107 -0
  63. package/dist/lib/measure/intensity-provider.d.ts +6 -0
  64. package/dist/lib/measure/intensity-provider.js +34 -0
  65. package/dist/lib/measure/power-profile-repository.d.ts +19 -0
  66. package/dist/lib/measure/power-profile-repository.js +129 -0
  67. package/dist/lib/measure/types.d.ts +137 -0
  68. package/dist/lib/measure/types.js +1 -0
  69. package/dist/lib/measure/zone-mapper.d.ts +16 -0
  70. package/dist/lib/measure/zone-mapper.js +104 -0
  71. package/dist/lib/state.d.ts +4 -0
  72. package/dist/lib/state.js +21 -0
  73. package/dist/portal/assets/index-BP8FwWqA.css +1 -0
  74. package/dist/portal/assets/index-MU6EBerh.js +188 -0
  75. package/dist/portal/duoops.svg +4 -0
  76. package/dist/portal/index.html +24 -0
  77. package/dist/portal/vite.svg +1 -0
  78. package/oclif.manifest.json +415 -0
  79. package/package.json +103 -0
@@ -0,0 +1,208 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import asciichart from 'asciichart';
3
+ import { bold, gray, green, red } from 'kleur/colors';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { configManager } from '../../lib/config.js';
8
+ import { persistJobToBigQuery } from '../../lib/integrations/bigquery-sink.js';
9
+ import { CarbonCalculator } from '../../lib/measure/carbon-calculator.js';
10
+ import { parseTimeseriesFile } from '../../lib/measure/cli-utils.js';
11
+ import { IntensityProvider } from '../../lib/measure/intensity-provider.js';
12
+ import { PowerProfileRepository } from '../../lib/measure/power-profile-repository.js';
13
+ import { ZoneMapper } from '../../lib/measure/zone-mapper.js';
14
+ export default class CarbonCalculate extends Command {
15
+ static args = {
16
+ // none for now
17
+ };
18
+ static description = 'Calculate carbon emissions from CPU/RAM timeseries';
19
+ static examples = [
20
+ `<%= config.bin %> <%= command.id %> --provider gcp --machine e2-standard-4 --region us-central1 --cpu cpu.json --ram-used ram_used.json`,
21
+ ];
22
+ static flags = {
23
+ budget: Flags.integer({
24
+ description: 'Carbon budget in grams CO2e',
25
+ }),
26
+ 'cpu-timeseries': Flags.string({
27
+ description: 'Path to CPU timeseries JSON',
28
+ required: true,
29
+ }),
30
+ 'fail-on-budget': Flags.boolean({
31
+ default: false,
32
+ description: 'Exit with error if budget exceeded',
33
+ }),
34
+ machine: Flags.string({
35
+ char: 'm',
36
+ description: 'Machine type (e.g., e2-standard-4)',
37
+ required: true,
38
+ }),
39
+ 'out-json': Flags.string({
40
+ description: 'Path to write JSON report',
41
+ }),
42
+ 'out-md': Flags.string({
43
+ description: 'Path to write Markdown report',
44
+ }),
45
+ provider: Flags.string({
46
+ char: 'p',
47
+ default: 'gcp',
48
+ description: 'Cloud provider',
49
+ options: ['gcp', 'aws'], // Keeping aws in options but functionality limited
50
+ }),
51
+ 'ram-size-timeseries': Flags.string({
52
+ description: 'Path to RAM size timeseries JSON',
53
+ required: false,
54
+ }),
55
+ 'ram-used-timeseries': Flags.string({
56
+ description: 'Path to RAM used timeseries JSON',
57
+ required: true,
58
+ }),
59
+ region: Flags.string({
60
+ char: 'r',
61
+ description: 'Region (e.g., us-central1)',
62
+ required: true,
63
+ }),
64
+ };
65
+ // eslint-disable-next-line complexity -- carbon report has many branches
66
+ async run() {
67
+ const { flags } = await this.parse(CarbonCalculate);
68
+ // Parse timeseries
69
+ const cpuTimeseries = parseTimeseriesFile(flags['cpu-timeseries']);
70
+ const ramUsedTimeseries = parseTimeseriesFile(flags['ram-used-timeseries']);
71
+ const ramSizeTimeseries = flags['ram-size-timeseries']
72
+ ? parseTimeseriesFile(flags['ram-size-timeseries'])
73
+ : [];
74
+ if (cpuTimeseries.length === 0) {
75
+ this.error('No valid data points found in CPU timeseries file');
76
+ }
77
+ if (ramUsedTimeseries.length === 0) {
78
+ this.error('No valid data points found in RAM used timeseries file');
79
+ }
80
+ this.log(gray(`Loaded ${cpuTimeseries.length} CPU points, ${ramUsedTimeseries.length} RAM points`));
81
+ // Initialize dependencies
82
+ // Locate data directory relative to the compiled file or project root
83
+ // In production (dist), __dirname is .../dist/commands/carbon
84
+ // data is at .../data
85
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
86
+ // Try to resolve data directory
87
+ // 1. From source (development)
88
+ // 2. From dist (production/built)
89
+ let dataDir = path.resolve(__dirname, '../../../../data');
90
+ if (!fs.existsSync(dataDir)) {
91
+ // Fallback for different structures or if run from different location
92
+ dataDir = path.resolve(process.cwd(), 'data');
93
+ }
94
+ if (!fs.existsSync(dataDir)) {
95
+ this.error(`Could not locate data directory at ${dataDir}`);
96
+ }
97
+ const powerProfileRepository = new PowerProfileRepository(dataDir);
98
+ const zoneMapper = new ZoneMapper(dataDir);
99
+ // Using a placeholder API key or env var if we had one, but IntensityProvider might work without it for public data or fallback
100
+ const intensityProvider = new IntensityProvider();
101
+ const calculator = new CarbonCalculator(powerProfileRepository, zoneMapper, intensityProvider);
102
+ const jobInput = {
103
+ cpuTimeseries,
104
+ machineType: flags.machine,
105
+ provider: flags.provider,
106
+ ramSizeTimeseries,
107
+ ramUsedTimeseries,
108
+ region: flags.region,
109
+ };
110
+ // Sort for charts
111
+ const cpuSorted = [...cpuTimeseries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
112
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
113
+ const ramSorted = [...ramUsedTimeseries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
114
+ // CPU Chart
115
+ const cpuValues = cpuSorted.map((p) => {
116
+ if (flags.provider === 'aws') {
117
+ return p.value <= 1 ? p.value * 100 : p.value;
118
+ }
119
+ return p.value * 100;
120
+ });
121
+ if (cpuValues.length > 1) {
122
+ this.log(bold('\nCPU Utilization (%)'));
123
+ this.log(asciichart.plot(cpuValues, { format: (x) => x.toFixed(1).padStart(6), height: 8 }));
124
+ }
125
+ // Calculate
126
+ try {
127
+ const result = await calculator.calculate(jobInput);
128
+ const totalEnergyWh = (result.cpuEnergyKwh + result.ramEnergyKwh) * 1000;
129
+ const emissionsGrams = result.totalEmissions;
130
+ this.log(bold('\nCarbon Report'));
131
+ this.log(gray('------------------------------'));
132
+ this.log(`Provider: ${result.provider.toUpperCase()}`);
133
+ this.log(`Region: ${result.region}`);
134
+ this.log(`Machine: ${result.machineType}`);
135
+ this.log(`Runtime: ${(result.runtimeHours * 3600).toFixed(0)}s`);
136
+ this.log(`Energy: ${totalEnergyWh.toFixed(3)} Wh`);
137
+ let statusColor = green;
138
+ let budgetMsg = '';
139
+ if (flags.budget) {
140
+ if (emissionsGrams > flags.budget) {
141
+ statusColor = red;
142
+ budgetMsg = ` (over budget of ${flags.budget} g)`;
143
+ }
144
+ else {
145
+ budgetMsg = ` (within budget of ${flags.budget} g)`;
146
+ }
147
+ }
148
+ this.log(`Total: ${statusColor(emissionsGrams.toFixed(3) + ' gCO2e')}${budgetMsg}`);
149
+ this.log(`Intensity: ${result.carbonIntensity} gCO2e/kWh`);
150
+ this.log(`PUE: ${result.pue}`);
151
+ // Persist to BigQuery if configured
152
+ const config = configManager.get();
153
+ if (config.measure?.bigqueryDataset && config.measure?.bigqueryTable) {
154
+ try {
155
+ await persistJobToBigQuery({
156
+ budget: {
157
+ budgetConfigured: Boolean(flags.budget),
158
+ limitGrams: flags.budget,
159
+ overBudget: Boolean(flags.budget) && result.totalEmissions > (flags.budget || 0),
160
+ },
161
+ jobInput,
162
+ jsonReport: {
163
+ // Replicate basic JSON structure
164
+ emissions: result,
165
+ job: jobInput,
166
+ provider: result.provider,
167
+ },
168
+ result,
169
+ }, {
170
+ dataset: config.measure.bigqueryDataset,
171
+ table: config.measure.bigqueryTable,
172
+ });
173
+ this.log(gray(`Persisted results to BigQuery (${config.measure.bigqueryDataset}.${config.measure.bigqueryTable})`));
174
+ }
175
+ catch (error) {
176
+ this.warn(`Failed to persist to BigQuery: ${error}`);
177
+ }
178
+ }
179
+ // Output files
180
+ if (flags['out-json']) {
181
+ fs.writeFileSync(flags['out-json'], JSON.stringify(result, null, 2));
182
+ this.log(gray(`JSON report written to ${flags['out-json']}`));
183
+ }
184
+ if (flags['out-md']) {
185
+ const md = `
186
+ # Carbon Emission Report
187
+
188
+ | Metric | Value |
189
+ | :--- | :--- |
190
+ | Provider | ${result.provider} |
191
+ | Region | ${result.region} |
192
+ | Machine | ${result.machineType} |
193
+ | **Total Emissions** | **${emissionsGrams.toFixed(3)} gCO2e** |
194
+ | Energy Usage | ${totalEnergyWh.toFixed(3)} Wh |
195
+ | Runtime | ${(result.runtimeHours * 3600).toFixed(0)}s |
196
+ `;
197
+ fs.writeFileSync(flags['out-md'], md);
198
+ this.log(gray(`Markdown report written to ${flags['out-md']}`));
199
+ }
200
+ if (flags.budget && emissionsGrams > flags.budget && flags['fail-on-budget']) {
201
+ this.error(`Carbon budget exceeded: ${emissionsGrams.toFixed(3)}g > ${flags.budget}g`, { exit: 1 });
202
+ }
203
+ }
204
+ catch (error) {
205
+ this.error(error instanceof Error ? error.message : String(error));
206
+ }
207
+ }
208
+ }
@@ -0,0 +1,5 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class MeasureComponent extends Command {
3
+ static description: string;
4
+ run(): Promise<void>;
5
+ }
@@ -0,0 +1,23 @@
1
+ import { Command } from '@oclif/core';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ export default class MeasureComponent extends Command {
6
+ static description = 'Output the GitLab CI component configuration';
7
+ async run() {
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ // Locate the template file
10
+ // In dev: src/commands/measure/../../templates/duoops-measure-component.yml -> root/templates/...
11
+ // In dist: dist/commands/measure/../../templates/duoops-measure-component.yml -> root/templates/...
12
+ let templatePath = path.resolve(__dirname, '../../../../templates/duoops-measure-component.yml');
13
+ if (!fs.existsSync(templatePath)) {
14
+ // Fallback relative to CWD if running from source root
15
+ templatePath = path.resolve(process.cwd(), 'templates/duoops-measure-component.yml');
16
+ }
17
+ if (!fs.existsSync(templatePath)) {
18
+ this.error(`Could not locate component template at ${templatePath}`);
19
+ }
20
+ const content = fs.readFileSync(templatePath, 'utf8');
21
+ this.log(content);
22
+ }
23
+ }
@@ -0,0 +1,5 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Seed extends Command {
3
+ static description: string;
4
+ run(): Promise<void>;
5
+ }
@@ -0,0 +1,62 @@
1
+ import { BigQuery } from '@google-cloud/bigquery';
2
+ import { Command } from '@oclif/core';
3
+ import { configManager } from '../../lib/config.js';
4
+ export default class Seed extends Command {
5
+ static description = 'Seed the BigQuery emissions table with sample data for testing';
6
+ async run() {
7
+ const config = configManager.get();
8
+ if (!config.measure?.bigqueryDataset || !config.measure?.bigqueryTable) {
9
+ this.error('BigQuery not configured. Run "duoops init" first.');
10
+ }
11
+ const projectId = config.measure.googleProjectId || process.env.GCP_PROJECT_ID;
12
+ if (!projectId) {
13
+ this.error('Google Cloud Project ID is missing from configuration.');
14
+ }
15
+ const bigquery = new BigQuery({ projectId });
16
+ const datasetId = config.measure.bigqueryDataset;
17
+ const tableId = config.measure.bigqueryTable;
18
+ this.log(`Seeding data into ${datasetId}.${tableId}...`);
19
+ const rows = [];
20
+ const now = new Date();
21
+ // Generate 50 sample entries over the last 5 days
22
+ for (let i = 0; i < 50; i++) {
23
+ const date = new Date(now.getTime() - i * 4 * 60 * 60 * 1000); // Every 4 hours
24
+ // Randomize values somewhat realistically
25
+ const runtime = 120 + Math.random() * 300; // 2-7 minutes
26
+ const cpu = 0.4 + Math.random() * 0.5; // 40-90% CPU
27
+ const ram = 0.3 + Math.random() * 0.4; // 30-70% RAM
28
+ // Rough estimation:
29
+ // e2-standard-4 (4 vCPU, 16GB RAM)
30
+ // ~150W max power? Let's say 0.04 kWh per hour
31
+ const energy = (runtime / 3600) * (0.1 + (cpu * 0.1)); // kWh
32
+ const emissions = energy * 400; // ~400g CO2/kWh global avg
33
+ rows.push({
34
+ /* eslint-disable camelcase -- BigQuery schema uses snake_case */
35
+ cpu_utilization_avg: Number(cpu.toFixed(4)),
36
+ energy_kwh: Number(energy.toFixed(6)),
37
+ gitlab_job_id: 1_000_000 + i,
38
+ gitlab_job_name: i % 3 === 0 ? 'build-job' : i % 3 === 1 ? 'test-job' : 'deploy-job',
39
+ gitlab_project_id: config.defaultProjectId ? Number(config.defaultProjectId) : 12_345,
40
+ gitlab_user_name: 'dev-user',
41
+ ingested_at: bigquery.timestamp(date),
42
+ machine_type: 'e2-standard-4',
43
+ ram_utilization_avg: Number(ram.toFixed(4)),
44
+ region: 'us-central1',
45
+ runtime_seconds: Number(runtime.toFixed(2)),
46
+ total_emissions_g: Number(emissions.toFixed(4)),
47
+ /* eslint-enable camelcase */
48
+ });
49
+ }
50
+ try {
51
+ await bigquery
52
+ .dataset(datasetId)
53
+ .table(tableId)
54
+ .insert(rows);
55
+ this.log(`Successfully inserted ${rows.length} rows of sample data.`);
56
+ this.log('Check the Portal Metrics tab to visualize it!');
57
+ }
58
+ catch (error) {
59
+ this.error(`Failed to insert data: ${error instanceof Error ? error.message : String(error)}`);
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,14 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class PipelinesList extends Command {
3
+ static args: {
4
+ project: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
10
+ ref: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ status: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ };
13
+ run(): Promise<void>;
14
+ }
@@ -0,0 +1,62 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import { configManager } from '../../lib/config.js';
3
+ import { getPipelineProvider } from '../../lib/gitlab/index.js';
4
+ export default class PipelinesList extends Command {
5
+ static args = {
6
+ project: Args.string({
7
+ description: 'Project ID or path (e.g. group/project)',
8
+ required: false,
9
+ }),
10
+ };
11
+ static description = 'List GitLab CI pipelines for a project';
12
+ static examples = [
13
+ `<%= config.bin %> <%= command.id %> group/my-project`,
14
+ `<%= config.bin %> <%= command.id %> 123 --limit 20 --ref main`,
15
+ ];
16
+ static flags = {
17
+ limit: Flags.integer({
18
+ char: 'n',
19
+ default: 10,
20
+ description: 'Maximum number of pipelines to return',
21
+ }),
22
+ ref: Flags.string({
23
+ description: 'Filter by branch or tag',
24
+ }),
25
+ status: Flags.string({
26
+ description: 'Filter by status (created, pending, running, success, failed, canceled, skipped, manual, scheduled)',
27
+ }),
28
+ };
29
+ async run() {
30
+ const { args, flags } = await this.parse(PipelinesList);
31
+ const provider = getPipelineProvider();
32
+ const config = configManager.get();
33
+ const projectId = args.project || config.defaultProjectId;
34
+ if (!projectId) {
35
+ this.error('Project ID is required. Provide it as an argument or set DUOOPS_TEST_PROJECT_ID in .env');
36
+ }
37
+ const pipelines = await provider.listPipelines(projectId, {
38
+ perPage: flags.limit,
39
+ ref: flags.ref,
40
+ status: flags.status,
41
+ });
42
+ if (pipelines.length === 0) {
43
+ this.log('No pipelines found.');
44
+ return;
45
+ }
46
+ const rows = pipelines.map((p) => [
47
+ String(p.id),
48
+ p.ref,
49
+ p.status,
50
+ p.sha.slice(0, 8),
51
+ new Date(p.createdAt).toLocaleString(),
52
+ ]);
53
+ const colWidths = [12, 15, 12, 10, 25];
54
+ const header = ['ID', 'Ref', 'Status', 'SHA', 'Created'].map((h, i) => h.padEnd(colWidths[i] ?? 0));
55
+ this.log(header.join(' '));
56
+ this.log('-'.repeat(header.join(' ').length));
57
+ for (const row of rows) {
58
+ const line = row.map((cell, i) => String(cell).slice(0, colWidths[i] ?? 0).padEnd(colWidths[i] ?? 0));
59
+ this.log(line.join(' '));
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,13 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class PipelinesShow extends Command {
3
+ static args: {
4
+ project: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ pipeline_id: import("@oclif/core/interfaces").Arg<number, {
6
+ max?: number;
7
+ min?: number;
8
+ }>;
9
+ };
10
+ static description: string;
11
+ static examples: string[];
12
+ run(): Promise<void>;
13
+ }
@@ -0,0 +1,68 @@
1
+ import { Args, Command } from '@oclif/core';
2
+ import { getPipelineProvider } from '../../lib/gitlab/index.js';
3
+ export default class PipelinesShow extends Command {
4
+ static args = {
5
+ /* eslint-disable perfectionist/sort-objects -- project must be first for CLI arg order */
6
+ project: Args.string({
7
+ description: 'Project ID (e.g. 123456)',
8
+ async parse(input) {
9
+ if (/^\d+$/.test(input)) {
10
+ return input;
11
+ }
12
+ throw new Error('Project ID must be a numeric ID (e.g. 123456). Project paths are not currently supported by this command.');
13
+ },
14
+ required: true,
15
+ }),
16
+ pipeline_id: Args.integer({
17
+ description: 'Pipeline ID',
18
+ required: true,
19
+ }),
20
+ /* eslint-enable perfectionist/sort-objects */
21
+ };
22
+ static description = 'Show pipeline details and jobs';
23
+ static examples = [
24
+ `<%= config.bin %> <%= command.id %> 12345 67890`,
25
+ ];
26
+ async run() {
27
+ const { args } = await this.parse(PipelinesShow);
28
+ const provider = getPipelineProvider();
29
+ const [pipeline, jobs] = await Promise.all([
30
+ provider.getPipeline(args.project, args.pipeline_id),
31
+ provider.listJobs(args.project, args.pipeline_id),
32
+ ]);
33
+ this.log('');
34
+ this.log('Pipeline');
35
+ this.log(' ID: ', String(pipeline.id));
36
+ this.log(' Ref: ', pipeline.ref);
37
+ this.log(' SHA: ', pipeline.sha);
38
+ this.log(' Status: ', pipeline.status);
39
+ if (pipeline.duration !== undefined && pipeline.duration !== null) {
40
+ this.log(' Duration: ', `${pipeline.duration}s`);
41
+ }
42
+ this.log(' Created: ', pipeline.createdAt);
43
+ if (pipeline.webUrl) {
44
+ this.log(' URL: ', pipeline.webUrl);
45
+ }
46
+ this.log('');
47
+ this.log('Jobs');
48
+ if (jobs.length === 0) {
49
+ this.log(' (no jobs)');
50
+ return;
51
+ }
52
+ const colWidths = [6, 25, 12, 12, 10];
53
+ const header = ['ID', 'Name', 'Stage', 'Status', 'Duration'].map((h, i) => h.padEnd(colWidths[i] ?? 0));
54
+ this.log(' ' + header.join(' '));
55
+ this.log(' ' + '-'.repeat(header.join(' ').length));
56
+ for (const j of jobs) {
57
+ const duration = j.duration === undefined || j.duration === null ? '-' : `${j.duration}s`;
58
+ const line = [
59
+ String(j.id).padEnd(colWidths[0] ?? 0),
60
+ j.name.slice(0, (colWidths[1] ?? 0) - 1).padEnd(colWidths[1] ?? 0),
61
+ j.stage.slice(0, (colWidths[2] ?? 0) - 1).padEnd(colWidths[2] ?? 0),
62
+ j.status.padEnd(colWidths[3] ?? 0),
63
+ duration.padEnd(colWidths[4] ?? 0),
64
+ ];
65
+ this.log(' ' + line.join(' '));
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,8 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Portal extends Command {
3
+ static description: string;
4
+ static flags: {
5
+ port: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
6
+ };
7
+ run(): Promise<void>;
8
+ }
@@ -0,0 +1,139 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import { convertToModelMessages } from 'ai';
3
+ import cors from 'cors';
4
+ import express from 'express';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import open from 'open';
9
+ import { streamAgent } from '../lib/ai/agent.js';
10
+ import { configManager } from '../lib/config.js';
11
+ import { fetchAvailableProjects, fetchCarbonMetrics } from '../lib/measure/bigquery-service.js';
12
+ export default class Portal extends Command {
13
+ static description = 'Launch the DuoOps web portal';
14
+ static flags = {
15
+ port: Flags.integer({
16
+ char: 'p',
17
+ default: 3000,
18
+ description: 'Port to run the portal on',
19
+ }),
20
+ };
21
+ async run() {
22
+ const { flags } = await this.parse(Portal);
23
+ const app = express();
24
+ const { port } = flags;
25
+ app.use(cors());
26
+ app.use(express.json());
27
+ // API Routes – streaming chat (AI SDK UI protocol: tool steps, text stream)
28
+ /* eslint-disable max-depth -- stream consumer adds necessary nesting */
29
+ app.post('/api/chat', async (req, res) => {
30
+ try {
31
+ const { messages } = req.body;
32
+ if (!messages || !Array.isArray(messages)) {
33
+ res.status(400).json({ error: 'Messages array is required' });
34
+ return;
35
+ }
36
+ const modelMessages = await convertToModelMessages(messages);
37
+ const result = streamAgent(modelMessages);
38
+ const response = result.toUIMessageStreamResponse();
39
+ res.status(response.status);
40
+ for (const [k, v] of response.headers.entries())
41
+ res.setHeader(k, v);
42
+ if (response.body) {
43
+ const reader = response.body.getReader();
44
+ try {
45
+ /* eslint-disable no-await-in-loop -- stream consumer requires await in loop */
46
+ for (;;) {
47
+ const { done, value } = await reader.read();
48
+ if (done)
49
+ break;
50
+ res.write(Buffer.from(value));
51
+ }
52
+ /* eslint-enable no-await-in-loop */
53
+ }
54
+ finally {
55
+ reader.releaseLock();
56
+ }
57
+ }
58
+ res.end();
59
+ }
60
+ catch (error) {
61
+ this.error(String(error), { exit: false });
62
+ res.status(500).json({ error: String(error) });
63
+ }
64
+ });
65
+ /* eslint-enable max-depth */
66
+ app.get('/api/projects', async (req, res) => {
67
+ try {
68
+ const projects = await fetchAvailableProjects();
69
+ res.json(projects);
70
+ }
71
+ catch (error) {
72
+ if (String(error).includes('not configured')) {
73
+ res.json([]);
74
+ return;
75
+ }
76
+ this.error(String(error), { exit: false });
77
+ res.status(500).json({ error: String(error) });
78
+ }
79
+ });
80
+ app.get('/api/metrics', async (req, res) => {
81
+ try {
82
+ const { projectId } = req.query;
83
+ if (!projectId) {
84
+ res.status(400).json({ error: 'Project ID is required' });
85
+ return;
86
+ }
87
+ const metrics = await fetchCarbonMetrics(String(projectId), 50);
88
+ res.json(metrics);
89
+ }
90
+ catch (error) {
91
+ // If not configured, return empty array instead of crashing UI
92
+ if (String(error).includes('not configured')) {
93
+ res.json([]);
94
+ return;
95
+ }
96
+ this.error(String(error), { exit: false });
97
+ res.status(500).json({ error: String(error) });
98
+ }
99
+ });
100
+ app.get('/api/status', (req, res) => {
101
+ const config = configManager.get();
102
+ const aiProvider = process.env.DUOOPS_AI_PROVIDER || 'gitlab';
103
+ const bigqueryActive = Boolean(config.measure?.bigqueryDataset && config.measure?.bigqueryTable);
104
+ res.json({
105
+ aiProvider,
106
+ bigqueryActive,
107
+ });
108
+ });
109
+ // Serve Static Files (Frontend)
110
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
111
+ // Try resolving relative to the compiled command file (dist/commands/portal.js)
112
+ // We expect the build script to copy portal/dist to dist/portal
113
+ let staticPath = path.resolve(__dirname, '../portal');
114
+ // Fallback for development (running from source)
115
+ if (!fs.existsSync(staticPath)) {
116
+ staticPath = path.resolve(process.cwd(), 'portal/dist');
117
+ }
118
+ if (!fs.existsSync(staticPath)) {
119
+ this.warn(`Could not find portal frontend at ${staticPath}. Did you run 'pnpm build'?`);
120
+ }
121
+ app.use(express.static(staticPath));
122
+ // SPA Fallback - using simple middleware for Express 5 compat
123
+ app.use((req, res, next) => {
124
+ // Only serve index.html for GET requests that are NOT API calls
125
+ // and accept HTML (browser navigation)
126
+ if (req.method === 'GET' && !req.path.startsWith('/api') && req.accepts('html')) {
127
+ res.sendFile(path.join(staticPath, 'index.html'));
128
+ }
129
+ else {
130
+ next();
131
+ }
132
+ });
133
+ app.listen(port, async () => {
134
+ const url = `http://localhost:${port}`;
135
+ this.log(`🚀 DuoOps Portal running at ${url}`);
136
+ await open(url);
137
+ });
138
+ }
139
+ }
@@ -0,0 +1,5 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Undo extends Command {
3
+ static description: string;
4
+ run(): Promise<void>;
5
+ }
@@ -0,0 +1,35 @@
1
+ import { Command } from '@oclif/core';
2
+ import { exec } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { getLatestPatch, PATCHES_DIR } from '../lib/state.js';
7
+ const execAsync = promisify(exec);
8
+ export default class Undo extends Command {
9
+ static description = 'Undo the last edit performed by DuoOps';
10
+ async run() {
11
+ const latestPatch = getLatestPatch();
12
+ if (!latestPatch) {
13
+ this.log('No recent patches found to undo.');
14
+ return;
15
+ }
16
+ this.log(`Undoing changes from ${path.basename(latestPatch)}...`);
17
+ try {
18
+ // Reverse apply the patch
19
+ // -R = reverse
20
+ // --allow-empty = in case file state is weird, but usually not needed
21
+ await execAsync(`git apply -R "${latestPatch}"`);
22
+ // Move patch to 'reverted' folder for audit
23
+ const revertedDir = path.join(PATCHES_DIR, 'reverted');
24
+ if (!fs.existsSync(revertedDir)) {
25
+ fs.mkdirSync(revertedDir, { recursive: true });
26
+ }
27
+ const destPath = path.join(revertedDir, path.basename(latestPatch));
28
+ fs.renameSync(latestPatch, destPath);
29
+ this.log('Successfully reverted changes.');
30
+ }
31
+ catch (error) {
32
+ this.error(`Failed to undo changes: ${error instanceof Error ? error.message : String(error)}`);
33
+ }
34
+ }
35
+ }
@@ -0,0 +1 @@
1
+ export { run } from '@oclif/core';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { run } from '@oclif/core';