@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.
- package/dist/commands/init.js +71 -4
- package/dist/commands/setup.js +26 -26
- package/dist/index.js +16 -2
- package/dist/utils/detect.js +3 -1
- package/dist/utils/git.js +4 -4
- package/package.json +2 -1
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -94,32 +94,31 @@ const SEVERITY_LABELS = {
|
|
|
94
94
|
medium: 'Medium',
|
|
95
95
|
low: 'Low',
|
|
96
96
|
};
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
121
|
-
.
|
|
122
|
-
.map((
|
|
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
|
|
764
|
+
const repos = await fetchInstallCoveredRepos(token);
|
|
766
765
|
if (repos.length === 0) {
|
|
767
|
-
console.log(chalk.yellow('\n No repositories found
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/utils/detect.js
CHANGED
|
@@ -283,7 +283,9 @@ async function detectAuthBypass(rootDir) {
|
|
|
283
283
|
// File not found
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|