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.
- package/LICENSE +22 -0
- package/README.md +181 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +7 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +8 -0
- package/dist/commands/act.d.ts +12 -0
- package/dist/commands/act.js +61 -0
- package/dist/commands/ask.d.ts +8 -0
- package/dist/commands/ask.js +22 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +97 -0
- package/dist/commands/job/logs.d.ts +13 -0
- package/dist/commands/job/logs.js +26 -0
- package/dist/commands/measure/calculate.d.ts +19 -0
- package/dist/commands/measure/calculate.js +208 -0
- package/dist/commands/measure/component.d.ts +5 -0
- package/dist/commands/measure/component.js +23 -0
- package/dist/commands/measure/seed.d.ts +5 -0
- package/dist/commands/measure/seed.js +62 -0
- package/dist/commands/pipelines/list.d.ts +14 -0
- package/dist/commands/pipelines/list.js +62 -0
- package/dist/commands/pipelines/show.d.ts +13 -0
- package/dist/commands/pipelines/show.js +68 -0
- package/dist/commands/portal.d.ts +8 -0
- package/dist/commands/portal.js +139 -0
- package/dist/commands/undo.d.ts +5 -0
- package/dist/commands/undo.js +35 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/ai/agent.d.ts +6 -0
- package/dist/lib/ai/agent.js +139 -0
- package/dist/lib/ai/model.d.ts +2 -0
- package/dist/lib/ai/model.js +22 -0
- package/dist/lib/ai/tools/editing.d.ts +3 -0
- package/dist/lib/ai/tools/editing.js +61 -0
- package/dist/lib/ai/tools/filesystem.d.ts +4 -0
- package/dist/lib/ai/tools/filesystem.js +44 -0
- package/dist/lib/ai/tools/gitlab.d.ts +4 -0
- package/dist/lib/ai/tools/gitlab.js +81 -0
- package/dist/lib/ai/tools/measure.d.ts +3 -0
- package/dist/lib/ai/tools/measure.js +26 -0
- package/dist/lib/config.d.ts +18 -0
- package/dist/lib/config.js +72 -0
- package/dist/lib/gitlab/client.d.ts +6 -0
- package/dist/lib/gitlab/client.js +18 -0
- package/dist/lib/gitlab/index.d.ts +6 -0
- package/dist/lib/gitlab/index.js +49 -0
- package/dist/lib/gitlab/provider.d.ts +14 -0
- package/dist/lib/gitlab/provider.js +72 -0
- package/dist/lib/gitlab/types.d.ts +34 -0
- package/dist/lib/gitlab/types.js +5 -0
- package/dist/lib/integrations/bigquery-sink.d.ts +12 -0
- package/dist/lib/integrations/bigquery-sink.js +47 -0
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +11 -0
- package/dist/lib/measure/bigquery-service.d.ts +2 -0
- package/dist/lib/measure/bigquery-service.js +54 -0
- package/dist/lib/measure/carbon-calculator.d.ts +13 -0
- package/dist/lib/measure/carbon-calculator.js +125 -0
- package/dist/lib/measure/cli-utils.d.ts +2 -0
- package/dist/lib/measure/cli-utils.js +107 -0
- package/dist/lib/measure/intensity-provider.d.ts +6 -0
- package/dist/lib/measure/intensity-provider.js +34 -0
- package/dist/lib/measure/power-profile-repository.d.ts +19 -0
- package/dist/lib/measure/power-profile-repository.js +129 -0
- package/dist/lib/measure/types.d.ts +137 -0
- package/dist/lib/measure/types.js +1 -0
- package/dist/lib/measure/zone-mapper.d.ts +16 -0
- package/dist/lib/measure/zone-mapper.js +104 -0
- package/dist/lib/state.d.ts +4 -0
- package/dist/lib/state.js +21 -0
- package/dist/portal/assets/index-BP8FwWqA.css +1 -0
- package/dist/portal/assets/index-MU6EBerh.js +188 -0
- package/dist/portal/duoops.svg +4 -0
- package/dist/portal/index.html +24 -0
- package/dist/portal/vite.svg +1 -0
- package/oclif.manifest.json +415 -0
- 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,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,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,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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run } from '@oclif/core';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run } from '@oclif/core';
|