edsger 0.70.0 → 0.72.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.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * CLI command: `edsger discover <teamId> [runId]`
3
+ *
4
+ * Runs GitHub service discovery for a team: scans every repo in the team's
5
+ * connected GitHub org and upserts an inferred service for each. When invoked
6
+ * with an existing `runId` (the desktop creates a pending `discovery_runs` row
7
+ * first), the scan drives that row to running → success/failed; otherwise it
8
+ * creates its own run row.
9
+ */
10
+ export interface DiscoverOptions {
11
+ verbose?: boolean;
12
+ org?: string;
13
+ }
14
+ export declare function runDiscover(teamId: string, runId: string | undefined, options?: DiscoverOptions): Promise<void>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * CLI command: `edsger discover <teamId> [runId]`
3
+ *
4
+ * Runs GitHub service discovery for a team: scans every repo in the team's
5
+ * connected GitHub org and upserts an inferred service for each. When invoked
6
+ * with an existing `runId` (the desktop creates a pending `discovery_runs` row
7
+ * first), the scan drives that row to running → success/failed; otherwise it
8
+ * creates its own run row.
9
+ */
10
+ import { discoverServices } from '../../phases/discover-services/index.js';
11
+ import { hasSupabaseSession } from '../../supabase/client.js';
12
+ import { logError, logInfo } from '../../utils/logger.js';
13
+ export async function runDiscover(teamId, runId, options = {}) {
14
+ if (!hasSupabaseSession()) {
15
+ logError('Supabase session unavailable. Sign in to the Edsger desktop app to authorize the CLI.');
16
+ process.exit(1);
17
+ }
18
+ logInfo(`Starting GitHub service discovery for team ${teamId}`);
19
+ const result = await discoverServices({
20
+ teamId,
21
+ runId,
22
+ org: options.org,
23
+ verbose: options.verbose,
24
+ });
25
+ if (result.status === 'error') {
26
+ logError(result.message);
27
+ process.exit(1);
28
+ }
29
+ }
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { runClassDiagram } from './commands/class-diagram/index.js';
17
17
  import { runCodeReview } from './commands/code-review/index.js';
18
18
  import { runConfigGet, runConfigList, runConfigSet, runConfigUnset, } from './commands/config/index.js';
19
19
  import { runDataFlow } from './commands/data-flow/index.js';
20
+ import { runDiscover } from './commands/discover/index.js';
20
21
  import { runErDiagram } from './commands/er-diagram/index.js';
21
22
  import { runFinancingDeck } from './commands/financing-deck/index.js';
22
23
  import { runFindArchitecture } from './commands/find-architecture/index.js';
@@ -57,6 +58,7 @@ import { runWorkflow } from './commands/workflow/index.js';
57
58
  import { DEFAULT_MAX_FILES as FIND_ARCHITECTURE_DEFAULT_MAX_FILES } from './phases/find-architecture/index.js';
58
59
  import { DEFAULT_MAX_FILES as FIND_SMELLS_DEFAULT_MAX_FILES } from './phases/find-smells/index.js';
59
60
  import { SMELL_CATEGORIES, } from './phases/find-smells/types.js';
61
+ import { deregisterSession, registerSession, } from './system/session-manager.js';
60
62
  import { logError, logInfo } from './utils/logger.js';
61
63
  // Get package.json version dynamically
62
64
  // eslint-disable-next-line @typescript-eslint/naming-convention -- ESM __filename/__dirname polyfill
@@ -76,6 +78,24 @@ program
76
78
  .name('edsger')
77
79
  .description('AI-powered workflow automation and code review CLI tool')
78
80
  .version(version);
81
+ // When the desktop app spawns a streamed CLI it sets EDSGER_PROCESS_KEY (its
82
+ // process-registry key with the `:output` marker stripped, e.g.
83
+ // `sync-terraform` or `recipes:<productId>`). Open a cli_sessions row whose
84
+ // `command` is that key so the originating tab can find the still-running
85
+ // session and replay its logs across tab switches / restarts / devices.
86
+ // registerSession is idempotent: commands that open their own (richer) session
87
+ // just enrich the row started here (the process key stays in `command`), and
88
+ // deregisterSession is a no-op once they've closed it.
89
+ program.hook('preAction', async (_thisCommand, actionCommand) => {
90
+ if (process.env.EDSGER_PROCESS_KEY) {
91
+ await registerSession({ command: actionCommand.name() });
92
+ }
93
+ });
94
+ program.hook('postAction', async () => {
95
+ if (process.env.EDSGER_PROCESS_KEY) {
96
+ await deregisterSession();
97
+ }
98
+ });
79
99
  // ============================================================
