@haystackeditor/cli 0.15.12 → 0.15.14

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.
@@ -4,8 +4,10 @@
4
4
  * Creates .haystack.json with auto-detected settings.
5
5
  */
6
6
  import chalk from 'chalk';
7
+ import * as fs from 'fs/promises';
7
8
  import * as path from 'path';
8
9
  import { detectProject } from '../utils/detect.js';
10
+ import { parseRemoteUrl, UnsupportedRemoteUrlError } from '../utils/git.js';
9
11
  import { saveConfig, configExists } from '../utils/config.js';
10
12
  import { validateConfigSecurity, formatSecurityReport } from '../utils/secrets.js';
11
13
  export async function initCommand(options) {
@@ -32,7 +34,10 @@ export async function initCommand(options) {
32
34
  }
33
35
  console.log('');
34
36
  // Build config from detection (no interactive prompts)
35
- const config = buildConfigFromDetection(detected);
37
+ const config = await buildConfigFromDetection(detected);
38
+ if (config.dev_server) {
39
+ console.log(` Dev server: ${chalk.bold(config.dev_server.command)} (port ${config.dev_server.port})\n`);
40
+ }
36
41
  const configPath = await saveConfig(config);
37
42
  console.log(chalk.green(`✓ Created ${configPath}`));
38
43
  // Security validation
@@ -53,12 +58,14 @@ async function runSecurityCheck(configPath) {
53
58
  console.log(chalk.dim('\n' + formatSecurityReport(findings)));
54
59
  }
55
60
  }
