edsger 0.72.3 → 0.72.5

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.
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * CLI command: `edsger quality-benchmark <productId>`
3
3
  *
4
- * Runs the quality-benchmark phase against the product's GitHub repo and
5
- * writes the resulting JSON report to disk. Persistence to the
6
- * `quality_reports` table is the caller's responsibility desktop-app
7
- * picks the JSON up via stdout / file and writes via its supabase
8
- * client, exactly the same pattern used by other CLI commands.
4
+ * Runs the quality-benchmark phase against the product's GitHub repo,
5
+ * writes the resulting JSON report to disk, and persists it to the
6
+ * `quality_reports` table via the supabase SDK (RLS-scoped; MCP
7
+ * fallback when no desktop session is synced) unless --no-save.
9
8
  *
10
9
  * Default invocation (desktop): no --repo, no --branch — the CLI fetches
11
10
  * the product's GitHub config via MCP, clones (or reuses) the repo into
@@ -33,7 +32,7 @@ export interface QualityBenchmarkCliOptions {
33
32
  install?: boolean;
34
33
  output?: string;
35
34
  verbose?: boolean;
36
- /** When false, skip the quality_reports/save MCP call. */
35
+ /** When false, skip persisting the report to quality_reports. */
37
36
  save?: boolean;
38
37
  /** Overwrite any existing report row for this (product, commit, rubric). */
39
38
  force?: boolean;
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * CLI command: `edsger quality-benchmark <productId>`
3
3
  *
4
- * Runs the quality-benchmark phase against the product's GitHub repo and
5
- * writes the resulting JSON report to disk. Persistence to the
6
- * `quality_reports` table is the caller's responsibility desktop-app
7
- * picks the JSON up via stdout / file and writes via its supabase
8
- * client, exactly the same pattern used by other CLI commands.
4
+ * Runs the quality-benchmark phase against the product's GitHub repo,
5
+ * writes the resulting JSON report to disk, and persists it to the
6
+ * `quality_reports` table via the supabase SDK (RLS-scoped; MCP
7
+ * fallback when no desktop session is synced) unless --no-save.
9
8
  *
10
9
  * Default invocation (desktop): no --repo, no --branch — the CLI fetches
11
10
  * the product's GitHub config via MCP, clones (or reuses) the repo into
@@ -26,10 +25,10 @@
26
25
  import { mkdirSync, writeFileSync } from 'node:fs';
27
26
  import { dirname, resolve } from 'node:path';
28
27
  import { getGitHubConfigByProduct, getGitHubConfigByRepository, getRepositoryBasics, } from '../../api/github.js';
29
- import { callMcpEndpoint } from '../../api/mcp-client.js';
30
28
  import { fetchProductBasics } from '../../phases/find-shared/mcp.js';
31
29
  import { runQualityBenchmark, } from '../../phases/quality-benchmark/index.js';
32
30
  import { prepareQualityWorkspace } from '../../phases/quality-benchmark/workspace.js';
31
+ import { saveQualityReport } from '../../services/quality-reports.js';
33
32
  import { getSupabase } from '../../supabase/client.js';
34
33
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
35
34
  export async function runQualityBenchmarkCli(productId, options) {
@@ -166,10 +165,12 @@ export async function runQualityBenchmarkCli(productId, options) {
166
165
  mkdirSync(dirname(outputPath), { recursive: true });
167
166
  writeFileSync(outputPath, JSON.stringify(reportEnvelope, null, 2), 'utf8');
168
167
  logSuccess(`Report written to ${outputPath}`);
169
- // Persist via MCP unless the caller opted out.
168
+ // Persist to quality_reports unless the caller opted out. Goes through
169
+ // the supabase SDK directly (RLS-scoped), with an MCP fallback for
170
+ // sessions authorized by an MCP token only.
170
171
  if (options.save !== false) {
171
172
  try {
172
- const saved = (await callMcpEndpoint('quality_reports/save', {
173
+ const saved = await saveQualityReport({
173
174
  product_id: repoOnly ? null : productId,
174
175
  repository_id: repositoryId,
175
176
  commit_sha: outcome.commitSha,
@@ -193,14 +194,13 @@ export async function runQualityBenchmarkCli(productId, options) {
193
194
  completed_at: outcome.completedAt,
194
195
  duration_seconds: outcome.durationSeconds,
195
196
  replace_existing: options.force === true,
196
- }));
197
- const row = JSON.parse(saved.content?.[0]?.text ?? '{}');
198
- if (row.id) {
199
- logSuccess(`Saved to quality_reports.id = ${row.id}`);
197
+ });
198
+ if (saved.id) {
199
+ logSuccess(`Saved to quality_reports.id = ${saved.id}`);
200
200
  }
201
201
  }
202
202
  catch (err) {
203
- logWarning(`Failed to persist report via MCP (will keep local file): ${err instanceof Error ? err.message : String(err)}`);
203
+ logWarning(`Failed to persist report (will keep local file): ${err instanceof Error ? err.message : String(err)}`);
204
204
  }
205
205
  }
206
206
  logInfo(`Overall: grade ${outcome.report.overall_grade ?? '?'} (${outcome.report.overall_score ?? '?'})`);
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Quality reports service — persists `edsger quality-benchmark` results.
3
+ *
4
+ * Writes go through the supabase SDK directly when the desktop-synced
5
+ * session is available (RLS gates access: product owner / active team
6
+ * member, or repo team access via `user_has_repository_access` for
7
+ * repo-only reports). Falls back to the MCP edge function
8
+ * (`quality_reports/save`) when no usable session is synced — e.g.
9
+ * standalone CLI runs authorized by an MCP token.
10
+ */
11
+ export interface SaveQualityReportParams {
12
+ /** Product scope; null for repo-only reports. */
13
+ product_id: string | null;
14
+ /** Repository scope; required when product_id is null. */
15
+ repository_id: string | null;
16
+ commit_sha: string;
17
+ rubric_version: string;
18
+ branch?: string | null;
19
+ repo_root?: string | null;
20
+ detected_context?: unknown;
21
+ tool_versions?: Record<string, string>;
22
+ unavailable_tools?: unknown[];
23
+ applied_checks?: unknown;
24
+ tool_outputs?: Record<string, unknown>;
25
+ external_signals?: unknown;
26
+ dropped_findings?: number;
27
+ dimension_scores?: unknown;
28
+ overall_score?: number | null;
29
+ overall_grade?: string | null;
30
+ executive_summary?: string | null;
31
+ low_confidence?: boolean;
32
+ status?: string;
33
+ started_at?: string | null;
34
+ completed_at?: string | null;
35
+ duration_seconds?: number | null;
36
+ /** Delete any existing row for this (scope, commit, rubric) before insert. */
37
+ replace_existing?: boolean;
38
+ }
39
+ /**
40
+ * Insert a completed quality report row. Returns the new row id, or null
41
+ * if the saved row could not be parsed (MCP path only).
42
+ */
43
+ export declare function saveQualityReport(params: SaveQualityReportParams): Promise<{
44
+ id: string | null;
45
+ }>;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Quality reports service — persists `edsger quality-benchmark` results.
3
+ *
4
+ * Writes go through the supabase SDK directly when the desktop-synced
5
+ * session is available (RLS gates access: product owner / active team
6
+ * member, or repo team access via `user_has_repository_access` for
7
+ * repo-only reports). Falls back to the MCP edge function
8
+ * (`quality_reports/save`) when no usable session is synced — e.g.
9
+ * standalone CLI runs authorized by an MCP token.
10
+ */
11
+ import { callMcpEndpoint } from '../api/mcp-client.js';
12
+ import { ensureSupabaseSession, getSupabase } from '../supabase/client.js';
13
+ /**
14
+ * Insert a completed quality report row. Returns the new row id, or null
15
+ * if the saved row could not be parsed (MCP path only).
16
+ */
17
+ export async function saveQualityReport(params) {
18
+ const { product_id, repository_id, commit_sha, rubric_version } = params;
19
+ if (!product_id && !repository_id) {
20
+ throw new Error('Either product_id or repository_id is required');
21
+ }
22
+ if (await ensureSupabaseSession()) {
23
+ const supabase = getSupabase();
24
+ if (params.replace_existing) {
25
+ // Clear the existing row on the natural cache key for this report's
26
+ // scope. Product-scoped: UNIQUE(product_id, commit_sha, rubric_version).
27
+ // Repo-only: UNIQUE(repository_id, commit_sha, rubric_version) WHERE
28
+ // product_id IS NULL — the repo-only delete MUST also require
29
+ // product_id IS NULL so it never clobbers a product report of the same
30
+ // linked repo at this commit.
31
+ let del = supabase
32
+ .from('quality_reports')
33
+ .delete()
34
+ .eq('commit_sha', commit_sha)
35
+ .eq('rubric_version', rubric_version);
36
+ del = product_id
37
+ ? del.eq('product_id', product_id)
38
+ : del.eq('repository_id', repository_id).is('product_id', null);
39
+ const { error: delErr } = await del;
40
+ if (delErr) {
41
+ throw new Error(`Failed to clear existing report: ${delErr.message}`);
42
+ }
43
+ }
44
+ const { data: { user }, } = await supabase.auth.getUser();
45
+ const { data, error } = await supabase
46
+ .from('quality_reports')
47
+ .insert({
48
+ product_id: product_id ?? null,
49
+ repository_id: repository_id ?? null,
50
+ commit_sha,
51
+ rubric_version,
52
+ branch: params.branch ?? null,
53
+ repo_root: params.repo_root ?? null,
54
+ detected_context: params.detected_context ?? {},
55
+ tool_versions: params.tool_versions ?? {},
56
+ unavailable_tools: params.unavailable_tools ?? [],
57
+ applied_checks: params.applied_checks ?? {},
58
+ tool_outputs: params.tool_outputs ?? {},
59
+ external_signals: params.external_signals ?? {},
60
+ dropped_findings: params.dropped_findings ?? 0,
61
+ dimension_scores: params.dimension_scores ?? {},
62
+ overall_score: params.overall_score ?? null,
63
+ overall_grade: params.overall_grade ?? null,
64
+ executive_summary: params.executive_summary ?? null,
65
+ low_confidence: params.low_confidence ?? false,
66
+ status: params.status ?? 'completed',
67
+ started_at: params.started_at ?? null,
68
+ completed_at: params.completed_at ?? new Date().toISOString(),
69
+ duration_seconds: params.duration_seconds ?? null,
70
+ created_by: user?.id ?? null,
71
+ })
72
+ .select('id')
73
+ .single();
74
+ if (error) {
75
+ if (error.code === '23505') {
76
+ throw new Error(`quality report already exists for ` +
77
+ `(${product_id ? 'product' : 'repository'}, commit, rubric); ` +
78
+ `pass --force to overwrite`);
79
+ }
80
+ throw new Error(`Failed to save quality report: ${error.message}`);
81
+ }
82
+ return { id: data?.id ?? null };
83
+ }
84
+ // No synced session — fall back to the MCP edge function (MCP-token auth).
85
+ const saved = (await callMcpEndpoint('quality_reports/save', params));
86
+ try {
87
+ const row = JSON.parse(saved.content?.[0]?.text ?? '{}');
88
+ return { id: row.id ?? null };
89
+ }
90
+ catch {
91
+ return { id: null };
92
+ }
93
+ }
@@ -1,5 +1,37 @@
1
1
  import { loadConfig } from '../config.js';
2
2
  import { type CliOptions } from '../types/index.js';
3
+ /**
4
+ * Determine the correct npm install target for optional dependencies.
5
+ *
6
+ * `Function('return import("pkg")')()` resolves from the edsger module's
7
+ * own location, walking up the node_modules chain. We need to install
8
+ * packages into a directory that sits on that resolution path.
9
+ *
10
+ * - Source checkout / monorepo → `npm install` in the edsger package dir
11
+ * - Project dependency → `npm install` in the project root (has package.json)
12
+ * - Global-style install → `npm install -g --prefix <prefix>`
13
+ *
14
+ * The global-style case covers both a plain `npm install -g edsger` and the
15
+ * desktop app's managed install (`npm install -g edsger --prefix <userData>/cli`).
16
+ * In both, edsger lives at `<prefix>/lib/node_modules/edsger` (or
17
+ * `<prefix>/node_modules/edsger` on Windows) and there is NO package.json at
18
+ * the directory that owns that node_modules. Running a *project-local*
19
+ * `npm install` there would treat every already-installed package — including
20
+ * edsger and all its dependencies — as extraneous and PRUNE them, destroying
21
+ * the install. We must install with `--prefix` into the same prefix instead.
22
+ */
23
+ export interface NpmInstallTarget {
24
+ global: boolean;
25
+ cwd?: string;
26
+ prefix?: string;
27
+ }
28
+ /**
29
+ * Pure resolution of the npm install target, given edsger's own directory and
30
+ * a predicate for testing whether a path exists. Separated from
31
+ * {@link getNpmInstallTarget} so it can be unit-tested without touching the
32
+ * real filesystem or `import.meta.url`.
33
+ */
34
+ export declare function resolveNpmInstallTarget(edsgerDir: string, fileExists: (p: string) => boolean): NpmInstallTarget;
3
35
  export interface ValidationResult {
4
36
  config: ReturnType<typeof loadConfig>;
5
37
  mcpServerUrl: string;
@@ -1,9 +1,37 @@
1
1
  import { execSync, spawnSync } from 'child_process';
2
- import { dirname, resolve, sep } from 'path';
2
+ import { existsSync } from 'fs';
3
+ import { basename, dirname, join, resolve, sep } from 'path';
3
4
  import { fileURLToPath } from 'url';
4
5
  import { getMcpServerUrl, getMcpToken } from '../auth/auth-store.js';
5
6
  import { loadConfig, validateConfig } from '../config.js';
6
7
  import { logInfo, logWarning } from './logger.js';
8
+ /**
9
+ * Pure resolution of the npm install target, given edsger's own directory and
10
+ * a predicate for testing whether a path exists. Separated from
11
+ * {@link getNpmInstallTarget} so it can be unit-tested without touching the
12
+ * real filesystem or `import.meta.url`.
13
+ */
14
+ export function resolveNpmInstallTarget(edsgerDir, fileExists) {
15
+ const nodeModulesSegment = `${sep}node_modules${sep}`;
16
+ if (!edsgerDir.includes(nodeModulesSegment)) {
17
+ // Source checkout / monorepo — install in edsger's own package dir
18
+ return { global: false, cwd: edsgerDir };
19
+ }
20
+ // edsger is inside some node_modules tree. The directory that owns the
21
+ // outermost node_modules containing edsger is the candidate install root.
22
+ const installRoot = edsgerDir.split(nodeModulesSegment)[0];
23
+ // A real project dependency has a package.json at that root, so a
24
+ // project-local install safely adds to its dependencies without pruning.
25
+ if (fileExists(join(installRoot, 'package.json'))) {
26
+ return { global: false, cwd: installRoot };
27
+ }
28
+ // No package.json → global-style install. Derive the npm prefix so the
29
+ // package lands on edsger's resolution path. Unix global installs nest
30
+ // under `<prefix>/lib`; Windows installs put node_modules directly under
31
+ // `<prefix>`.
32
+ const prefix = basename(installRoot) === 'lib' ? dirname(installRoot) : installRoot;
33
+ return { global: true, prefix };
34
+ }
7
35
  /**
8
36
  * Determine the correct npm install target for optional dependencies.
9
37
  *
@@ -11,33 +39,22 @@ import { logInfo, logWarning } from './logger.js';
11
39
  * own location, walking up the node_modules chain. We need to install
12
40
  * packages into a directory that sits on that resolution path.
13
41
  *
14
- * - Global install → `npm install -g`
15
- * - Project dependency → `npm install` in the project root
16
- * - Monorepo / development → `npm install` in the edsger package dir
42
+ * - Source checkout / monorepo → `npm install` in the edsger package dir
43
+ * - Project dependency → `npm install` in the project root (has package.json)
44
+ * - Global-style install → `npm install -g --prefix <prefix>`
45
+ *
46
+ * The global-style case covers both a plain `npm install -g edsger` and the
47
+ * desktop app's managed install (`npm install -g edsger --prefix <userData>/cli`).
48
+ * In both, edsger lives at `<prefix>/lib/node_modules/edsger` (or
49
+ * `<prefix>/node_modules/edsger` on Windows) and there is NO package.json at
50
+ * the directory that owns that node_modules. Running a *project-local*
51
+ * `npm install` there would treat every already-installed package — including
52
+ * edsger and all its dependencies — as extraneous and PRUNE them, destroying
53
+ * the install. We must install with `--prefix` into the same prefix instead.
17
54
  */
18
55
  function getNpmInstallTarget() {
19
56
  const edsgerDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
20
- const nodeModulesSegment = `${sep}node_modules${sep}`;
21
- if (!edsgerDir.includes(nodeModulesSegment)) {
22
- // Source checkout / monorepo — install in edsger's own package dir
23
- return { global: false, cwd: edsgerDir };
24
- }
25
- // edsger is inside some node_modules tree
26
- try {
27
- const globalPrefix = execSync('npm config get prefix', {
28
- encoding: 'utf-8',
29
- stdio: ['pipe', 'pipe', 'ignore'],
30
- }).trim();
31
- if (edsgerDir.startsWith(globalPrefix)) {
32
- return { global: true };
33
- }
34
- }
35
- catch {
36
- // ignore — fall through to project-local
37
- }
38
- // Project dependency — install at the project root (parent of node_modules)
39
- const projectRoot = edsgerDir.split(nodeModulesSegment)[0];
40
- return { global: false, cwd: projectRoot };
57
+ return resolveNpmInstallTarget(edsgerDir, existsSync);
41
58
  }
42
59
  /**
43
60
  * Common configuration validation for all CLI commands
@@ -106,7 +123,8 @@ const restartProcess = (envFlag) => {
106
123
  function npmInstall(packageName) {
107
124
  const target = getNpmInstallTarget();
108
125
  const globalFlag = target.global ? ' -g' : '';
109
- const cmd = `npm install${globalFlag} ${packageName}`;
126
+ const prefixFlag = target.prefix ? ` --prefix "${target.prefix}"` : '';
127
+ const cmd = `npm install${globalFlag}${prefixFlag} ${packageName}`;
110
128
  logInfo(` $ ${cmd}${target.cwd ? ` (in ${target.cwd})` : ''}`);
111
129
  execSync(cmd, { cwd: target.cwd, stdio: 'inherit' });
112
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.72.3",
3
+ "version": "0.72.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"
@@ -54,8 +54,8 @@
54
54
  "commander": "^12.0.0",
55
55
  "cosmiconfig": "^9.0.0",
56
56
  "dotenv": "^16.4.5",
57
- "edsger-contract": "0.9.1",
58
- "edsger-tools": "0.9.1",
57
+ "edsger-contract": "0.9.2",
58
+ "edsger-tools": "0.9.2",
59
59
  "gray-matter": "^4.0.3",
60
60
  "zod": "^4.0.0"
61
61
  },