80
100
  // Subcommand: edsger login
81
101
  // ============================================================
@@ -888,6 +908,27 @@ program
888
908
  process.exit(1);
889
909
  }
890
910
  });
911
+ // ============================================================
912
+ // Subcommand: edsger discover <teamId> [runId]
913
+ //
914
+ // Scans every repo in the team's connected GitHub org and upserts an
915
+ // inferred service for each into the service catalog. Drives an existing
916
+ // discovery_runs row (created by the desktop) to running → success/failed.
917
+ // ============================================================
918
+ program
919
+ .command('discover <teamId> [runId]')
920
+ .description('Discover services by scanning every repo in the team’s connected GitHub organisation')
921
+ .option('-v, --verbose', 'Verbose output')
922
+ .option('--org <name>', 'Override the GitHub organisation to scan')
923
+ .action(async (teamId, runId, opts) => {
924
+ try {
925
+ await runDiscover(teamId, runId, opts);
926
+ }
927
+ catch (error) {
928
+ logError(error instanceof Error ? error.message : String(error));
929
+ process.exit(1);
930
+ }
931
+ });
891
932
  // Subcommand: edsger sync-terraform <teamId>
892
933
  //
893
934
  // Clones the team's terraform repo (from teams.terraform_repo_full_name)
@@ -13,10 +13,10 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
13
13
  import { getRepositoryBasics } from '../../api/github.js';
14
14
  import { DEFAULT_MODEL } from '../../constants.js';
15
15
  import { getSupabase } from '../../supabase/client.js';
16
- import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
16
+ import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
17
17
  import { cleanupIssueRepo } from '../../workspace/workspace-manager.js';
18
+ import { cloneDiagramRepos, describeRepoScope, } from '../diagram-shared/clone-repos.js';
18
19
  import { fetchProductBasics } from '../find-shared/mcp.js';
19
- import { cloneDiagramRepos, describeRepoScope } from '../diagram-shared/clone-repos.js';
20
20
  import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
21
21
  import { createDataFlowCaptureState, createDataFlowMcpServer, validateConsistency, } from './mcp-server.js';
22
22
  import { createDataFlowSystemPrompt, createDataFlowUserPrompt, } from './prompts.js';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Phase: discover-services (GitHub Discovery)