56
- function buildConfigFromDetection(detected) {
61
+ async function buildConfigFromDetection(detected) {
57
62
  const env = {};
58
63
  if (detected.suggestedAuthBypass) {
59
64
  const [key, value] = detected.suggestedAuthBypass.split('=');
60
65
  env[key] = value || 'true';
61
66
  }
67
+ const pkgJson = await readPackageJson();
68
+ const name = resolveProjectName(pkgJson);
62
69
  if (detected.isMonorepo && detected.services?.length) {
63
70
  const services = {};
64
71
  for (const s of detected.services) {
@@ -74,12 +81,72 @@ function buildConfigFromDetection(detected) {
74
81
  }
75
82
  return {
76
83
  version: '1',
77
- name: path.basename(process.cwd()),
84
+ name,
78
85
  services,
79
86
  };
80
87
  }
88
+ const devServer = buildDevServer(detected, pkgJson?.scripts, env);
81
89
  return {
82
90
  version: '1',
83
- name: path.basename(process.cwd()),
91
+ name,
92
+ ...(devServer ? { dev_server: devServer } : {}),
84
93
  };
85
94
  }
95
+ /**
96
+ * Prefer the GitHub repo name, then package.json name, then the directory
97
+ * name. The directory basename alone is often a worktree or clone-dir name
98
+ * that has nothing to do with the project.
99
+ */
100
+ function resolveProjectName(pkgJson) {
101
+ try {
102
+ return parseRemoteUrl().repo;
103
+ }
104
+ catch (err) {
105
+ // Classify by type, not message text (PR007). A remote that exists but
106
+ // isn't recognized deserves a warning — the fallback name may not match
107
+ // the repo. A missing remote/repo is normal for fresh projects.
108
+ if (err instanceof UnsupportedRemoteUrlError) {
109
+ console.log(chalk.yellow(`⚠ Git remote URL not recognized (${err.message}); using package.json/directory name instead`));
110
+ }
111
+ else {
112
+ console.log(chalk.dim(' No git remote found; using package.json/directory name'));
113
+ }
114
+ }
115
+ return pkgJson?.name || path.basename(process.cwd());
116
+ }
117
+ /**
118
+ * Build a dev_server block for single-repo projects, but only when there is
119
+ * real evidence of one: a dev/start script in package.json. Without that
120
+ * gate we'd fabricate commands like `npm dev` that don't exist.
121
+ */
122
+ function buildDevServer(detected, scripts, env) {
123
+ const script = scripts?.dev ? 'dev' : scripts?.start ? 'start' : null;
124
+ if (!script)
125
+ return undefined;
126
+ const pm = detected.packageManager;
127
+ // npm and bun need the `run` form; pnpm/yarn run scripts directly.
128
+ const command = pm === 'npm' || pm === 'bun' ? `${pm} run ${script}` : `${pm} ${script}`;
129
+ return {
130
+ command,
131
+ port: detected.suggestedPort || 3000,
132
+ ready_pattern: detected.suggestedReadyPattern || 'ready|started|listening|Local:',
133
+ ...(Object.keys(env).length > 0 ? { env } : {}),
134
+ };
135
+ }
136
+ async function readPackageJson() {
137
+ try {
138
+ const raw = await fs.readFile(path.join(process.cwd(), 'package.json'), 'utf-8');
139
+ return JSON.parse(raw);
140
+ }
141
+ catch (err) {
142
+ // A missing package.json is normal (non-Node projects). Anything else —
143
+ // unreadable file, malformed JSON — degrades name resolution and
144
+ // dev-server detection, so say so instead of silently falling back.
145
+ if (err.code === 'ENOENT') {
146
+ return null;
147
+ }
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ console.log(chalk.yellow(`⚠ Could not read package.json (${message}); skipping dev-server detection`));
150
+ return null;
151
+ }
152
+ }
@@ -94,32 +94,31 @@ const SEVERITY_LABELS = {
94
94
  medium: 'Medium',
95
95
  low: 'Low',
96
96
  };
97
- async function fetchUserRepos(token) {
98
- const repos = [];
99
- let page = 1;
100
- // Fetch up to 200 repos (non-archived, sorted by recent push)
101
- while (page <= 2) {
102
- const response = await fetch(`${GITHUB_API}/user/repos?sort=pushed&per_page=100&page=${page}&type=owner`, {
103
- headers: {
104
- Authorization: `Bearer ${token}`,
105
- Accept: 'application/vnd.github.v3+json',
106
- 'User-Agent': 'Haystack-CLI',
107
- },
108
- });
109
- if (!response.ok) {
110
- throw new Error(`Failed to fetch repos: ${response.status}`);
97
+ // =============================================================================
98
+ // GitHub API helpers
99
+ // =============================================================================
100
+ /**
101
+ * Source the onboarding repo list from the user's Haystack GitHub App
102
+ * installations (resolved server-side with the App JWT via
103
+ * /api/github/user-installations) instead of `GET /user/repos` with the user's
104
+ * OAuth token. Every repo here is covered by an installation, so the picker can
105
+ * only ever offer repos Haystack can actually access — no OAuth GitHub-data call,
106
+ * and no "access denied" downstream when a chosen repo turns out not to be
107
+ * install-covered. Sorted most-recently-pushed first.
108
+ */
109
+ async function fetchInstallCoveredRepos(token) {
110
+ const installations = await fetchHaystackInstallations(token);
111
+ const pushedByName = new Map();
112
+ for (const inst of installations) {
113
+ for (const repo of inst.repositories ?? []) {
114
+ if (repo?.full_name && !pushedByName.has(repo.full_name)) {
115
+ pushedByName.set(repo.full_name, repo.pushed_at ?? '');
116
+ }
111
117
  }
112
- const batch = (await response.json());
113
- if (batch.length === 0)
114
- break;
115
- repos.push(...batch);
116
- if (batch.length < 100)
117
- break;
118
- page++;
119
118
  }
120
- return repos
121
- .filter((r) => !r.archived)
122
- .map((r) => r.full_name);
119
+ return [...pushedByName.entries()]
120
+ .sort((a, b) => b[1].localeCompare(a[1])) // ISO pushed_at desc → recent first
121
+ .map(([fullName]) => fullName);
123
122
  }
124
123
  // Bootstrap PR constants — kept in sync with the web wizard
125
124
  // (src/features/onboarding/services/onboardingApi.ts). The branch prefix and
@@ -762,9 +761,10 @@ async function stepEnsureAppInstalled(token) {
762
761
  async function stepSelectRepos(token) {
763
762
  console.log(chalk.bold('\n Step 1: Select repositories\n'));
764
763
  console.log(chalk.dim(' Fetching your repositories...'));
765
- const repos = await fetchUserRepos(token);
764
+ const repos = await fetchInstallCoveredRepos(token);
766
765
  if (repos.length === 0) {
767
- console.log(chalk.yellow('\n No repositories found.\n'));
766
+ console.log(chalk.yellow('\n No repositories found on your Haystack App installation(s).'));
767
+ console.log(chalk.dim(` Add repositories to the install at ${HAYSTACK_APP_INSTALL_URL}, then re-run \`haystack setup\`.\n`));
768
768
  process.exit(1);
769
769
  }
770
770
  let selectedRepos = [];
package/dist/index.js CHANGED
@@ -42,7 +42,12 @@ import { schemaCommand, listSchemas } from './commands/schema-cmd.js';
42
42
  import { registerWebhook, listWebhooks, rotateWebhookSecret, setWebhookEnabled, listDeliveries, replayDelivery, } from './commands/webhooks.js';
43
43
  import { editRulesCommand, validateRulesCommand } from './commands/rules.js';
44
44
  import { headlessLoginCommand, headlessLogoutCommand, listTokensCommand, revokeTokenCommand, } from './commands/tokens.js';
45
- import { runMcpServer } from './commands/mcp.js';
45
+ // `mcp` is imported lazily inside its command action (below), NOT at the top level.
46
+ // It is the only module that pulls in `@modelcontextprotocol/sdk`; keeping it out of
47
+ // the startup import graph means a missing/broken SDK can never crash core commands
48
+ // like `triage`/`submit`. (0.15.12 shipped `mcp.js` but published its package.json
49
+ // WITHOUT `@modelcontextprotocol/sdk`, so the eager import here hard-crashed every
50
+ // command at startup with ERR_MODULE_NOT_FOUND.)
46
51
  // pr-state read helpers (parsePrRef, prReadCommand) are imported by the MCP
47
52
  // server directly from ./commands/pr.js; no top-level CLI command is wired
48
53
  // here. The previous `pr <ref> [show|merge|snooze|dismiss]` and `fix <ref>`
@@ -818,10 +823,19 @@ program
818
823
  .description('Run a stdio MCP server exposing Haystack tools to Claude Code, Cursor, etc.')
819
824
  .action(async () => {
820
825
  try {
826
+ // Lazy import: only this command needs @modelcontextprotocol/sdk, so we keep it
827
+ // out of the startup graph (see the note where the eager import used to be).
828
+ const { runMcpServer } = await import('./commands/mcp.js');
821
829
  await runMcpServer();
822
830
  }
823
831
  catch (err) {
824
- console.error(chalk.red('mcp server failed:'), err instanceof Error ? err.message : err);
832
+ const code = err?.code;
833
+ if (code === 'ERR_MODULE_NOT_FOUND') {
834
+ console.error(chalk.red('mcp server failed:'), 'the @modelcontextprotocol/sdk package is missing. Reinstall the CLI to pull it in (e.g. `npm i -g @haystackeditor/cli@latest`).');
835
+ }
836
+ else {
837
+ console.error(chalk.red('mcp server failed:'), err instanceof Error ? err.message : err);
838
+ }
825
839
  process.exit(1);
826
840
  }
827
841
  });
@@ -283,7 +283,9 @@ async function detectAuthBypass(rootDir) {
283
283
  // File not found
284
284
  }
285
285
  }
286
- return 'SKIP_AUTH=true'; // Default suggestion
286
+ // No evidence of an auth-bypass variable — suggest nothing rather than
287
+ // fabricating a SKIP_AUTH the app doesn't read.
288
+ return undefined;
287
289
  }
288
290
  /**
289
291
  * Set suggestions based on detected framework
package/dist/utils/git.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Provides functions for git operations: branch management, push, remote parsing.
5
5
  */
6
- import { execSync } from 'child_process';
6
+ import { execFileSync, execSync } from 'child_process';
7
7
  import * as fs from 'fs';
8
8
  import * as os from 'os';
9
9
  import * as path from 'path';
@@ -369,7 +369,7 @@ export function getCommitMessagesSinceBase(baseBranch) {
369
369
  /** True if `ref` resolves to a commit in the current repo. */
370
370
  function gitRefResolves(ref) {
371
371
  try {
372
- execSync(`git rev-parse --verify --quiet ${ref}^{commit}`, {
372
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', '--', `${ref}^{commit}`], {
373
373
  stdio: ['pipe', 'pipe', 'pipe'],
374
374
  });
375
375
  return true;
@@ -382,7 +382,7 @@ function gitRefResolves(ref) {
382
382
  function gitIsAncestor(ancestor, descendant) {
383
383
  try {
384
384
  // exit 0 => ancestor; exit 1 => not; both non-throwing for us via stdio pipe
385
- execSync(`git merge-base --is-ancestor ${ancestor} ${descendant}`, {
385
+ execFileSync('git', ['merge-base', '--is-ancestor', '--', ancestor, descendant], {
386
386
  stdio: ['pipe', 'pipe', 'pipe'],
387
387
  });
388
388
  return true;
@@ -440,7 +440,7 @@ export function resolveDiffBaseRef(baseBranch) {
440
440
  // if <base> was rewound on the remote).
441
441
  let fetched = false;
442
442
  try {
443
- execSync(`git fetch origin +refs/heads/${baseBranch}:${remoteRef}`, {
443
+ execFileSync('git', ['fetch', 'origin', `+refs/heads/${baseBranch}:${remoteRef}`], {
444
444
  stdio: ['pipe', 'pipe', 'pipe'],
445
445
  });
446
446
  fetched = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.12",
3
+ "version": "0.15.14",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,6 +34,7 @@
34
34
  "url": "https://github.com/haystackeditor/haystack-review/issues"
35
35
  },
36
36
  "dependencies": {
37
+ "@modelcontextprotocol/sdk": "1.26.0",
37
38
  "chalk": "5.6.2",
38
39
  "commander": "12.1.0",
39
40
  "fast-glob": "3.3.3",