edsger 0.60.0 → 0.62.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/dist/auth/env-store.js +3 -0
- package/dist/commands/data-flow/index.d.ts +17 -0
- package/dist/commands/data-flow/index.js +46 -0
- package/dist/commands/screen-flow/index.d.ts +4 -4
- package/dist/commands/screen-flow/index.js +5 -5
- package/dist/commands/sync-aws/index.d.ts +16 -0
- package/dist/commands/sync-aws/index.js +184 -0
- package/dist/commands/sync-datadog/index.d.ts +16 -0
- package/dist/commands/sync-datadog/index.js +199 -0
- package/dist/commands/sync-org-repos/index.d.ts +11 -0
- package/dist/commands/sync-org-repos/index.js +59 -0
- package/dist/commands/sync-terraform/index.d.ts +16 -0
- package/dist/commands/sync-terraform/index.js +211 -0
- package/dist/index.js +111 -2
- package/dist/phases/data-flow/index.d.ts +25 -0
- package/dist/phases/data-flow/index.js +257 -0
- package/dist/phases/data-flow/mcp-server.d.ts +85 -0
- package/dist/phases/data-flow/mcp-server.js +140 -0
- package/dist/phases/data-flow/prompts.d.ts +14 -0
- package/dist/phases/data-flow/prompts.js +36 -0
- package/dist/phases/data-flow/types.d.ts +71 -0
- package/dist/phases/data-flow/types.js +86 -0
- package/dist/phases/output-contracts.js +71 -0
- package/dist/phases/screen-flow/index.d.ts +2 -2
- package/dist/phases/screen-flow/index.js +27 -15
- package/dist/phases/screen-flow/mcp-server.d.ts +1 -1
- package/dist/phases/sync-org-repos/index.d.ts +24 -0
- package/dist/phases/sync-org-repos/index.js +143 -0
- package/dist/skills/phase/data-flow/SKILL.md +82 -0
- package/package.json +3 -3
package/dist/auth/env-store.js
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: edsger data-flow <productId> --flow-id <id>
|
|
3
|
+
*
|
|
4
|
+
* Maps the product's data nodes (source/dataset/transform/sink/queue/model)
|
|
5
|
+
* and the connections between them into a structured flow stored in
|
|
6
|
+
* flows / flow_nodes / flow_edges (rows tagged type='data').
|
|
7
|
+
*
|
|
8
|
+
* The desktop UI creates a pending flows row first, then invokes the CLI
|
|
9
|
+
* with --flow-id; the CLI flips status running → success/failed and
|
|
10
|
+
* populates the nodes/edges tables.
|
|
11
|
+
*/
|
|
12
|
+
export interface DataFlowCliOptions {
|
|
13
|
+
flowId: string;
|
|
14
|
+
guidance?: string;
|
|
15
|
+
verbose?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function runDataFlow(productId: string, options: DataFlowCliOptions): Promise<void>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: edsger data-flow <productId> --flow-id <id>
|
|
3
|
+
*
|
|
4
|
+
* Maps the product's data nodes (source/dataset/transform/sink/queue/model)
|
|
5
|
+
* and the connections between them into a structured flow stored in
|
|
6
|
+
* flows / flow_nodes / flow_edges (rows tagged type='data').
|
|
7
|
+
*
|
|
8
|
+
* The desktop UI creates a pending flows row first, then invokes the CLI
|
|
9
|
+
* with --flow-id; the CLI flips status running → success/failed and
|
|
10
|
+
* populates the nodes/edges tables.
|
|
11
|
+
*/
|
|
12
|
+
import { runDataFlowPhase } from '../../phases/data-flow/index.js';
|
|
13
|
+
import { deregisterSession, registerSession, } from '../../system/session-manager.js';
|
|
14
|
+
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
15
|
+
export async function runDataFlow(productId, options) {
|
|
16
|
+
const { flowId, guidance, verbose } = options;
|
|
17
|
+
if (!productId) {
|
|
18
|
+
throw new Error('Product ID is required for data-flow');
|
|
19
|
+
}
|
|
20
|
+
if (!flowId) {
|
|
21
|
+
throw new Error('--flow-id is required (the pending flows row id)');
|
|
22
|
+
}
|
|
23
|
+
await registerSession({ command: 'data-flow', productId });
|
|
24
|
+
logInfo(`Starting data flow generation for product ${productId}`);
|
|
25
|
+
try {
|
|
26
|
+
const result = await runDataFlowPhase({
|
|
27
|
+
productId,
|
|
28
|
+
flowId,
|
|
29
|
+
guidance,
|
|
30
|
+
verbose,
|
|
31
|
+
});
|
|
32
|
+
if (result.status === 'success') {
|
|
33
|
+
logSuccess(result.message);
|
|
34
|
+
if (result.summary) {
|
|
35
|
+
logInfo(`\nSummary: ${result.summary}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
logError(result.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await deregisterSession();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* CLI command: edsger screen-flow <productId> --flow-id <id>
|
|
3
3
|
*
|
|
4
4
|
* Maps the product's user-facing screens and transitions into a structured
|
|
5
|
-
* flow stored in
|
|
5
|
+
* flow stored in flows / flow_nodes / flow_edges (rows tagged type='screen').
|
|
6
6
|
*
|
|
7
|
-
* The desktop UI creates a pending
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* The desktop UI creates a pending flows row first, then invokes the CLI
|
|
8
|
+
* with --flow-id; the CLI flips status running → success/failed and
|
|
9
|
+
* populates the nodes/edges tables.
|
|
10
10
|
*/
|
|
11
11
|
export interface ScreenFlowCliOptions {
|
|
12
12
|
flowId: string;
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* CLI command: edsger screen-flow <productId> --flow-id <id>
|
|
3
3
|
*
|
|
4
4
|
* Maps the product's user-facing screens and transitions into a structured
|
|
5
|
-
* flow stored in
|
|
5
|
+
* flow stored in flows / flow_nodes / flow_edges (rows tagged type='screen').
|
|
6
6
|
*
|
|
7
|
-
* The desktop UI creates a pending
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* The desktop UI creates a pending flows row first, then invokes the CLI
|
|
8
|
+
* with --flow-id; the CLI flips status running → success/failed and
|
|
9
|
+
* populates the nodes/edges tables.
|
|
10
10
|
*/
|
|
11
11
|
import { runScreenFlowPhase } from '../../phases/screen-flow/index.js';
|
|
12
12
|
import { deregisterSession, registerSession, } from '../../system/session-manager.js';
|
|
@@ -17,7 +17,7 @@ export async function runScreenFlow(productId, options) {
|
|
|
17
17
|
throw new Error('Product ID is required for screen-flow');
|
|
18
18
|
}
|
|
19
19
|
if (!flowId) {
|
|
20
|
-
throw new Error('--flow-id is required (the pending
|
|
20
|
+
throw new Error('--flow-id is required (the pending flows row id)');
|
|
21
21
|
}
|
|
22
22
|
await registerSession({ command: 'screen-flow', productId });
|
|
23
23
|
logInfo(`Starting screen flow generation for product ${productId}`);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: `edsger sync-aws <teamId>`
|
|
3
|
+
*
|
|
4
|
+
* Uses the standard AWS credential chain (env vars, ~/.aws/credentials,
|
|
5
|
+
* IAM role, etc.) to discover services by their `Service` tag and
|
|
6
|
+
* optionally pull cost data from Cost Explorer.
|
|
7
|
+
*
|
|
8
|
+
* Does NOT store AWS credentials — relies on whatever the user's shell
|
|
9
|
+
* already has configured (same as running `aws` CLI directly).
|
|
10
|
+
*/
|
|
11
|
+
export interface SyncAwsOptions {
|
|
12
|
+
verbose?: boolean;
|
|
13
|
+
region?: string;
|
|
14
|
+
noCost?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function runSyncAws(teamId: string, options?: SyncAwsOptions): Promise<void>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: `edsger sync-aws <teamId>`
|
|
3
|
+
*
|
|
4
|
+
* Uses the standard AWS credential chain (env vars, ~/.aws/credentials,
|
|
5
|
+
* IAM role, etc.) to discover services by their `Service` tag and
|
|
6
|
+
* optionally pull cost data from Cost Explorer.
|
|
7
|
+
*
|
|
8
|
+
* Does NOT store AWS credentials — relies on whatever the user's shell
|
|
9
|
+
* already has configured (same as running `aws` CLI directly).
|
|
10
|
+
*/
|
|
11
|
+
import { exec as execCb } from 'child_process';
|
|
12
|
+
import { promisify } from 'util';
|
|
13
|
+
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
14
|
+
import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
15
|
+
const exec = promisify(execCb);
|
|
16
|
+
async function runAwsCmd(args, region) {
|
|
17
|
+
const regionFlag = region ? ` --region ${region}` : '';
|
|
18
|
+
try {
|
|
19
|
+
const { stdout } = await exec(`aws ${args}${regionFlag} --output json`, {
|
|
20
|
+
timeout: 30_000,
|
|
21
|
+
});
|
|
22
|
+
return stdout;
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
const e = err;
|
|
26
|
+
throw new Error(e.stderr?.trim() || e.message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function checkAwsCli() {
|
|
30
|
+
try {
|
|
31
|
+
await exec('aws --version', { timeout: 5000 });
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function runSyncAws(teamId, options = {}) {
|
|
39
|
+
const { verbose, noCost } = options;
|
|
40
|
+
const region = options.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1';
|
|
41
|
+
logInfo(`Starting AWS sync for team ${teamId} (region: ${region})`);
|
|
42
|
+
if (!(await checkAwsCli())) {
|
|
43
|
+
logError('AWS CLI not found. Install it from https://aws.amazon.com/cli/ ' +
|
|
44
|
+
'and configure credentials with `aws configure`.');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
// Verify credentials work
|
|
48
|
+
try {
|
|
49
|
+
const identity = await runAwsCmd('sts get-caller-identity', region);
|
|
50
|
+
const parsed = JSON.parse(identity);
|
|
51
|
+
logInfo(`Authenticated as ${parsed.Arn} (account ${parsed.Account})`);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
logError(`AWS credentials not configured or invalid: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
55
|
+
'Run `aws configure` or export AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
// ── Phase 1: Resource discovery by Service tag ──
|
|
59
|
+
logInfo('Phase 1: Discovering resources by Service tag...');
|
|
60
|
+
let resources = [];
|
|
61
|
+
try {
|
|
62
|
+
let paginationToken;
|
|
63
|
+
do {
|
|
64
|
+
const tokenArg = paginationToken
|
|
65
|
+
? ` --pagination-token "${paginationToken}"`
|
|
66
|
+
: '';
|
|
67
|
+
const raw = await runAwsCmd(`resourcegroupstaggingapi get-resources --tag-filters Key=Service${tokenArg}`, region);
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
resources.push(...parsed.ResourceTagMappingList);
|
|
70
|
+
paginationToken = parsed.PaginationToken || undefined;
|
|
71
|
+
} while (paginationToken);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
logError(`Failed to query tagged resources: ${err instanceof Error ? err.message : String(err)}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
logInfo(`Found ${resources.length} tagged resources`);
|
|
78
|
+
// Group by Service tag value
|
|
79
|
+
const byService = new Map();
|
|
80
|
+
for (const res of resources) {
|
|
81
|
+
const serviceTag = res.Tags.find((t) => t.Key === 'Service')?.Value;
|
|
82
|
+
if (!serviceTag)
|
|
83
|
+
continue;
|
|
84
|
+
if (!byService.has(serviceTag))
|
|
85
|
+
byService.set(serviceTag, []);
|
|
86
|
+
byService.get(serviceTag).push(res);
|
|
87
|
+
}
|
|
88
|
+
logInfo(`Mapped to ${byService.size} distinct services`);
|
|
89
|
+
let created = 0;
|
|
90
|
+
let updated = 0;
|
|
91
|
+
let componentCount = 0;
|
|
92
|
+
for (const [serviceName, svcResources] of byService) {
|
|
93
|
+
const teamTag = svcResources[0]?.Tags.find((t) => t.Key === 'Team')?.Value;
|
|
94
|
+
// Tier signals
|
|
95
|
+
const hasMultiAz = svcResources.some((r) => r.ResourceARN.includes(':rds:') || r.ResourceARN.includes(':elasticache:'));
|
|
96
|
+
const hasLambda = svcResources.some((r) => r.ResourceARN.includes(':lambda:'));
|
|
97
|
+
const hasEcs = svcResources.some((r) => r.ResourceARN.includes(':ecs:'));
|
|
98
|
+
const tier = hasMultiAz || (hasEcs && svcResources.length > 3) ? 'critical' : 'standard';
|
|
99
|
+
const kind = hasLambda && !hasEcs ? 'job' : 'service';
|
|
100
|
+
if (verbose) {
|
|
101
|
+
logInfo(` ${serviceName}: ${svcResources.length} resources, tier=${tier}`);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const result = (await callMcpEndpoint('services.upsert_draft', {
|
|
105
|
+
team_id: teamId,
|
|
106
|
+
name: serviceName,
|
|
107
|
+
kind,
|
|
108
|
+
tier,
|
|
109
|
+
source: 'aws',
|
|
110
|
+
source_ref: `aws:${region}:${serviceName}`,
|
|
111
|
+
field_sources: {
|
|
112
|
+
name: { value: serviceName, confidence: 1.0, source: 'aws_tag', source_detail: 'Tag:Service' },
|
|
113
|
+
tier: { value: tier, confidence: hasMultiAz ? 0.8 : 0.5, source: 'aws_resource_analysis' },
|
|
114
|
+
...(teamTag ? { owner: { value: teamTag, confidence: 0.8, source: 'aws_tag', source_detail: 'Tag:Team' } } : {}),
|
|
115
|
+
},
|
|
116
|
+
needs_review: false,
|
|
117
|
+
}));
|
|
118
|
+
if (result.action === 'created')
|
|
119
|
+
created++;
|
|
120
|
+
else
|
|
121
|
+
updated++;
|
|
122
|
+
// Add resource components
|
|
123
|
+
for (const res of svcResources) {
|
|
124
|
+
const arnParts = res.ResourceARN.split(':');
|
|
125
|
+
const resourceType = arnParts[2] ?? 'unknown';
|
|
126
|
+
try {
|
|
127
|
+
await callMcpEndpoint('services.add_component', {
|
|
128
|
+
service_id: result.service.id,
|
|
129
|
+
kind: 'aws_resource',
|
|
130
|
+
provider: 'aws',
|
|
131
|
+
external_id: res.ResourceARN,
|
|
132
|
+
metadata: {
|
|
133
|
+
resource_type: resourceType,
|
|
134
|
+
region,
|
|
135
|
+
tags: Object.fromEntries(res.Tags.map((t) => [t.Key, t.Value])),
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
componentCount++;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// ignore duplicates
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
logWarning(`Failed to sync "${serviceName}": ${err instanceof Error ? err.message : String(err)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// ── Phase 2: Cost data (optional) ──
|
|
150
|
+
if (!noCost) {
|
|
151
|
+
logInfo('Phase 2: Fetching cost data from Cost Explorer...');
|
|
152
|
+
const now = new Date();
|
|
153
|
+
const endDate = now.toISOString().slice(0, 10);
|
|
154
|
+
const startDate = new Date(now.getTime() - 30 * 24 * 3600 * 1000)
|
|
155
|
+
.toISOString()
|
|
156
|
+
.slice(0, 10);
|
|
157
|
+
try {
|
|
158
|
+
const raw = await runAwsCmd(`ce get-cost-and-usage ` +
|
|
159
|
+
`--time-period Start=${startDate},End=${endDate} ` +
|
|
160
|
+
`--granularity MONTHLY ` +
|
|
161
|
+
`--metrics BlendedCost ` +
|
|
162
|
+
`--group-by Type=TAG,Key=Service`, region);
|
|
163
|
+
const parsed = JSON.parse(raw);
|
|
164
|
+
for (const period of parsed.ResultsByTime) {
|
|
165
|
+
for (const group of period.Groups) {
|
|
166
|
+
const tagValue = group.Keys[0]?.replace('Service$', '') ?? '';
|
|
167
|
+
if (!tagValue || tagValue === '')
|
|
168
|
+
continue;
|
|
169
|
+
const cost = parseFloat(group.Metrics.BlendedCost.Amount);
|
|
170
|
+
if (cost <= 0)
|
|
171
|
+
continue;
|
|
172
|
+
if (verbose) {
|
|
173
|
+
logInfo(` ${tagValue}: $${cost.toFixed(2)}/month`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
logSuccess('Cost data fetched (displayed above, not yet persisted to DB)');
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
logWarning(`Cost Explorer query failed (may need ce:GetCostAndUsage permission): ${err instanceof Error ? err.message : String(err)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
logSuccess(`AWS sync completed: ${created} created, ${updated} updated, ${componentCount} resource components from ${byService.size} services`);
|
|
184
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: `edsger sync-datadog <teamId>`
|
|
3
|
+
*
|
|
4
|
+
* Imports services from a Datadog Service Catalog into the edsger service
|
|
5
|
+
* catalog. Reads DD_API_KEY, DD_APP_KEY, DD_SITE from process.env
|
|
6
|
+
* (populated by `edsger config set` or desktop credentials).
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Fetch all service definitions from Datadog API
|
|
10
|
+
* 2. Map each to an edsger service (name, tier, owner, repos, dashboards)
|
|
11
|
+
* 3. Upsert via MCP endpoints (services.upsert_draft, services.add_component)
|
|
12
|
+
*/
|
|
13
|
+
export interface SyncDatadogOptions {
|
|
14
|
+
verbose?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function runSyncDatadog(teamId: string, options?: SyncDatadogOptions): Promise<void>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: `edsger sync-datadog <teamId>`
|
|
3
|
+
*
|
|
4
|
+
* Imports services from a Datadog Service Catalog into the edsger service
|
|
5
|
+
* catalog. Reads DD_API_KEY, DD_APP_KEY, DD_SITE from process.env
|
|
6
|
+
* (populated by `edsger config set` or desktop credentials).
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Fetch all service definitions from Datadog API
|
|
10
|
+
* 2. Map each to an edsger service (name, tier, owner, repos, dashboards)
|
|
11
|
+
* 3. Upsert via MCP endpoints (services.upsert_draft, services.add_component)
|
|
12
|
+
*/
|
|
13
|
+
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
14
|
+
import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
15
|
+
function readDatadogEnv() {
|
|
16
|
+
const apiKey = process.env.DD_API_KEY ?? '';
|
|
17
|
+
const appKey = process.env.DD_APP_KEY ?? '';
|
|
18
|
+
const site = process.env.DD_SITE ?? 'datadoghq.com';
|
|
19
|
+
const missing = [];
|
|
20
|
+
if (!apiKey)
|
|
21
|
+
missing.push('DD_API_KEY');
|
|
22
|
+
if (!appKey)
|
|
23
|
+
missing.push('DD_APP_KEY');
|
|
24
|
+
if (missing.length > 0)
|
|
25
|
+
return { ok: false, missing };
|
|
26
|
+
return { ok: true, env: { apiKey, appKey, site } };
|
|
27
|
+
}
|
|
28
|
+
function mapTier(ddTier) {
|
|
29
|
+
if (!ddTier)
|
|
30
|
+
return 'standard';
|
|
31
|
+
const lower = ddTier.toLowerCase();
|
|
32
|
+
if (lower.includes('1') || lower.includes('critical') || lower.includes('high'))
|
|
33
|
+
return 'critical';
|
|
34
|
+
if (lower.includes('3') || lower.includes('low') || lower.includes('experiment'))
|
|
35
|
+
return 'experimental';
|
|
36
|
+
return 'standard';
|
|
37
|
+
}
|
|
38
|
+
function mapKind(ddType) {
|
|
39
|
+
if (!ddType)
|
|
40
|
+
return 'service';
|
|
41
|
+
const lower = ddType.toLowerCase();
|
|
42
|
+
if (lower === 'web' || lower === 'website' || lower === 'frontend')
|
|
43
|
+
return 'website';
|
|
44
|
+
if (lower === 'library' || lower === 'lib')
|
|
45
|
+
return 'library';
|
|
46
|
+
if (lower === 'job' || lower === 'worker' || lower === 'cron')
|
|
47
|
+
return 'job';
|
|
48
|
+
return 'service';
|
|
49
|
+
}
|
|
50
|
+
export async function runSyncDatadog(teamId, options = {}) {
|
|
51
|
+
const { verbose } = options;
|
|
52
|
+
logInfo(`Starting Datadog service sync for team ${teamId}`);
|
|
53
|
+
const env = readDatadogEnv();
|
|
54
|
+
if (!env.ok) {
|
|
55
|
+
logError(`Datadog not configured. Missing: ${env.missing.join(', ')}. ` +
|
|
56
|
+
`Configure in the desktop app (Settings → Credentials → Datadog) or ` +
|
|
57
|
+
`set them with \`edsger config set DD_API_KEY=<key> DD_APP_KEY=<key>\`. ` +
|
|
58
|
+
`Optional: DD_SITE for non-US1 sites.`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
const { apiKey, appKey, site } = env.env;
|
|
62
|
+
// Fetch service definitions from Datadog
|
|
63
|
+
logInfo(`Fetching service definitions from Datadog (${site})...`);
|
|
64
|
+
let services = [];
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(`https://api.${site}/api/v2/services/definitions?page[size]=100`, {
|
|
67
|
+
headers: {
|
|
68
|
+
'DD-API-KEY': apiKey,
|
|
69
|
+
'DD-APPLICATION-KEY': appKey,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
const text = await res.text().catch(() => '');
|
|
74
|
+
logError(`Datadog API returned ${res.status}: ${text}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const json = (await res.json());
|
|
78
|
+
services = json.data ?? [];
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
logError(`Failed to fetch from Datadog: ${err instanceof Error ? err.message : String(err)}`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
logInfo(`Found ${services.length} service definitions in Datadog`);
|
|
85
|
+
let created = 0;
|
|
86
|
+
let updated = 0;
|
|
87
|
+
let skipped = 0;
|
|
88
|
+
let componentCount = 0;
|
|
89
|
+
for (const svc of services) {
|
|
90
|
+
const schema = svc.attributes?.schema;
|
|
91
|
+
if (!schema) {
|
|
92
|
+
skipped++;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const name = schema['dd-service'] ?? schema.service;
|
|
96
|
+
if (!name) {
|
|
97
|
+
skipped++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (verbose)
|
|
101
|
+
logInfo(`Processing: ${name}`);
|
|
102
|
+
try {
|
|
103
|
+
// Upsert service
|
|
104
|
+
const result = (await callMcpEndpoint('services.upsert_draft', {
|
|
105
|
+
team_id: teamId,
|
|
106
|
+
name,
|
|
107
|
+
display_name: schema.application ?? name,
|
|
108
|
+
description: schema.description ?? null,
|
|
109
|
+
kind: mapKind(schema.type),
|
|
110
|
+
tier: mapTier(schema.tier),
|
|
111
|
+
language: schema.languages?.[0] ?? null,
|
|
112
|
+
tags: schema.tags ?? [],
|
|
113
|
+
source: 'datadog',
|
|
114
|
+
source_ref: `datadog:${name}`,
|
|
115
|
+
field_sources: {
|
|
116
|
+
name: { value: name, confidence: 1.0, source: 'datadog' },
|
|
117
|
+
tier: { value: mapTier(schema.tier), confidence: 0.9, source: 'datadog' },
|
|
118
|
+
language: { value: schema.languages?.[0], confidence: 1.0, source: 'datadog' },
|
|
119
|
+
...(schema.team ? { owner: { value: schema.team, confidence: 0.9, source: 'datadog' } } : {}),
|
|
120
|
+
},
|
|
121
|
+
needs_review: false,
|
|
122
|
+
}));
|
|
123
|
+
const serviceId = result.service.id;
|
|
124
|
+
if (result.action === 'created')
|
|
125
|
+
created++;
|
|
126
|
+
else
|
|
127
|
+
updated++;
|
|
128
|
+
// Add repo components
|
|
129
|
+
for (const repo of schema.repos ?? []) {
|
|
130
|
+
if (!repo.url)
|
|
131
|
+
continue;
|
|
132
|
+
const repoName = repo.url
|
|
133
|
+
.replace(/^https?:\/\/github\.com\//, '')
|
|
134
|
+
.replace(/\.git$/, '');
|
|
135
|
+
try {
|
|
136
|
+
await callMcpEndpoint('services.add_component', {
|
|
137
|
+
service_id: serviceId,
|
|
138
|
+
kind: 'repo',
|
|
139
|
+
provider: 'github',
|
|
140
|
+
external_id: repoName,
|
|
141
|
+
url: repo.url,
|
|
142
|
+
});
|
|
143
|
+
componentCount++;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
if (verbose)
|
|
147
|
+
logWarning(` Failed to add repo: ${repo.url}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Add link components (dashboards, runbooks, etc.)
|
|
151
|
+
for (const link of schema.links ?? []) {
|
|
152
|
+
if (!link.url)
|
|
153
|
+
continue;
|
|
154
|
+
const kind = link.type === 'dashboard' || link.type === 'grafana'
|
|
155
|
+
? 'dashboard'
|
|
156
|
+
: link.type === 'runbook' || link.type === 'doc'
|
|
157
|
+
? 'runbook'
|
|
158
|
+
: link.type === 'oncall' || link.type === 'pagerduty'
|
|
159
|
+
? 'pagerduty_service'
|
|
160
|
+
: 'documentation';
|
|
161
|
+
try {
|
|
162
|
+
await callMcpEndpoint('services.add_component', {
|
|
163
|
+
service_id: serviceId,
|
|
164
|
+
kind,
|
|
165
|
+
provider: 'datadog',
|
|
166
|
+
external_id: link.name ?? link.url,
|
|
167
|
+
url: link.url,
|
|
168
|
+
});
|
|
169
|
+
componentCount++;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
if (verbose)
|
|
173
|
+
logWarning(` Failed to add link: ${link.url}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Add Slack channel
|
|
177
|
+
const slackContact = (schema.contacts ?? []).find((c) => c.type === 'slack');
|
|
178
|
+
if (slackContact?.contact) {
|
|
179
|
+
try {
|
|
180
|
+
await callMcpEndpoint('services.add_component', {
|
|
181
|
+
service_id: serviceId,
|
|
182
|
+
kind: 'slack_channel',
|
|
183
|
+
provider: 'slack',
|
|
184
|
+
external_id: slackContact.contact,
|
|
185
|
+
});
|
|
186
|
+
componentCount++;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// ignore
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
logWarning(`Failed to sync "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
195
|
+
skipped++;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
logSuccess(`Datadog sync completed: ${created} created, ${updated} updated, ${skipped} skipped, ${componentCount} components added`);
|
|
199
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: `edsger sync-org-repos <teamId>`
|
|
3
|
+
*
|
|
4
|
+
* Reads the team's configured github_org, fetches all repos from that org
|
|
5
|
+
* via the local `gh` CLI, and creates a product for each repo not yet linked.
|
|
6
|
+
*/
|
|
7
|
+
export interface SyncOrgReposCliOptions {
|
|
8
|
+
verbose?: boolean;
|
|
9
|
+
org?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function runSyncOrgRepos(teamId: string, options?: SyncOrgReposCliOptions): Promise<void>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: `edsger sync-org-repos <teamId>`
|
|
3
|
+
*
|
|
4
|
+
* Reads the team's configured github_org, fetches all repos from that org
|
|
5
|
+
* via the local `gh` CLI, and creates a product for each repo not yet linked.
|
|
6
|
+
*/
|
|
7
|
+
import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
|
|
8
|
+
import { syncOrgRepos } from '../../phases/sync-org-repos/index.js';
|
|
9
|
+
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
10
|
+
const ORG_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
11
|
+
export async function runSyncOrgRepos(teamId, options = {}) {
|
|
12
|
+
const { verbose } = options;
|
|
13
|
+
if (!hasSupabaseSession()) {
|
|
14
|
+
logError('Supabase session unavailable. Sign in to the Edsger desktop app to authorize the CLI.');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const supabase = getSupabase();
|
|
18
|
+
let orgLogin = options.org;
|
|
19
|
+
if (!orgLogin) {
|
|
20
|
+
const { data: team, error } = await supabase
|
|
21
|
+
.from('teams')
|
|
22
|
+
.select('github_org')
|
|
23
|
+
.eq('id', teamId)
|
|
24
|
+
.single();
|
|
25
|
+
if (error || !team) {
|
|
26
|
+
logError(`Team not found: ${teamId}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
orgLogin = team.github_org ?? undefined;
|
|
30
|
+
if (!orgLogin) {
|
|
31
|
+
logError('No GitHub organization configured for this team. Set it in team settings first, or pass --org <name>.');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!ORG_NAME_RE.test(orgLogin) || orgLogin.length > 39) {
|
|
36
|
+
logError(`Invalid GitHub organization name: "${orgLogin}"`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
const { data: { user }, } = await supabase.auth.getUser();
|
|
40
|
+
if (!user) {
|
|
41
|
+
logError('Not authenticated');
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
logInfo(`Syncing repos from org "${orgLogin}" into team ${teamId}`);
|
|
45
|
+
const result = await syncOrgRepos({
|
|
46
|
+
teamId,
|
|
47
|
+
orgLogin,
|
|
48
|
+
userId: user.id,
|
|
49
|
+
verbose,
|
|
50
|
+
});
|
|
51
|
+
if (result.status === 'success') {
|
|
52
|
+
logSuccess(result.message);
|
|
53
|
+
logInfo(`Total: ${result.total} · created: ${result.created} · skipped: ${result.skipped}`);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
logError(result.message);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: `edsger sync-terraform <teamId>`
|
|
3
|
+
*
|
|
4
|
+
* Reads the team's terraform_repo_full_name from the DB, clones the repo,
|
|
5
|
+
* and lets the agent (Claude SDK) analyse .tf files to extract service
|
|
6
|
+
* identities, AWS resources, module dependencies, and tier signals.
|
|
7
|
+
*
|
|
8
|
+
* Unlike sync-datadog (structured API → deterministic mapping), Terraform
|
|
9
|
+
* HCL is too varied for regex parsing — the agent reads files and reasons
|
|
10
|
+
* about module structure, variable passing, resource tagging, etc.
|
|
11
|
+
*/
|
|
12
|
+
export interface SyncTerraformOptions {
|
|
13
|
+
verbose?: boolean;
|
|
14
|
+
dir?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function runSyncTerraform(teamId: string, options?: SyncTerraformOptions): Promise<void>;
|