3
+ *
4
+ * Scans every repository in a team's connected GitHub organisation and infers
5
+ * a service for each one. The server mints a short-lived org-scoped token
6
+ * (`github/org_token`) and the CLI reads each repo's signal files directly via
7
+ * Octokit — a handful of root files (README, package manifests, Dockerfile) —
8
+ * so private repos are read over the API without cloning. It then derives a
9
+ * service identity (name, language, kind, description) heuristically and
10
+ * upserts it into the team-scoped service catalog (`services` + a `repo`
11
+ * component).
12
+ *
13
+ * Progress is recorded against a `discovery_runs` row: the run is flipped to
14
+ * `running`, heart-beaten on every repo, and finalized as `success`/`failed`.
15
+ * The desktop Discover Services page reads that row to render live status.
16
+ */
17
+ export interface DiscoverServicesResult {
18
+ status: 'success' | 'error';
19
+ message: string;
20
+ reposScanned: number;
21
+ servicesCreated: number;
22
+ servicesUpdated: number;
23
+ }
24
+ export declare function discoverServices(opts: {
25
+ teamId: string;
26
+ runId?: string;
27
+ org?: string;
28
+ verbose?: boolean;
29
+ }): Promise<DiscoverServicesResult>;
@@ -0,0 +1,528 @@
1
+ /**
2
+ * Phase: discover-services (GitHub Discovery)
3
+ *
4
+ * Scans every repository in a team's connected GitHub organisation and infers
5
+ * a service for each one. The server mints a short-lived org-scoped token
6
+ * (`github/org_token`) and the CLI reads each repo's signal files directly via
7
+ * Octokit — a handful of root files (README, package manifests, Dockerfile) —
8
+ * so private repos are read over the API without cloning. It then derives a
9
+ * service identity (name, language, kind, description) heuristically and
10
+ * upserts it into the team-scoped service catalog (`services` + a `repo`
11
+ * component).
12
+ *
13
+ * Progress is recorded against a `discovery_runs` row: the run is flipped to
14
+ * `running`, heart-beaten on every repo, and finalized as `success`/`failed`.
15
+ * The desktop Discover Services page reads that row to render live status.
16
+ */
17
+ import { Octokit } from '@octokit/rest';
18
+ import { callMcpEndpoint } from '../../api/mcp-client.js';
19
+ import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
20
+ import { logDebug, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
21
+ const ORG_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
22
+ const MAX_FILE_BYTES = 16_000;
23
+ /** Signal files read from each repo's root to infer a service. */
24
+ const SIGNAL_FILES = [
25
+ 'README.md',
26
+ 'readme.md',
27
+ 'package.json',
28
+ 'pyproject.toml',
29
+ 'requirements.txt',
30
+ 'go.mod',
31
+ 'pom.xml',
32
+ 'build.gradle',
33
+ 'Cargo.toml',
34
+ 'Gemfile',
35
+ 'composer.json',
36
+ 'Dockerfile',
37
+ ];
38
+ /**
39
+ * Read a repo's root-level signal files with Octokit. Lists the root tree once,
40
+ * then fetches only the signal files that actually exist (in parallel) — avoids
41
+ * a 404 round-trip per candidate file. Returns a path → content map (capped).
42
+ */
43
+ async function readRepoSignalFiles(octokit, owner, name, ref) {
44
+ const files = new Map();
45
+ let rootNames;
46
+ try {
47
+ const { data } = await octokit.repos.getContent({
48
+ owner,
49
+ repo: name,
50
+ path: '',
51
+ ...(ref ? { ref } : {}),
52
+ });
53
+ if (!Array.isArray(data)) {
54
+ return files;
55
+ }
56
+ rootNames = new Set(data.filter((e) => e.type === 'file').map((e) => e.name));
57
+ }
58
+ catch {
59
+ // Empty repo, no access, or missing ref — nothing to read.
60
+ return files;
61
+ }
62
+ const wanted = SIGNAL_FILES.filter((p) => rootNames.has(p));
63
+ await Promise.all(wanted.map(async (path) => {
64
+ try {
65
+ const { data } = await octokit.repos.getContent({
66
+ owner,
67
+ repo: name,
68
+ path,
69
+ ...(ref ? { ref } : {}),
70
+ });
71
+ if (!Array.isArray(data) &&
72
+ data.type === 'file' &&
73
+ typeof data.content === 'string') {
74
+ files.set(path, Buffer.from(data.content, 'base64')
75
+ .toString('utf8')
76
+ .slice(0, MAX_FILE_BYTES));
77
+ }
78
+ }
79
+ catch {
80
+ // Skip unreadable file.
81
+ }
82
+ }));
83
+ return files;
84
+ }
85
+ const SERVER_FRAMEWORKS = [
86
+ 'express',
87
+ 'fastify',
88
+ 'koa',
89
+ '@nestjs/core',
90
+ 'next',
91
+ 'hapi',
92
+ 'flask',
93
+ 'django',
94
+ 'fastapi',
95
+ 'uvicorn',
96
+ 'gunicorn',
97
+ 'gin',
98
+ 'echo',
99
+ 'fiber',
100
+ 'spring-boot',
101
+ 'actix',
102
+ 'rails',
103
+ 'sinatra',
104
+ ];
105
+ /** Read the first markdown heading or paragraph from a README as a fallback description. */
106
+ function descriptionFromReadme(content) {
107
+ const lines = content.split('\n');
108
+ for (const raw of lines) {
109
+ const line = raw.trim();
110
+ if (!line || line.startsWith('![') || line.startsWith('<')) {
111
+ continue;
112
+ }
113
+ const heading = line.match(/^#{1,3}\s+(.*)$/);
114
+ if (heading) {
115
+ const text = heading[1].trim();
116
+ // Skip a bare title that just repeats the repo name — keep scanning for prose.
117
+ if (text.length > 0 && /\s/.test(text)) {
118
+ return text.slice(0, 280);
119
+ }
120
+ continue;
121
+ }
122
+ // First non-heading prose line.
123
+ return line.replace(/[*_`>#-]/g, '').trim().slice(0, 280) || null;
124
+ }
125
+ return null;
126
+ }
127
+ /** Map manifest presence to a language when GitHub doesn't report one. */
128
+ function languageFromManifests(files) {
129
+ const byManifest = [
130
+ ['package.json', 'JavaScript'],
131
+ ['pyproject.toml', 'Python'],
132
+ ['requirements.txt', 'Python'],
133
+ ['go.mod', 'Go'],
134
+ ['pom.xml', 'Java'],
135
+ ['build.gradle', 'Java'],
136
+ ['Cargo.toml', 'Rust'],
137
+ ['Gemfile', 'Ruby'],
138
+ ['composer.json', 'PHP'],
139
+ ];
140
+ for (const [manifest, language] of byManifest) {
141
+ if (files.has(manifest)) {
142
+ return language;
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+ /** Parse the subset of package.json fields we use for inference. */
148
+ function parsePackageJson(raw) {
149
+ const empty = {
150
+ name: null,
151
+ deps: [],
152
+ hasStart: false,
153
+ isPrivate: null,
154
+ hasMain: false,
155
+ };
156
+ if (!raw) {
157
+ return empty;
158
+ }
159
+ try {
160
+ const pkg = JSON.parse(raw);
161
+ return {
162
+ name: typeof pkg.name === 'string' ? pkg.name.replace(/^@[^/]+\//, '') : null,
163
+ deps: [
164
+ ...Object.keys(pkg.dependencies ?? {}),
165
+ ...Object.keys(pkg.devDependencies ?? {}),
166
+ ],
167
+ hasStart: Boolean(pkg.scripts?.start),
168
+ isPrivate: pkg.private === true,
169
+ hasMain: Boolean(pkg.main || pkg.exports || pkg.module),
170
+ };
171
+ }
172
+ catch {
173
+ // Malformed package.json — fall back to empty.
174
+ return empty;
175
+ }
176
+ }
177
+ /** Infer a service identity from a repo + its signal files. */
178
+ function inferService(repo, files) {
179
+ const fullName = repo.full_name;
180
+ const hasDockerfile = files.has('Dockerfile');
181
+ // Parse package.json if present.
182
+ const pkgRaw = files.get('package.json');
183
+ const pkg = parsePackageJson(pkgRaw);
184
+ const { name: pkgName, deps: pkgDeps, hasStart: pkgHasStart, isPrivate: pkgIsPrivate, hasMain: pkgHasMain, } = pkg;
185
+ // Detect language from repo metadata or manifest presence.
186
+ const language = repo.language ?? languageFromManifests(files);
187
+ // Detect server frameworks across manifests for kind + tags.
188
+ const manifestBlob = [
189
+ pkgDeps.join(' '),
190
+ files.get('requirements.txt') ?? '',
191
+ files.get('pyproject.toml') ?? '',
192
+ files.get('go.mod') ?? '',
193
+ files.get('pom.xml') ?? '',
194
+ files.get('build.gradle') ?? '',
195
+ files.get('Gemfile') ?? '',
196
+ ]
197
+ .join(' ')
198
+ .toLowerCase();
199
+ const detectedFrameworks = SERVER_FRAMEWORKS.filter((fw) => manifestBlob.includes(fw.toLowerCase()));
200
+ // Decide kind. Anything deployable (Dockerfile, start script, or a server
201
+ // framework) is a service; a published package manifest with a main/exports
202
+ // entry and no run target is a library; otherwise default to service.
203
+ const isDeployable = hasDockerfile || pkgHasStart || detectedFrameworks.length > 0;
204
+ const isLibrary = !isDeployable &&
205
+ Boolean(pkgRaw) &&
206
+ pkgHasMain &&
207
+ !pkgHasStart &&
208
+ pkgIsPrivate === false;
209
+ const kind = isLibrary ? 'library' : 'service';
210
+ let kindConfidence = 0.5;
211
+ if (isDeployable) {
212
+ kindConfidence = 0.8;
213
+ }
214
+ else if (isLibrary) {
215
+ kindConfidence = 0.7;
216
+ }
217
+ const name = (repo.name || pkgName || fullName.split('/')[1]).trim();
218
+ // Description: repo metadata wins, then README.
219
+ const readme = files.get('README.md') ?? files.get('readme.md');
220
+ const description = repo.description?.trim() ||
221
+ (readme ? descriptionFromReadme(readme) : null) ||
222
+ null;
223
+ const descriptionConfidence = repo.description?.trim() ? 0.9 : 0.5;
224
+ const tags = Array.from(new Set([
225
+ ...(language ? [language.toLowerCase()] : []),
226
+ ...detectedFrameworks.map((f) => f.replace(/^@[^/]+\//, '')),
227
+ ])).slice(0, 12);
228
+ const fieldSources = {
229
+ name: {
230
+ value: name,
231
+ confidence: 0.9,
232
+ source: 'github',
233
+ source_detail: fullName,
234
+ },
235
+ kind: { value: kind, confidence: kindConfidence, source: 'github' },
236
+ };
237
+ if (language) {
238
+ fieldSources.language = {
239
+ value: language,
240
+ confidence: repo.language ? 1.0 : 0.6,
241
+ source: 'github',
242
+ };
243
+ }
244
+ if (description) {
245
+ fieldSources.description = {
246
+ value: description,
247
+ confidence: descriptionConfidence,
248
+ source: 'github',
249
+ };
250
+ }
251
+ return {
252
+ name,
253
+ display_name: name,
254
+ description,
255
+ kind,
256
+ language,
257
+ tags,
258
+ field_sources: fieldSources,
259
+ };
260
+ }
261
+ /**
262
+ * Upsert one inferred service + its repo component, mirroring the
263
+ * `services.upsert_draft` semantics (insert when new, update only unlocked
264
+ * fields when it already exists). Returns whether the service was created or
265
+ * updated so the caller can keep run-level counters.
266
+ */
267
+ async function upsertServiceForRepo(
268
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Supabase client type
269
+ supabase, teamId, userId, repo, inferred) {
270
+ const { data: existing } = await supabase
271
+ .from('services')
272
+ .select('id, locked_fields')
273
+ .eq('team_id', teamId)
274
+ .eq('name', inferred.name)
275
+ .maybeSingle();
276
+ const fields = {
277
+ display_name: inferred.display_name,
278
+ description: inferred.description,
279
+ kind: inferred.kind,
280
+ language: inferred.language,
281
+ tags: inferred.tags,
282
+ source: 'github',
283
+ source_ref: `github:${repo.full_name}`,
284
+ field_sources: inferred.field_sources,
285
+ };
286
+ let serviceId;
287
+ let action;
288
+ if (existing) {
289
+ const locked = existing.locked_fields ?? [];
290
+ const updates = {};
291
+ for (const [k, v] of Object.entries(fields)) {
292
+ if (!locked.includes(k) && v !== null && v !== undefined) {
293
+ updates[k] = v;
294
+ }
295
+ }
296
+ const { error } = await supabase
297
+ .from('services')
298
+ .update(updates)
299
+ .eq('id', existing.id);
300
+ if (error) {
301
+ throw error;
302
+ }
303
+ serviceId = existing.id;
304
+ action = 'updated';
305
+ }
306
+ else {
307
+ const { data: ins, error } = await supabase
308
+ .from('services')
309
+ .insert({
310
+ team_id: teamId,
311
+ name: inferred.name,
312
+ ...fields,
313
+ needs_review: true,
314
+ created_by: userId,
315
+ })
316
+ .select('id')
317
+ .single();
318
+ if (error) {
319
+ throw error;
320
+ }
321
+ serviceId = ins?.id ?? null;
322
+ action = 'created';
323
+ }
324
+ if (serviceId) {
325
+ await supabase.from('service_components').upsert({
326
+ service_id: serviceId,
327
+ kind: 'repo',
328
+ provider: 'github',
329
+ external_id: repo.full_name,
330
+ url: repo.html_url,
331
+ metadata: {
332
+ default_branch: repo.default_branch,
333
+ private: repo.private,
334
+ language: repo.language,
335
+ },
336
+ }, { onConflict: 'service_id,kind,external_id' });
337
+ }
338
+ return action;
339
+ }
340
+ export async function discoverServices(opts) {
341
+ const { teamId, verbose } = opts;
342
+ const err = (message) => ({
343
+ status: 'error',
344
+ message,
345
+ reposScanned: 0,
346
+ servicesCreated: 0,
347
+ servicesUpdated: 0,
348
+ });
349
+ if (!hasSupabaseSession()) {
350
+ return err('Supabase session unavailable. Sign in to the Edsger desktop app.');
351
+ }
352
+ const supabase = getSupabase();
353
+ const { data: { user }, } = await supabase.auth.getUser();
354
+ if (!user) {
355
+ return err('Not authenticated');
356
+ }
357
+ // Resolve the team's GitHub org.
358
+ let orgLogin = opts.org;
359
+ if (!orgLogin) {
360
+ const { data: team, error: teamErr } = await supabase
361
+ .from('teams')
362
+ .select('github_org')
363
+ .eq('id', teamId)
364
+ .single();
365
+ if (teamErr || !team) {
366
+ return err(`Team not found: ${teamId}`);
367
+ }
368
+ orgLogin = team.github_org ?? undefined;
369
+ }
370
+ if (!orgLogin) {
371
+ return err('No GitHub organization configured for this team. Set it in team settings first.');
372
+ }
373
+ if (!ORG_NAME_RE.test(orgLogin) || orgLogin.length > 39) {
374
+ return err(`Invalid GitHub organization name: "${orgLogin}"`);
375
+ }
376
+ // Resolve / create the discovery_runs row and flip it to running.
377
+ let runId = opts.runId ?? null;
378
+ if (runId) {
379
+ const { error: updErr } = await supabase
380
+ .from('discovery_runs')
381
+ .update({
382
+ status: 'running',
383
+ last_heartbeat_at: new Date().toISOString(),
384
+ })
385
+ .eq('id', runId)
386
+ .eq('team_id', teamId);
387
+ if (updErr) {
388
+ logWarning(`Could not mark discovery run as running: ${updErr.message}`);
389
+ }
390
+ }
391
+ else {
392
+ const { data: inserted, error: insErr } = await supabase
393
+ .from('discovery_runs')
394
+ .insert({
395
+ team_id: teamId,
396
+ scope: 'org',
397
+ scope_ref: orgLogin,
398
+ status: 'running',
399
+ created_by: user.id,
400
+ last_heartbeat_at: new Date().toISOString(),
401
+ })
402
+ .select('id')
403
+ .single();
404
+ if (insErr) {
405
+ logWarning(`Could not create discovery run row: ${insErr.message}`);
406
+ }
407
+ else {
408
+ runId = inserted?.id ?? null;
409
+ }
410
+ }
411
+ const finalize = async (status, counts, error) => {
412
+ if (!runId) {
413
+ return;
414
+ }
415
+ await supabase
416
+ .from('discovery_runs')
417
+ .update({
418
+ status,
419
+ repos_scanned: counts.repos,
420
+ services_found: counts.created,
421
+ services_updated: counts.updated,
422
+ error: error ?? null,
423
+ completed_at: new Date().toISOString(),
424
+ last_heartbeat_at: new Date().toISOString(),
425
+ })
426
+ .eq('id', runId)
427
+ .eq('team_id', teamId);
428
+ };
429
+ // List the org's repos via the Edsger GitHub App (server-side).
430
+ logInfo(`Scanning GitHub org "${orgLogin}" for services...`);
431
+ let repos;
432
+ try {
433
+ const res = (await callMcpEndpoint('github/org_repos', {
434
+ team_id: teamId,
435
+ org: orgLogin,
436
+ }));
437
+ if (!res.configured) {
438
+ const message = res.message || `The Edsger GitHub App is not configured for "${orgLogin}".`;
439
+ await finalize('failed', { repos: 0, created: 0, updated: 0 }, message);
440
+ return err(message);
441
+ }
442
+ repos = res.repos ?? [];
443
+ }
444
+ catch (e) {
445
+ const message = `Failed to list repos: ${e instanceof Error ? e.message : String(e)}`;
446
+ await finalize('failed', { repos: 0, created: 0, updated: 0 }, message);
447
+ return err(message);
448
+ }
449
+ if (repos.length === 0) {
450
+ await finalize('success', { repos: 0, created: 0, updated: 0 });
451
+ logSuccess(`No repositories found in "${orgLogin}"`);
452
+ return {
453
+ status: 'success',
454
+ message: `No repositories found in ${orgLogin}`,
455
+ reposScanned: 0,
456
+ servicesCreated: 0,
457
+ servicesUpdated: 0,
458
+ };
459
+ }
460
+ // Mint an org-scoped GitHub token so the CLI can read repo files itself.
461
+ let octokit;
462
+ try {
463
+ const tokenRes = (await callMcpEndpoint('github/org_token', {
464
+ team_id: teamId,
465
+ org: orgLogin,
466
+ }));
467
+ if (!tokenRes.configured || !tokenRes.token) {
468
+ const message = tokenRes.message ||
469
+ `Could not obtain a GitHub token for "${orgLogin}".`;
470
+ await finalize('failed', { repos: 0, created: 0, updated: 0 }, message);
471
+ return err(message);
472
+ }
473
+ octokit = new Octokit({ auth: tokenRes.token });
474
+ }
475
+ catch (e) {
476
+ const message = `Failed to obtain GitHub token: ${e instanceof Error ? e.message : String(e)}`;
477
+ await finalize('failed', { repos: 0, created: 0, updated: 0 }, message);
478
+ return err(message);
479
+ }
480
+ logInfo(`Found ${repos.length} repos. Inferring services...`);
481
+ let scanned = 0;
482
+ let created = 0;
483
+ let updated = 0;
484
+ for (const repo of repos) {
485
+ scanned++;
486
+ try {
487
+ // Read the repo's signal files directly via Octokit.
488
+ const [owner, name] = repo.full_name.split('/');
489
+ const files = await readRepoSignalFiles(octokit, owner, name, repo.default_branch ?? undefined);
490
+ const inferred = inferService(repo, files);
491
+ const action = await upsertServiceForRepo(supabase, teamId, user.id, repo, inferred);
492
+ if (action === 'created') {
493
+ created++;
494
+ }
495
+ else {
496
+ updated++;
497
+ }
498
+ if (verbose) {
499
+ logDebug(` ${repo.full_name} → ${inferred.name} (${inferred.kind}${inferred.language ? `, ${inferred.language}` : ''})`);
500
+ }
501
+ }
502
+ catch (e) {
503
+ logWarning(`Skipped ${repo.full_name}: ${e instanceof Error ? e.message : String(e)}`);
504
+ }
505
+ // Heartbeat + running counters so the UI can show live progress.
506
+ if (runId && (scanned % 3 === 0 || scanned === repos.length)) {
507
+ await supabase
508
+ .from('discovery_runs')
509
+ .update({
510
+ repos_scanned: scanned,
511
+ services_found: created,
512
+ services_updated: updated,
513
+ last_heartbeat_at: new Date().toISOString(),
514
+ })
515
+ .eq('id', runId)
516
+ .eq('team_id', teamId);
517
+ }
518
+ }
519
+ await finalize('success', { repos: scanned, created, updated });
520
+ logSuccess(`Discovery completed: scanned ${scanned} repos, ${created} services created, ${updated} updated`);
521
+ return {
522
+ status: 'success',
523
+ message: `Discovered ${created + updated} services from ${scanned} repos`,
524
+ reposScanned: scanned,
525
+ servicesCreated: created,
526
+ servicesUpdated: updated,
527
+ };
528
+ }
@@ -15,8 +15,8 @@ import { DEFAULT_MODEL } from '../../constants.js';
15
15
  import { getSupabase } from '../../supabase/client.js';
16
16
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
17
17
  import { cleanupIssueRepo } from '../../workspace/workspace-manager.js';
18
- import { fetchProductBasics } from '../find-shared/mcp.js';
19
18
  import { cloneDiagramRepos, describeRepoScope, } from '../diagram-shared/clone-repos.js';
19
+ import { fetchProductBasics } from '../find-shared/mcp.js';
20
20
  import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
21
21
  import { createErDiagramCaptureState, createErDiagramMcpServer, validateConsistency, } from './mcp-server.js';
22
22
  import { createErDiagramSystemPrompt, createErDiagramUserPrompt, } from './prompts.js';
@@ -11,10 +11,10 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
11
11
  import { getRepositoryBasics } from '../../api/github.js';
12
12
  import { DEFAULT_MODEL } from '../../constants.js';
13
13
  import { getSupabase } from '../../supabase/client.js';
14
- import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
14
+ import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
15
15
  import { cleanupIssueRepo } from '../../workspace/workspace-manager.js';
16
+ import { cloneDiagramRepos, describeRepoScope, } from '../diagram-shared/clone-repos.js';
16
17
  import { fetchProductBasics } from '../find-shared/mcp.js';
17
- import { cloneDiagramRepos, describeRepoScope } from '../diagram-shared/clone-repos.js';
18
18
  import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
19
19
  import { createScreenFlowCaptureState, createScreenFlowMcpServer, validateConsistency, } from './mcp-server.js';
20
20
  import { createScreenFlowSystemPrompt, createScreenFlowUserPrompt, } from './prompts.js';
@@ -110,7 +110,9 @@ export async function runScreenFlowPhase(options) {
110
110
  logInfo(`Extraction produced ${extraction.nodes.length} screens / ${extraction.edges.length} transitions`);
111
111
  const theme = resolveTheme(extraction.theme, repos);
112
112
  if (Object.keys(theme).length > 0) {
113
- logInfo(`Theme: ${Object.entries(theme).map(([k, v]) => `${k}=${v}`).join(', ')}`);
113
+ logInfo(`Theme: ${Object.entries(theme)
114
+ .map(([k, v]) => `${k}=${v}`)
115
+ .join(', ')}`);
114
116
  await persistTheme(supabase, diagramId, theme);
115
117
  }
116
118
  const { nodesCreated, edgesCreated } = await persistDiagram(supabase, diagramId, extraction);
@@ -17,8 +17,8 @@ import { DEFAULT_MODEL } from '../../constants.js';
17
17
  import { getSupabase } from '../../supabase/client.js';
18
18
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
19
19
  import { cleanupIssueRepo } from '../../workspace/workspace-manager.js';
20
- import { fetchProductBasics } from '../find-shared/mcp.js';
21
20
  import { cloneDiagramRepos, describeRepoScope, } from '../diagram-shared/clone-repos.js';
21
+ import { fetchProductBasics } from '../find-shared/mcp.js';
22
22
  import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
23
23
  import { createSequenceDiagramCaptureState, createSequenceDiagramMcpServer, validateConsistency, } from './mcp-server.js';
24
24
  import { createSequenceDiagramSystemPrompt, createSequenceDiagramUserPrompt, } from './prompts.js';
@@ -20,6 +20,14 @@ export interface CliSession {
20
20
  /**
21
21
  * Register this CLI session with the server.
22
22
  * Optionally accepts command name and product ID for filtering on detail pages.
23
+ *
24
+ * Idempotent: if a session is already active (e.g. registered by the top-level
25
+ * command hook), this enriches that row with the command/product instead of
26
+ * creating a second session, and returns the existing id.
27
+ *
28
+ * The stored `command` is the desktop process key when the CLI was spawned by
29
+ * the app (EDSGER_PROCESS_KEY), so a feature/tab can find this session; see
30
+ * `resolvedCommand`.
23
31
  */
24
32
  export declare function registerSession(options?: {
25
33
  command?: string;
@@ -25,13 +25,39 @@ function generateSessionId() {
25
25
  const random = Math.random().toString(36).substring(2, 8);
26
26
  return `cli-${timestamp}-${random}`;
27
27
  }
28
+ /**
29
+ * The value stored in `cli_sessions.command`.
30
+ *
31
+ * When the desktop app spawns a streamed CLI it injects EDSGER_PROCESS_KEY --
32
+ * its process-registry key (`:output` channel marker already stripped), e.g.
33
+ * `sync-terraform`, `recipes:<productId>`, `pr-review:<prId>`. That key is what
34
+ * the originating tab looks up by, so it takes precedence over the bare command
35
+ * name; standalone runs fall back to the supplied command name.
36
+ */
37
+ function resolvedCommand(options) {
38
+ return process.env.EDSGER_PROCESS_KEY || options?.command;
39
+ }
28
40
  /**
29
41
  * Register this CLI session with the server.
30
42
  * Optionally accepts command name and product ID for filtering on detail pages.
43
+ *
44
+ * Idempotent: if a session is already active (e.g. registered by the top-level
45
+ * command hook), this enriches that row with the command/product instead of
46
+ * creating a second session, and returns the existing id.
47
+ *
48
+ * The stored `command` is the desktop process key when the CLI was spawned by
49
+ * the app (EDSGER_PROCESS_KEY), so a feature/tab can find this session; see
50
+ * `resolvedCommand`.
31
51
  */
32
52
  export async function registerSession(options) {
53
+ // Already in a session for this process — enrich, don't double-register.
54
+ if (currentSessionId) {
55
+ await enrichSession(options);
56
+ return currentSessionId;
57
+ }
33
58
  const sessionId = generateSessionId();
34
59
  currentSessionId = sessionId;
60
+ const command = resolvedCommand(options);
35
61
  try {
36
62
  const userId = getUserId();
37
63
  if (hasSupabaseSession() && userId) {
@@ -44,8 +70,8 @@ export async function registerSession(options) {
44
70
  started_at: new Date().toISOString(),
45
71
  last_heartbeat: new Date().toISOString(),
46
72
  };
47
- if (options?.command) {
48
- row.command = options.command;
73
+ if (command) {
74
+ row.command = command;
49
75
  }
50
76
  if (options?.productId) {
51
77
  row.product_id = options.productId;
@@ -64,8 +90,8 @@ export async function registerSession(options) {
64
90
  version: getVersion(),
65
91
  status: 'running',
66
92
  };
67
- if (options?.command) {
68
- payload.command = options.command;
93
+ if (command) {
94
+ payload.command = command;
69
95
  }
70
96
  if (options?.productId) {
71
97
  payload.product_id = options.productId;
@@ -82,6 +108,41 @@ export async function registerSession(options) {
82
108
  initLogSync(sessionId);
83
109
  return sessionId;
84
110
  }
111
+ /**
112
+ * Best-effort enrichment of the already-active session row with command /
113
+ * product info (e.g. when a command registers itself after the top-level hook
114
+ * already opened the session). Direct-SDK path only; the MCP heartbeat path
115
+ * keeps whatever the initial register supplied.
116
+ */
117
+ async function enrichSession(options) {
118
+ const command = resolvedCommand(options);
119
+ if (!currentSessionId || (!command && !options?.productId)) {
120
+ return;
121
+ }
122
+ try {
123
+ const userId = getUserId();
124
+ if (!hasSupabaseSession() || !userId) {
125
+ return;
126
+ }
127
+ const patch = {};
128
+ // With a desktop process key present, `command` resolves to that key, so
129
+ // a self-registering command can't clobber it with its bare name.
130
+ if (command) {
131
+ patch.command = command;
132
+ }
133
+ if (options?.productId) {
134
+ patch.product_id = options.productId;
135
+ }
136
+ await getSupabase()
137
+ .from('cli_sessions')
138
+ .update(patch)
139
+ .eq('session_id', currentSessionId)
140
+ .eq('user_id', userId);
141
+ }
142
+ catch {
143
+ // best-effort
144
+ }
145
+ }
85
146
  /**
86
147
  * Send a heartbeat to keep the session alive and check for commands
87
148
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.70.0",
3
+ "version": "0.72.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"