duoops 0.2.8 → 0.2.13
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/dist/commands/mcp/deploy.js +3 -1
- package/dist/lib/gcloud.d.ts +1 -0
- package/dist/lib/gcloud.js +28 -0
- package/mcp-server/src/auth.ts +16 -8
- package/mcp-server/src/index.ts +28 -10
- package/mcp-server/src/lib/bigquery.ts +33 -56
- package/mcp-server/src/lib/types.ts +7 -6
- package/mcp-server/src/tools/emissions.ts +40 -38
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { configManager } from '../../lib/config.js';
|
|
9
|
-
import { detectGcpProject, enableApis, ensureCloudBuildServiceAccount, getActiveAccount, requireGcloud, validateProjectAccess } from '../../lib/gcloud.js';
|
|
9
|
+
import { detectGcpProject, enableApis, ensureBigQueryAccess, ensureCloudBuildServiceAccount, getActiveAccount, requireGcloud, validateProjectAccess } from '../../lib/gcloud.js';
|
|
10
10
|
export default class McpDeploy extends Command {
|
|
11
11
|
static description = 'Deploy the DuoOps MCP server to Google Cloud Run';
|
|
12
12
|
static flags = {
|
|
@@ -75,6 +75,8 @@ export default class McpDeploy extends Command {
|
|
|
75
75
|
}
|
|
76
76
|
this.log(gray('\nConfiguring Cloud Build service account...'));
|
|
77
77
|
ensureCloudBuildServiceAccount(gcpProject, (msg) => this.log(gray(msg)));
|
|
78
|
+
this.log(gray('\nConfiguring BigQuery access for MCP server...'));
|
|
79
|
+
ensureBigQueryAccess(gcpProject, (msg) => this.log(gray(msg)));
|
|
78
80
|
const mcpServerDir = flags.source || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../mcp-server');
|
|
79
81
|
if (!fs.existsSync(path.join(mcpServerDir, 'Dockerfile'))) {
|
|
80
82
|
this.error(`MCP server source not found at ${mcpServerDir}. Provide --source flag or run from the DuoOps repo.`);
|
package/dist/lib/gcloud.d.ts
CHANGED
|
@@ -4,4 +4,5 @@ export declare function detectGcpProject(): string | undefined;
|
|
|
4
4
|
export declare function validateProjectAccess(project: string): void;
|
|
5
5
|
export declare function enableApis(project: string, apis: string[], log: (msg: string) => void): void;
|
|
6
6
|
export declare function getProjectNumber(project: string): string | undefined;
|
|
7
|
+
export declare function ensureBigQueryAccess(project: string, log: (msg: string) => void): void;
|
|
7
8
|
export declare function ensureCloudBuildServiceAccount(project: string, log: (msg: string) => void): void;
|
package/dist/lib/gcloud.js
CHANGED
|
@@ -75,6 +75,34 @@ export function getProjectNumber(project) {
|
|
|
75
75
|
return undefined;
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
export function ensureBigQueryAccess(project, log) {
|
|
79
|
+
const projectNumber = getProjectNumber(project);
|
|
80
|
+
if (!projectNumber) {
|
|
81
|
+
log(' ? Could not determine project number, skipping BigQuery permissions');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const computeSa = `${projectNumber}-compute@developer.gserviceaccount.com`;
|
|
85
|
+
const roles = [
|
|
86
|
+
'roles/bigquery.user',
|
|
87
|
+
'roles/bigquery.dataViewer',
|
|
88
|
+
];
|
|
89
|
+
log(` Granting BigQuery permissions to ${computeSa}...`);
|
|
90
|
+
for (const role of roles) {
|
|
91
|
+
try {
|
|
92
|
+
execSync(`gcloud projects add-iam-policy-binding ${project} --member="serviceAccount:${computeSa}" --role="${role}" --quiet`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
93
|
+
log(` ✓ ${role}`);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
const stderr = error.stderr || '';
|
|
97
|
+
if (stderr.includes('PERMISSION_DENIED')) {
|
|
98
|
+
log(` ? ${role} — no permission to grant`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
log(` ? ${role} — failed: ${stderr.trim().split('\n')[0]}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
78
106
|
export function ensureCloudBuildServiceAccount(project, log) {
|
|
79
107
|
const projectNumber = getProjectNumber(project);
|
|
80
108
|
if (!projectNumber) {
|
package/mcp-server/src/auth.ts
CHANGED
|
@@ -5,17 +5,25 @@ import {NextFunction, Request, Response} from 'express'
|
|
|
5
5
|
export const GITLAB_URL = process.env.GITLAB_URL || 'https://gitlab.com'
|
|
6
6
|
|
|
7
7
|
export async function validateGitLabToken(token: string): Promise<boolean> {
|
|
8
|
+
try {
|
|
9
|
+
const response = await axios.get(`${GITLAB_URL}/oauth/token/info`, {
|
|
10
|
+
headers: {Authorization: `Bearer ${token}`},
|
|
11
|
+
validateStatus: (status) => status < 500,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
if (response.status === 200) return true
|
|
15
|
+
} catch {/* ignore */}
|
|
16
|
+
|
|
8
17
|
try {
|
|
9
18
|
const response = await axios.get(`${GITLAB_URL}/api/v4/user`, {
|
|
10
|
-
headers: {
|
|
11
|
-
|
|
12
|
-
},
|
|
19
|
+
headers: {Authorization: `Bearer ${token}`},
|
|
20
|
+
validateStatus: (status) => status < 500,
|
|
13
21
|
})
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
|
|
23
|
+
if (response.status === 200) return true
|
|
24
|
+
} catch {/* ignore */}
|
|
25
|
+
|
|
26
|
+
return false
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
package/mcp-server/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
2
|
import {SSEServerTransport} from '@modelcontextprotocol/sdk/server/sse.js'
|
|
3
|
+
import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
3
4
|
import express from 'express'
|
|
4
5
|
|
|
5
6
|
import {authMiddleware, oauthEndpoints} from './auth.js'
|
|
@@ -12,7 +13,6 @@ async function main() {
|
|
|
12
13
|
version: '1.0.0',
|
|
13
14
|
})
|
|
14
15
|
|
|
15
|
-
// Register tools
|
|
16
16
|
registerEmissionsTools(server)
|
|
17
17
|
registerProfileTools(server)
|
|
18
18
|
|
|
@@ -24,30 +24,48 @@ async function main() {
|
|
|
24
24
|
|
|
25
25
|
oauthEndpoints(app)
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
let transport: SSEServerTransport
|
|
27
|
+
let sseTransport: SSEServerTransport
|
|
29
28
|
|
|
30
29
|
app.get('/sse', authMiddleware, async (req, res) => {
|
|
31
|
-
|
|
32
|
-
await server.connect(
|
|
30
|
+
sseTransport = new SSEServerTransport('/message', res)
|
|
31
|
+
await server.connect(sseTransport)
|
|
33
32
|
})
|
|
34
33
|
|
|
35
34
|
app.post('/message', authMiddleware, async (req, res) => {
|
|
36
|
-
if (!
|
|
35
|
+
if (!sseTransport) {
|
|
37
36
|
res.sendStatus(400)
|
|
38
37
|
return
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
await
|
|
40
|
+
await sseTransport.handlePostMessage(req, res)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
app.post('/mcp', authMiddleware, async (req, res) => {
|
|
44
|
+
const transport = new StreamableHTTPServerTransport({sessionIdGenerator: undefined})
|
|
45
|
+
res.on('close', () => {
|
|
46
|
+
transport.close()
|
|
47
|
+
})
|
|
48
|
+
await server.connect(transport)
|
|
49
|
+
await transport.handleRequest(req, res)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
app.get('/mcp', authMiddleware, async (req, res) => {
|
|
53
|
+
const transport = new StreamableHTTPServerTransport({sessionIdGenerator: undefined})
|
|
54
|
+
res.on('close', () => {
|
|
55
|
+
transport.close()
|
|
56
|
+
})
|
|
57
|
+
await server.connect(transport)
|
|
58
|
+
await transport.handleRequest(req, res)
|
|
42
59
|
})
|
|
43
60
|
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
app.delete('/mcp', async (_req, res) => {
|
|
62
|
+
res.status(405).send('Session termination not supported')
|
|
63
|
+
})
|
|
46
64
|
|
|
47
65
|
const port = process.env.PORT || 8080
|
|
48
66
|
app.listen(port, () => {
|
|
49
67
|
console.log(`DuoOps MCP Server running on port ${port}`)
|
|
50
|
-
console.log(`
|
|
68
|
+
console.log(`Transports: SSE at /sse, HTTP Streamable at /mcp`)
|
|
51
69
|
})
|
|
52
70
|
}
|
|
53
71
|
|
|
@@ -23,18 +23,9 @@ export class BigQueryService {
|
|
|
23
23
|
|
|
24
24
|
async getJobEmissions(jobId: number, projectId?: number): Promise<EmissionsRecord | null> {
|
|
25
25
|
const query = `
|
|
26
|
-
SELECT
|
|
27
|
-
job_id,
|
|
28
|
-
pipeline_id,
|
|
29
|
-
gitlab_project_id as project_id,
|
|
30
|
-
machine_type,
|
|
31
|
-
region,
|
|
32
|
-
total_emissions,
|
|
33
|
-
(cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
|
|
34
|
-
created_at,
|
|
35
|
-
runtime_seconds
|
|
26
|
+
SELECT *
|
|
36
27
|
FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
|
|
37
|
-
WHERE
|
|
28
|
+
WHERE gitlab_job_id = @jobId
|
|
38
29
|
${projectId ? 'AND gitlab_project_id = @projectId' : ''}
|
|
39
30
|
LIMIT 1
|
|
40
31
|
`
|
|
@@ -48,75 +39,61 @@ export class BigQueryService {
|
|
|
48
39
|
return rows.length > 0 ? (rows[0] as EmissionsRecord) : null
|
|
49
40
|
}
|
|
50
41
|
|
|
51
|
-
async
|
|
42
|
+
async getProjectEmissionsSummary(projectId: number, limit: number = 10): Promise<EmissionsRecord[]> {
|
|
43
|
+
const query = `
|
|
44
|
+
SELECT *
|
|
45
|
+
FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
|
|
46
|
+
WHERE gitlab_project_id = @projectId
|
|
47
|
+
ORDER BY ingested_at DESC
|
|
48
|
+
LIMIT @limit
|
|
49
|
+
`
|
|
50
|
+
|
|
51
|
+
const options = {
|
|
52
|
+
params: {limit, projectId},
|
|
53
|
+
query,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const [rows] = await this.bigquery.query(options)
|
|
57
|
+
return rows as EmissionsRecord[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async getProjectJobEmissions(projectId: number, limit: number = 20): Promise<{
|
|
52
61
|
jobs: Partial<EmissionsRecord>[];
|
|
53
|
-
pipeline_id: number;
|
|
54
62
|
total_emissions_g: number;
|
|
55
63
|
total_energy_kwh: number;
|
|
56
64
|
}> {
|
|
57
65
|
const query = `
|
|
58
|
-
SELECT
|
|
59
|
-
job_id,
|
|
60
|
-
machine_type,
|
|
61
|
-
total_emissions,
|
|
62
|
-
(cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
|
|
63
|
-
runtime_seconds
|
|
66
|
+
SELECT *
|
|
64
67
|
FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
|
|
65
|
-
WHERE
|
|
66
|
-
|
|
68
|
+
WHERE gitlab_project_id = @projectId
|
|
69
|
+
ORDER BY ingested_at DESC
|
|
70
|
+
LIMIT @limit
|
|
67
71
|
`
|
|
68
72
|
|
|
69
73
|
const options = {
|
|
70
|
-
params: {
|
|
74
|
+
params: {limit, projectId},
|
|
71
75
|
query,
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
const [rows] = await this.bigquery.query(options)
|
|
75
|
-
|
|
79
|
+
|
|
76
80
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
77
|
-
const totalEmissions = rows.reduce((sum: number, row: any) => sum + (row.
|
|
81
|
+
const totalEmissions = rows.reduce((sum: number, row: any) => sum + (row.total_emissions_g || 0), 0)
|
|
78
82
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
-
const totalEnergy = rows.reduce((sum: number, row: any) => sum + (row.
|
|
83
|
+
const totalEnergy = rows.reduce((sum: number, row: any) => sum + (row.energy_kwh || 0), 0)
|
|
80
84
|
|
|
81
85
|
return {
|
|
82
86
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
87
|
jobs: rows.map((r: any) => ({
|
|
84
|
-
|
|
88
|
+
energy_kwh: r.energy_kwh,
|
|
89
|
+
gitlab_job_id: r.gitlab_job_id,
|
|
90
|
+
gitlab_job_name: r.gitlab_job_name,
|
|
85
91
|
machine_type: r.machine_type,
|
|
86
92
|
runtime_seconds: r.runtime_seconds,
|
|
87
|
-
|
|
88
|
-
total_energy_kwh: r.total_energy_kwh
|
|
93
|
+
total_emissions_g: r.total_emissions_g,
|
|
89
94
|
})),
|
|
90
|
-
pipeline_id: pipelineId,
|
|
91
95
|
total_emissions_g: totalEmissions,
|
|
92
96
|
total_energy_kwh: totalEnergy
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
|
-
|
|
96
|
-
async getProjectEmissionsSummary(projectId: number, limit: number = 10): Promise<EmissionsRecord[]> {
|
|
97
|
-
const query = `
|
|
98
|
-
SELECT
|
|
99
|
-
job_id,
|
|
100
|
-
pipeline_id,
|
|
101
|
-
gitlab_project_id as project_id,
|
|
102
|
-
machine_type,
|
|
103
|
-
region,
|
|
104
|
-
total_emissions,
|
|
105
|
-
(cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
|
|
106
|
-
created_at,
|
|
107
|
-
runtime_seconds
|
|
108
|
-
FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
|
|
109
|
-
WHERE gitlab_project_id = @projectId
|
|
110
|
-
ORDER BY created_at DESC
|
|
111
|
-
LIMIT @limit
|
|
112
|
-
`
|
|
113
|
-
|
|
114
|
-
const options = {
|
|
115
|
-
params: {limit, projectId},
|
|
116
|
-
query,
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const [rows] = await this.bigquery.query(options)
|
|
120
|
-
return rows as EmissionsRecord[]
|
|
121
|
-
}
|
|
122
99
|
}
|
|
@@ -54,13 +54,14 @@ export interface CarbonResult {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export interface EmissionsRecord {
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
energy_kwh: number;
|
|
58
|
+
gitlab_job_id: number;
|
|
59
|
+
gitlab_job_name: string;
|
|
60
|
+
gitlab_project_id: number;
|
|
61
|
+
gitlab_user_name: string;
|
|
62
|
+
ingested_at: string;
|
|
59
63
|
machine_type: string;
|
|
60
|
-
pipeline_id: number;
|
|
61
|
-
project_id: number;
|
|
62
64
|
region: string;
|
|
63
65
|
runtime_seconds: number;
|
|
64
|
-
|
|
65
|
-
total_energy_kwh: number;
|
|
66
|
+
total_emissions_g: number;
|
|
66
67
|
}
|
|
@@ -15,7 +15,7 @@ export function registerEmissionsTools(server: McpServer) {
|
|
|
15
15
|
},
|
|
16
16
|
async ({job_id, project_id}) => {
|
|
17
17
|
const emissions = await bq.getJobEmissions(job_id, project_id)
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
if (!emissions) {
|
|
20
20
|
return {
|
|
21
21
|
content: [{text: `No emissions data found for job ${job_id}`, type: 'text'}],
|
|
@@ -33,74 +33,76 @@ export function registerEmissionsTools(server: McpServer) {
|
|
|
33
33
|
)
|
|
34
34
|
|
|
35
35
|
server.tool(
|
|
36
|
-
'
|
|
36
|
+
'get_project_emissions_summary',
|
|
37
37
|
{
|
|
38
|
-
|
|
39
|
-
project_id: z.number().
|
|
38
|
+
limit: z.number().optional().default(10).describe('Number of recent jobs to return'),
|
|
39
|
+
project_id: z.number().describe('GitLab Project ID'),
|
|
40
40
|
},
|
|
41
|
-
async ({
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
if (
|
|
41
|
+
async ({limit, project_id}) => {
|
|
42
|
+
const records = await bq.getProjectEmissionsSummary(project_id, limit)
|
|
43
|
+
|
|
44
|
+
if (records.length === 0) {
|
|
45
45
|
return {
|
|
46
|
-
content: [{text: `No emissions
|
|
46
|
+
content: [{text: `No emissions history found for project ${project_id}. The measure CI component may not have run yet.`, type: 'text'}],
|
|
47
47
|
isError: true,
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
const totalEmissions = records.reduce((sum, r) => sum + r.total_emissions_g, 0)
|
|
52
|
+
const avgEmissions = totalEmissions / records.length
|
|
53
|
+
const totalEnergy = records.reduce((sum, r) => sum + r.energy_kwh, 0)
|
|
54
|
+
|
|
51
55
|
const summary = `
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
Total
|
|
55
|
-
|
|
56
|
+
Project ${project_id} Emissions Summary (Last ${records.length} jobs):
|
|
57
|
+
Average per job: ${avgEmissions.toFixed(3)} gCO2e
|
|
58
|
+
Total recent: ${totalEmissions.toFixed(3)} gCO2e
|
|
59
|
+
Total energy: ${totalEnergy.toFixed(4)} kWh
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
${
|
|
59
|
-
.sort((a, b) => (b.total_emissions || 0) - (a.total_emissions || 0))
|
|
60
|
-
.slice(0, 3)
|
|
61
|
-
.map(j => `- Job ${j.job_id} (${j.machine_type}): ${j.total_emissions?.toFixed(3)} gCO2e`)
|
|
62
|
-
.join('\n')}
|
|
61
|
+
Recent Jobs:
|
|
62
|
+
${records.map(r => `- ${r.ingested_at} | ${r.gitlab_job_name} (${r.machine_type}): ${r.total_emissions_g.toFixed(3)} gCO2e, ${r.energy_kwh.toFixed(4)} kWh, ${r.runtime_seconds.toFixed(0)}s`).join('\n')}
|
|
63
63
|
`
|
|
64
64
|
|
|
65
65
|
return {
|
|
66
|
-
content: [
|
|
67
|
-
{text: summary, type: 'text'},
|
|
68
|
-
{text: JSON.stringify(result, null, 2), type: 'text'} // Full JSON also provided
|
|
69
|
-
]
|
|
66
|
+
content: [{text: summary, type: 'text'}]
|
|
70
67
|
}
|
|
71
68
|
}
|
|
72
69
|
)
|
|
73
70
|
|
|
74
71
|
server.tool(
|
|
75
|
-
'
|
|
72
|
+
'get_project_job_emissions',
|
|
76
73
|
{
|
|
77
|
-
limit: z.number().optional().default(
|
|
74
|
+
limit: z.number().optional().default(20).describe('Number of recent jobs to return'),
|
|
78
75
|
project_id: z.number().describe('GitLab Project ID'),
|
|
79
76
|
},
|
|
80
77
|
async ({limit, project_id}) => {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
if (
|
|
78
|
+
const result = await bq.getProjectJobEmissions(project_id, limit)
|
|
79
|
+
|
|
80
|
+
if (result.jobs.length === 0) {
|
|
84
81
|
return {
|
|
85
|
-
content: [{text: `No emissions
|
|
82
|
+
content: [{text: `No emissions data found for project ${project_id}`, type: 'text'}],
|
|
86
83
|
isError: true,
|
|
87
84
|
}
|
|
88
85
|
}
|
|
89
86
|
|
|
90
|
-
const totalEmissions = records.reduce((sum, r) => sum + r.total_emissions, 0)
|
|
91
|
-
const avgEmissions = totalEmissions / records.length
|
|
92
|
-
|
|
93
87
|
const summary = `
|
|
94
|
-
Project ${project_id}
|
|
95
|
-
|
|
96
|
-
Total
|
|
88
|
+
Project ${project_id} Carbon Report:
|
|
89
|
+
Total Emissions: ${result.total_emissions_g.toFixed(3)} gCO2e
|
|
90
|
+
Total Energy: ${result.total_energy_kwh.toFixed(4)} kWh
|
|
91
|
+
Jobs Recorded: ${result.jobs.length}
|
|
97
92
|
|
|
98
|
-
|
|
99
|
-
${
|
|
93
|
+
Top Emitters:
|
|
94
|
+
${result.jobs
|
|
95
|
+
.sort((a, b) => (b.total_emissions_g || 0) - (a.total_emissions_g || 0))
|
|
96
|
+
.slice(0, 5)
|
|
97
|
+
.map(j => `- Job ${j.gitlab_job_id} ${j.gitlab_job_name} (${j.machine_type}): ${j.total_emissions_g?.toFixed(3)} gCO2e`)
|
|
98
|
+
.join('\n')}
|
|
100
99
|
`
|
|
101
100
|
|
|
102
101
|
return {
|
|
103
|
-
content: [
|
|
102
|
+
content: [
|
|
103
|
+
{text: summary, type: 'text'},
|
|
104
|
+
{text: JSON.stringify(result, null, 2), type: 'text'}
|
|
105
|
+
]
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
)
|
package/oclif.manifest.json
CHANGED