@haystackeditor/cli 0.14.5 → 0.15.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/commands/setup.d.ts +16 -1
- package/dist/commands/setup.js +196 -186
- package/dist/index.js +34 -9
- package/dist/triage/prompts.js +35 -4
- package/dist/triage/types.d.ts +1 -1
- package/dist/utils/prompter.d.ts +74 -0
- package/dist/utils/prompter.js +226 -0
- package/package.json +1 -1
package/dist/commands/setup.d.ts
CHANGED
|
@@ -11,4 +11,19 @@
|
|
|
11
11
|
* 7. Confirm & write .haystack.json to selected repos
|
|
12
12
|
* 8. Install session tracking (Entire CLI + .entire/settings.json + local hooks)
|
|
13
13
|
*/
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Flags accepted by `haystack setup`. With none, it runs the interactive TTY
|
|
16
|
+
* wizard. `--json` switches to the coding-agent protocol (NDJSON on stdout,
|
|
17
|
+
* answers on stdin). Any pre-supplied answer (a flag or --answers entry) skips
|
|
18
|
+
* the matching prompt, so the same flow runs fully autonomously when every
|
|
19
|
+
* decision is known up-front.
|
|
20
|
+
*/
|
|
21
|
+
export interface SetupOptions {
|
|
22
|
+
json?: boolean;
|
|
23
|
+
repos?: string[];
|
|
24
|
+
yes?: boolean;
|
|
25
|
+
autoMerge?: boolean;
|
|
26
|
+
skipEntire?: boolean;
|
|
27
|
+
answersFile?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function setupCommand(options?: SetupOptions): Promise<void>;
|
package/dist/commands/setup.js
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
* 8. Install session tracking (Entire CLI + .entire/settings.json + local hooks)
|
|
13
13
|
*/
|
|
14
14
|
import chalk from 'chalk';
|
|
15
|
-
import inquirer from 'inquirer';
|
|
16
15
|
import { execFileSync, execSync } from 'child_process';
|
|
17
16
|
import { mkdirSync } from 'fs';
|
|
18
17
|
import { basename, join } from 'path';
|
|
@@ -20,6 +19,41 @@ import { resolveAuthContext } from '../utils/auth.js';
|
|
|
20
19
|
import { hooksInstall } from './hooks.js';
|
|
21
20
|
import { findGitRoot } from '../utils/hooks.js';
|
|
22
21
|
import { trackError, trackSetupEvent } from '../utils/telemetry.js';
|
|
22
|
+
import { createPrompter, loadAnswersFile } from '../utils/prompter.js';
|
|
23
|
+
/** The active prompter for this `setup` invocation (TTY or JSON). */
|
|
24
|
+
let ui;
|
|
25
|
+
/**
|
|
26
|
+
* Translate CLI flags into a pre-supplied answer map keyed by question id.
|
|
27
|
+
* Explicit flags win; `--answers` fills the rest. Anything still missing is
|
|
28
|
+
* asked at runtime (TTY prompt or JSON `question` event).
|
|
29
|
+
*/
|
|
30
|
+
function buildAnswerMap(options) {
|
|
31
|
+
const answers = options.answersFile ? loadAnswersFile(options.answersFile) : {};
|
|
32
|
+
if (options.repos && options.repos.length > 0)
|
|
33
|
+
answers.select_repos = options.repos;
|
|
34
|
+
if (typeof options.autoMerge === 'boolean')
|
|
35
|
+
answers.auto_merge = options.autoMerge;
|
|
36
|
+
if (options.skipEntire) {
|
|
37
|
+
answers.install_entire = false;
|
|
38
|
+
answers.upgrade_entire = false;
|
|
39
|
+
answers.install_hooks = false;
|
|
40
|
+
}
|
|
41
|
+
if (options.yes) {
|
|
42
|
+
// Accept defaults non-interactively: don't open the toggle UI (keep every
|
|
43
|
+
// discovered item) and confirm the write. Only fill ids not already set.
|
|
44
|
+
if (!('want_to_toggle' in answers))
|
|
45
|
+
answers.want_to_toggle = false;
|
|
46
|
+
if (!('confirm_write' in answers))
|
|
47
|
+
answers.confirm_write = true;
|
|
48
|
+
if (!('auto_merge' in answers))
|
|
49
|
+
answers.auto_merge = true;
|
|
50
|
+
if (!('install_entire' in answers))
|
|
51
|
+
answers.install_entire = false;
|
|
52
|
+
if (!('install_hooks' in answers))
|
|
53
|
+
answers.install_hooks = false;
|
|
54
|
+
}
|
|
55
|
+
return answers;
|
|
56
|
+
}
|
|
23
57
|
// =============================================================================
|
|
24
58
|
// Constants
|
|
25
59
|
// =============================================================================
|
|
@@ -310,12 +344,16 @@ async function buildEntireSettingsContent(repoFullName, token) {
|
|
|
310
344
|
return null;
|
|
311
345
|
return { content: updatedContent, existingSha };
|
|
312
346
|
}
|
|
313
|
-
async function addBootstrapLabels(owner, repo, prNumber, prUrl, token) {
|
|
314
|
-
// CRITICAL, not best-effort
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
347
|
+
async function addBootstrapLabels(owner, repo, prNumber, prUrl, token, autoMerge) {
|
|
348
|
+
// CRITICAL, not best-effort. ONBOARDING_BOOTSTRAP_LABEL is the identity marker
|
|
349
|
+
// that keeps the PR recognized in the user's Analyzing / Good to Merge tabs
|
|
350
|
+
// (isOnboardingBootstrapPR) and lets the worker's bootstrap path discover it —
|
|
351
|
+
// so it's ALWAYS applied. AUTO_MERGE_LABEL enrolls the PR for auto-merge, so add
|
|
352
|
+
// it only when auto-merge is on. The label API failing is almost always
|
|
353
|
+
// transient, so retry a few times before giving up.
|
|
354
|
+
const labels = autoMerge
|
|
355
|
+
? [ONBOARDING_BOOTSTRAP_LABEL, AUTO_MERGE_LABEL]
|
|
356
|
+
: [ONBOARDING_BOOTSTRAP_LABEL];
|
|
319
357
|
let lastErr = '';
|
|
320
358
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
321
359
|
const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/issues/${prNumber}/labels`, {
|
|
@@ -444,14 +482,11 @@ async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBra
|
|
|
444
482
|
const commitSha = await createCommitWithFiles(owner, repo, baseSha, files, CONFIG_COMMIT_MSG, token);
|
|
445
483
|
await createBranch(owner, repo, branchName, commitSha, token);
|
|
446
484
|
const pr = await createBootstrapPR(owner, repo, branchName, defaultBranch, token, autoMergeEnabled);
|
|
447
|
-
//
|
|
448
|
-
// the
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
if (autoMergeEnabled) {
|
|
453
|
-
await addBootstrapLabels(owner, repo, pr.number, pr.html_url, token);
|
|
454
|
-
}
|
|
485
|
+
// Always apply the onboarding-bootstrap marker so the PR stays recognized in
|
|
486
|
+
// the user's tabs (isOnboardingBootstrapPR); enroll for auto-merge only when
|
|
487
|
+
// the repo hasn't opted out. With auto-merge off the PR is still opened, marked,
|
|
488
|
+
// and analyzed — it just waits for the user to merge it.
|
|
489
|
+
await addBootstrapLabels(owner, repo, pr.number, pr.html_url, token, autoMergeEnabled);
|
|
455
490
|
return { prUrl: pr.html_url, entireSettingsBundled };
|
|
456
491
|
}
|
|
457
492
|
/**
|
|
@@ -551,7 +586,7 @@ async function runUnifiedScan(repo, token) {
|
|
|
551
586
|
}
|
|
552
587
|
if (statusResp.status === 404) {
|
|
553
588
|
// State expired (TTL) or runId never registered — surface, don't hang.
|
|
554
|
-
|
|
589
|
+
ui.clearProgress();
|
|
555
590
|
throw new Error('Scan state expired — please retry.');
|
|
556
591
|
}
|
|
557
592
|
if (!statusResp.ok) {
|
|
@@ -570,14 +605,11 @@ async function runUnifiedScan(repo, token) {
|
|
|
570
605
|
const key = `${state.progress.message}|${state.progress.detail ?? ''}`;
|
|
571
606
|
if (key !== lastProgressKey) {
|
|
572
607
|
lastProgressKey = key;
|
|
573
|
-
|
|
574
|
-
if (state.progress.detail)
|
|
575
|
-
process.stdout.write(chalk.dim(` — ${state.progress.detail}`));
|
|
576
|
-
process.stdout.write('\x1b[K');
|
|
608
|
+
ui.progress(state.progress.message, state.progress.detail);
|
|
577
609
|
}
|
|
578
610
|
}
|
|
579
611
|
if (state.status === 'done') {
|
|
580
|
-
|
|
612
|
+
ui.clearProgress();
|
|
581
613
|
if (!state.result)
|
|
582
614
|
throw new Error('Scan completed but returned no result.');
|
|
583
615
|
return {
|
|
@@ -587,11 +619,11 @@ async function runUnifiedScan(repo, token) {
|
|
|
587
619
|
};
|
|
588
620
|
}
|
|
589
621
|
if (state.status === 'error') {
|
|
590
|
-
|
|
622
|
+
ui.clearProgress();
|
|
591
623
|
throw new Error(state.error || 'Scan failed');
|
|
592
624
|
}
|
|
593
625
|
if (state.status === 'running' && Date.now() - state.updatedAt > SCAN_STALE_MS) {
|
|
594
|
-
|
|
626
|
+
ui.clearProgress();
|
|
595
627
|
throw new Error('Scan stalled (no progress) — please retry.');
|
|
596
628
|
}
|
|
597
629
|
}
|
|
@@ -641,26 +673,33 @@ class InstallationsAuthError extends Error {
|
|
|
641
673
|
}
|
|
642
674
|
}
|
|
643
675
|
async function fetchHaystackInstallations(token) {
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
|
|
676
|
+
// Detect the Haystack App via the auth-worker, NOT GitHub's
|
|
677
|
+
// `GET /user/installations`. That GitHub endpoint requires a GitHub App
|
|
678
|
+
// user-to-server token; the CLI's `haystack login` token is a classic OAuth
|
|
679
|
+
// token, so GitHub answers 403 ("must authenticate with an access token
|
|
680
|
+
// authorized to a GitHub App") — which made step 0 falsely report the App as
|
|
681
|
+
// uninstalled and exit. The auth-worker resolves installations server-side
|
|
682
|
+
// with the App's own JWT, so it works with the CLI's token. Every entry it
|
|
683
|
+
// returns IS a Haystack installation (the worker only knows about this App),
|
|
684
|
+
// so there's no app_slug to filter on.
|
|
685
|
+
const res = await fetch(`${HAYSTACK_API}/api/github/user-installations`, {
|
|
647
686
|
headers: {
|
|
648
687
|
Authorization: `Bearer ${token}`,
|
|
649
|
-
Accept: 'application/
|
|
688
|
+
Accept: 'application/json',
|
|
650
689
|
'User-Agent': 'Haystack-CLI',
|
|
651
690
|
},
|
|
652
691
|
});
|
|
653
692
|
if (!res.ok) {
|
|
654
|
-
// 401/403 are auth
|
|
655
|
-
// the
|
|
656
|
-
// type so the poll loop can
|
|
693
|
+
// 401/403 here are genuine auth failures (the token the worker validates is
|
|
694
|
+
// bad), unlike the GitHub endpoint's spurious 403. Retrying won't recover;
|
|
695
|
+
// bail with a distinct error type so the poll loop can prompt re-auth.
|
|
657
696
|
if (res.status === 401 || res.status === 403) {
|
|
658
697
|
throw new InstallationsAuthError(res.status, `${res.status} ${await res.text()}`);
|
|
659
698
|
}
|
|
660
699
|
throw new Error(`failed to fetch installations: ${res.status} ${await res.text()}`);
|
|
661
700
|
}
|
|
662
701
|
const data = (await res.json());
|
|
663
|
-
return
|
|
702
|
+
return data.installations ?? [];
|
|
664
703
|
}
|
|
665
704
|
function tryOpenBrowser(url) {
|
|
666
705
|
// Best-effort browser open. Never blocks setup — the URL is also printed.
|
|
@@ -687,16 +726,24 @@ function tryOpenBrowser(url) {
|
|
|
687
726
|
return false;
|
|
688
727
|
}
|
|
689
728
|
}
|
|
729
|
+
/** True if the App is installed; tolerates transient errors (returns false so
|
|
730
|
+
* the poll keeps waiting) but rethrows a genuine auth failure so the caller can
|
|
731
|
+
* bail and prompt re-auth. */
|
|
732
|
+
async function isAppInstalled(token) {
|
|
733
|
+
return (await fetchHaystackInstallations(token)).length > 0;
|
|
734
|
+
}
|
|
690
735
|
async function stepEnsureAppInstalled(token) {
|
|
691
736
|
console.log(chalk.bold(' Step 0: Verify GitHub App'));
|
|
692
|
-
|
|
737
|
+
// Initial check — distinguish a genuine auth failure (bail, prompt re-auth)
|
|
738
|
+
// from a transient probe error (refuse-to-fail and proceed).
|
|
693
739
|
try {
|
|
694
|
-
|
|
740
|
+
if (await isAppInstalled(token)) {
|
|
741
|
+
console.log(chalk.green(' ✓ Haystack App installed\n'));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
695
744
|
}
|
|
696
745
|
catch (err) {
|
|
697
746
|
if (err instanceof InstallationsAuthError) {
|
|
698
|
-
// Token is dead — retrying won't help. Prompt re-auth and exit so the
|
|
699
|
-
// user doesn't waste a 10-minute poll on something that will never work.
|
|
700
747
|
console.log(chalk.yellow(`\n Your GitHub token is no longer valid (${err.status}).`));
|
|
701
748
|
console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
|
|
702
749
|
trackError('haystack_setup_installations_auth_failure', { status: err.status });
|
|
@@ -704,81 +751,47 @@ async function stepEnsureAppInstalled(token) {
|
|
|
704
751
|
}
|
|
705
752
|
console.log(chalk.yellow(` Could not check App installations: ${err.message}`));
|
|
706
753
|
console.log(chalk.dim(` Continuing — install manually at ${HAYSTACK_APP_INSTALL_URL} if not yet installed.\n`));
|
|
707
|
-
|
|
708
|
-
// brick setup). Still log so we can see if probes start failing
|
|
709
|
-
// systemically across users.
|
|
710
|
-
trackError('haystack_setup_installations_probe_failed', {
|
|
711
|
-
error_message: err.message,
|
|
712
|
-
});
|
|
713
|
-
return;
|
|
714
|
-
}
|
|
715
|
-
if (installed.length > 0) {
|
|
716
|
-
const accounts = installed.map((i) => i.account?.login).filter(Boolean).join(', ');
|
|
717
|
-
console.log(chalk.green(` ✓ Haystack App installed${accounts ? ` (${accounts})` : ''}\n`));
|
|
754
|
+
trackError('haystack_setup_installations_probe_failed', { error_message: err.message });
|
|
718
755
|
return;
|
|
719
756
|
}
|
|
720
757
|
console.log(chalk.yellow(' The Haystack GitHub App is not installed yet.'));
|
|
721
|
-
console.log(chalk.dim(
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
758
|
+
console.log(chalk.dim(" Without it, Haystack can't analyze, triage, or merge your PRs."));
|
|
759
|
+
// Only auto-open a browser for a local interactive user. Under a coding agent
|
|
760
|
+
// (JSON mode) the `action` event carries the URL for the agent to relay.
|
|
761
|
+
if (ui.interactive) {
|
|
762
|
+
const opened = tryOpenBrowser(HAYSTACK_APP_INSTALL_URL);
|
|
763
|
+
if (opened)
|
|
764
|
+
console.log(chalk.dim(' (Opened in your browser. Pick the repos to grant access.)'));
|
|
765
|
+
}
|
|
766
|
+
// ui.action emits an `action` event (JSON) or prints + spins (TTY), then polls
|
|
767
|
+
// until the App is detected or it times out. A mid-poll auth failure surfaces
|
|
768
|
+
// as a thrown InstallationsAuthError.
|
|
769
|
+
let installed = false;
|
|
770
|
+
try {
|
|
771
|
+
installed = await ui.action({
|
|
772
|
+
id: 'install_app',
|
|
773
|
+
message: 'Install the Haystack GitHub App, then it is detected automatically.',
|
|
774
|
+
url: HAYSTACK_APP_INSTALL_URL,
|
|
775
|
+
check: () => isAppInstalled(token),
|
|
776
|
+
pollIntervalMs: APP_INSTALL_POLL_INTERVAL_MS,
|
|
777
|
+
timeoutMs: APP_INSTALL_TIMEOUT_MS,
|
|
778
|
+
});
|
|
727
779
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
await new Promise((resolve) => setTimeout(resolve, APP_INSTALL_POLL_INTERVAL_MS));
|
|
735
|
-
let current;
|
|
736
|
-
try {
|
|
737
|
-
current = await fetchHaystackInstallations(token);
|
|
738
|
-
consecutiveFailures = 0;
|
|
739
|
-
lastError = null;
|
|
740
|
-
}
|
|
741
|
-
catch (err) {
|
|
742
|
-
// Auth errors during the poll loop mean the token went bad mid-flow
|
|
743
|
-
// (e.g. user revoked it from GitHub settings). Same response as the
|
|
744
|
-
// initial check — bail with re-auth guidance instead of grinding for
|
|
745
|
-
// 10 minutes against a permanent failure.
|
|
746
|
-
if (err instanceof InstallationsAuthError) {
|
|
747
|
-
console.log(chalk.yellow(`\n Your GitHub token became invalid (${err.status}) while waiting.`));
|
|
748
|
-
console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
|
|
749
|
-
trackError('haystack_setup_installations_auth_failure', { status: err.status, during: 'poll' });
|
|
750
|
-
process.exit(1);
|
|
751
|
-
}
|
|
752
|
-
// Per-tick failures are usually transient (token refresh race, brief
|
|
753
|
-
// GitHub API blip). Log the first one quietly so the user sees we're
|
|
754
|
-
// not silently spinning, but don't spam the terminal every 3s.
|
|
755
|
-
consecutiveFailures += 1;
|
|
756
|
-
lastError = err instanceof Error ? err.message : String(err);
|
|
757
|
-
if (consecutiveFailures === 1) {
|
|
758
|
-
console.log(chalk.dim(` (Transient error checking installations: ${lastError} — retrying...)`));
|
|
759
|
-
}
|
|
760
|
-
continue;
|
|
761
|
-
}
|
|
762
|
-
if (current.length > 0) {
|
|
763
|
-
const accounts = current.map((i) => i.account?.login).filter(Boolean).join(', ');
|
|
764
|
-
console.log(chalk.green(`\n ✓ App installed${accounts ? ` (${accounts})` : ''}\n`));
|
|
765
|
-
return;
|
|
780
|
+
catch (err) {
|
|
781
|
+
if (err instanceof InstallationsAuthError) {
|
|
782
|
+
console.log(chalk.yellow(`\n Your GitHub token became invalid (${err.status}) while waiting.`));
|
|
783
|
+
console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
|
|
784
|
+
trackError('haystack_setup_installations_auth_failure', { status: err.status, during: 'poll' });
|
|
785
|
+
process.exit(1);
|
|
766
786
|
}
|
|
787
|
+
throw err;
|
|
767
788
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
// non-null after the final tick) so we can tell "user didn't click install"
|
|
772
|
-
// apart from "GitHub API was down."
|
|
773
|
-
trackError('haystack_setup_app_install_timeout', {
|
|
774
|
-
consecutive_failures: consecutiveFailures,
|
|
775
|
-
last_error: lastError,
|
|
776
|
-
timeout_ms: APP_INSTALL_TIMEOUT_MS,
|
|
777
|
-
});
|
|
778
|
-
console.log(chalk.yellow('\n Timed out waiting for App install.'));
|
|
779
|
-
if (lastError) {
|
|
780
|
-
console.log(chalk.dim(` Last error checking installations: ${lastError}`));
|
|
789
|
+
if (installed) {
|
|
790
|
+
console.log(chalk.green('\n ✓ App installed\n'));
|
|
791
|
+
return;
|
|
781
792
|
}
|
|
793
|
+
trackError('haystack_setup_app_install_timeout', { timeout_ms: APP_INSTALL_TIMEOUT_MS });
|
|
794
|
+
console.log(chalk.yellow('\n Timed out waiting for App install.'));
|
|
782
795
|
console.log(chalk.dim(` Install at ${HAYSTACK_APP_INSTALL_URL}, then re-run \`haystack setup\`.\n`));
|
|
783
796
|
process.exit(1);
|
|
784
797
|
}
|
|
@@ -790,22 +803,15 @@ async function stepSelectRepos(token) {
|
|
|
790
803
|
console.log(chalk.yellow('\n No repositories found.\n'));
|
|
791
804
|
process.exit(1);
|
|
792
805
|
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
{
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
validate: (answer) => {
|
|
803
|
-
if (answer.length === 0)
|
|
804
|
-
return 'Select at least one repository.';
|
|
805
|
-
return true;
|
|
806
|
-
},
|
|
807
|
-
},
|
|
808
|
-
]);
|
|
806
|
+
const selectedRepos = await ui.multiselect({
|
|
807
|
+
id: 'select_repos',
|
|
808
|
+
message: 'Select repositories to configure:',
|
|
809
|
+
choices: repos.map((r) => ({ name: r, value: r })),
|
|
810
|
+
});
|
|
811
|
+
if (selectedRepos.length === 0) {
|
|
812
|
+
console.log(chalk.yellow('\n No repositories selected.\n'));
|
|
813
|
+
process.exit(1);
|
|
814
|
+
}
|
|
809
815
|
console.log(chalk.green(` ${selectedRepos.length} repo(s) selected\n`));
|
|
810
816
|
return selectedRepos;
|
|
811
817
|
}
|
|
@@ -879,14 +885,11 @@ async function stepReview(byRepo) {
|
|
|
879
885
|
result.instructions.forEach((ins, i) => printInstruction(ins, i));
|
|
880
886
|
}
|
|
881
887
|
}
|
|
882
|
-
const
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
default: false,
|
|
888
|
-
},
|
|
889
|
-
]);
|
|
888
|
+
const wantToToggle = await ui.confirm({
|
|
889
|
+
id: 'want_to_toggle',
|
|
890
|
+
message: 'Would you like to toggle any items?',
|
|
891
|
+
default: false,
|
|
892
|
+
});
|
|
890
893
|
if (!wantToToggle)
|
|
891
894
|
return;
|
|
892
895
|
// NUL separates repo / kind / id in the checkbox value so it can't collide
|
|
@@ -896,7 +899,7 @@ async function stepReview(byRepo) {
|
|
|
896
899
|
for (const [repo, result] of byRepo) {
|
|
897
900
|
if (result.rules.length + result.policies.length + result.instructions.length === 0)
|
|
898
901
|
continue;
|
|
899
|
-
allItems.push(
|
|
902
|
+
allItems.push({ separator: `── ${repo} ──` });
|
|
900
903
|
result.rules.forEach((r) => {
|
|
901
904
|
allItems.push({
|
|
902
905
|
name: `${r.name} ${chalk.dim(`[rule · ${CATEGORY_LABELS[r.category] || r.category}]`)}`,
|
|
@@ -919,15 +922,11 @@ async function stepReview(byRepo) {
|
|
|
919
922
|
});
|
|
920
923
|
});
|
|
921
924
|
}
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
choices: allItems,
|
|
928
|
-
pageSize: 20,
|
|
929
|
-
},
|
|
930
|
-
]);
|
|
925
|
+
const enabled = await ui.multiselect({
|
|
926
|
+
id: 'review_enabled_items',
|
|
927
|
+
message: 'Select items to enable:',
|
|
928
|
+
choices: allItems,
|
|
929
|
+
});
|
|
931
930
|
const enabledSet = new Set(enabled);
|
|
932
931
|
for (const [repo, result] of byRepo) {
|
|
933
932
|
result.rules.forEach((r) => { r.enabled = enabledSet.has(`${repo}${SEP}rule${SEP}${r.id}`); });
|
|
@@ -1083,22 +1082,16 @@ async function stepConfirm(byRepo, token) {
|
|
|
1083
1082
|
// + the repo-level preferences.auto_merge opt-in model). When off, the
|
|
1084
1083
|
// bootstrap PR is opened but NOT labeled, so the user merges it manually and
|
|
1085
1084
|
// no PRs auto-merge.
|
|
1086
|
-
const
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
type: 'confirm',
|
|
1097
|
-
name: 'confirmed',
|
|
1098
|
-
message: `Write Haystack config to ${selectedRepos.length} repo(s)?`,
|
|
1099
|
-
default: true,
|
|
1100
|
-
},
|
|
1101
|
-
]);
|
|
1085
|
+
const autoMerge = await ui.confirm({
|
|
1086
|
+
id: 'auto_merge',
|
|
1087
|
+
message: 'Enable auto-merge? (Haystack merges PRs automatically once they pass review)',
|
|
1088
|
+
default: true,
|
|
1089
|
+
});
|
|
1090
|
+
const confirmed = await ui.confirm({
|
|
1091
|
+
id: 'confirm_write',
|
|
1092
|
+
message: `Write Haystack config to ${selectedRepos.length} repo(s)?`,
|
|
1093
|
+
default: true,
|
|
1094
|
+
});
|
|
1102
1095
|
if (!confirmed) {
|
|
1103
1096
|
console.log(chalk.yellow('\n Setup cancelled.\n'));
|
|
1104
1097
|
return { confirmed: false, bootstrapPRs: new Map() };
|
|
@@ -1110,9 +1103,9 @@ async function stepConfirm(byRepo, token) {
|
|
|
1110
1103
|
const result = byRepo.get(repoFullName);
|
|
1111
1104
|
const files = buildOnboardingFiles(result, autoMerge);
|
|
1112
1105
|
try {
|
|
1113
|
-
|
|
1106
|
+
ui.progress(`Writing to ${repoFullName}...`);
|
|
1114
1107
|
const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token);
|
|
1115
|
-
|
|
1108
|
+
ui.clearProgress();
|
|
1116
1109
|
if (writeResult.bootstrapPR) {
|
|
1117
1110
|
// Default branch was protected — config landed in a bootstrap PR. With
|
|
1118
1111
|
// auto-merge on, Haystack merges it once its labels are processed; with
|
|
@@ -1130,7 +1123,7 @@ async function stepConfirm(byRepo, token) {
|
|
|
1130
1123
|
}
|
|
1131
1124
|
}
|
|
1132
1125
|
catch (err) {
|
|
1133
|
-
|
|
1126
|
+
ui.clearProgress();
|
|
1134
1127
|
console.log(chalk.red(` ✗ ${repoFullName}: ${err.message}`));
|
|
1135
1128
|
failedRepos.push(repoFullName);
|
|
1136
1129
|
}
|
|
@@ -1145,7 +1138,18 @@ async function stepConfirm(byRepo, token) {
|
|
|
1145
1138
|
// =============================================================================
|
|
1146
1139
|
// Main command
|
|
1147
1140
|
// =============================================================================
|
|
1148
|
-
export async function setupCommand() {
|
|
1141
|
+
export async function setupCommand(options = {}) {
|
|
1142
|
+
// Create the prompter FIRST: in JSON mode it redirects console.* to stderr so
|
|
1143
|
+
// stdout carries only NDJSON, which the steps below rely on.
|
|
1144
|
+
ui = createPrompter({ json: options.json, answers: buildAnswerMap(options) });
|
|
1145
|
+
try {
|
|
1146
|
+
await runSetupFlow(options);
|
|
1147
|
+
}
|
|
1148
|
+
finally {
|
|
1149
|
+
ui.close();
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
async function runSetupFlow(options) {
|
|
1149
1153
|
console.log(chalk.cyan('\n Haystack Setup Wizard\n'));
|
|
1150
1154
|
// Step 0: Check login
|
|
1151
1155
|
let authContext;
|
|
@@ -1182,12 +1186,27 @@ export async function setupCommand() {
|
|
|
1182
1186
|
await stepReview(byRepo);
|
|
1183
1187
|
// Step 4: Confirm & write the config files
|
|
1184
1188
|
const { confirmed, bootstrapPRs } = await stepConfirm(byRepo, token);
|
|
1185
|
-
if (!confirmed)
|
|
1189
|
+
if (!confirmed) {
|
|
1190
|
+
ui.result({ status: 'cancelled' });
|
|
1186
1191
|
return;
|
|
1192
|
+
}
|
|
1187
1193
|
// Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it
|
|
1188
1194
|
// which repos had a protected default branch so it can commit
|
|
1189
1195
|
// .entire/settings.json to the same PR instead of a doomed direct PUT.
|
|
1190
|
-
|
|
1196
|
+
// --skip-entire skips the whole step (binary install, hooks, AND the remote
|
|
1197
|
+
// .entire/settings.json write) — not just the install prompt.
|
|
1198
|
+
if (options.skipEntire) {
|
|
1199
|
+
console.log(chalk.dim(' Skipping session tracking (--skip-entire).\n'));
|
|
1200
|
+
}
|
|
1201
|
+
else {
|
|
1202
|
+
await stepInstallEntire(selectedRepos, token, bootstrapPRs);
|
|
1203
|
+
}
|
|
1204
|
+
// Final structured outcome for non-interactive/agent callers.
|
|
1205
|
+
ui.result({
|
|
1206
|
+
status: 'complete',
|
|
1207
|
+
repos: selectedRepos,
|
|
1208
|
+
pullRequests: [...bootstrapPRs.entries()].map(([repo, pr]) => ({ repo, url: pr.prUrl })),
|
|
1209
|
+
});
|
|
1191
1210
|
}
|
|
1192
1211
|
// =============================================================================
|
|
1193
1212
|
// Step 7: Install Entire CLI for session tracking
|
|
@@ -1330,14 +1349,11 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
|
|
|
1330
1349
|
}
|
|
1331
1350
|
else if (status.installed) {
|
|
1332
1351
|
console.log(chalk.dim(` Stock Entire CLI detected: ${status.version.split('\n')[0]}`));
|
|
1333
|
-
const
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
default: true,
|
|
1339
|
-
},
|
|
1340
|
-
]);
|
|
1352
|
+
const upgrade = await ui.confirm({
|
|
1353
|
+
id: 'upgrade_entire',
|
|
1354
|
+
message: 'Upgrade to Haystack fork? (adds transcript optimization — saves GB of git storage)',
|
|
1355
|
+
default: true,
|
|
1356
|
+
});
|
|
1341
1357
|
if (upgrade) {
|
|
1342
1358
|
binaryReady = await installEntireBinary();
|
|
1343
1359
|
}
|
|
@@ -1346,14 +1362,11 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
|
|
|
1346
1362
|
}
|
|
1347
1363
|
}
|
|
1348
1364
|
else {
|
|
1349
|
-
const
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
default: true,
|
|
1355
|
-
},
|
|
1356
|
-
]);
|
|
1365
|
+
const install = await ui.confirm({
|
|
1366
|
+
id: 'install_entire',
|
|
1367
|
+
message: 'Install Entire CLI? (tracks AI coding sessions for analysis)',
|
|
1368
|
+
default: true,
|
|
1369
|
+
});
|
|
1357
1370
|
if (install) {
|
|
1358
1371
|
binaryReady = await installEntireBinary();
|
|
1359
1372
|
}
|
|
@@ -1392,14 +1405,14 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
|
|
|
1392
1405
|
continue;
|
|
1393
1406
|
}
|
|
1394
1407
|
try {
|
|
1395
|
-
|
|
1408
|
+
ui.progress(`Configuring ${repoFullName}...`);
|
|
1396
1409
|
await configureEntireSettingsViaAPI(repoFullName, token);
|
|
1397
|
-
|
|
1410
|
+
ui.clearProgress();
|
|
1398
1411
|
console.log(chalk.green(` ✓ ${repoFullName}`));
|
|
1399
1412
|
configured++;
|
|
1400
1413
|
}
|
|
1401
1414
|
catch (err) {
|
|
1402
|
-
|
|
1415
|
+
ui.clearProgress();
|
|
1403
1416
|
console.log(chalk.yellow(` ⚠ ${repoFullName}: ${err instanceof Error ? err.message : err}`));
|
|
1404
1417
|
}
|
|
1405
1418
|
}
|
|
@@ -1422,14 +1435,11 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
|
|
|
1422
1435
|
});
|
|
1423
1436
|
if (repoName) {
|
|
1424
1437
|
try {
|
|
1425
|
-
const
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
default: true,
|
|
1431
|
-
},
|
|
1432
|
-
]);
|
|
1438
|
+
const installHooks = await ui.confirm({
|
|
1439
|
+
id: 'install_hooks',
|
|
1440
|
+
message: `Install git hooks locally for ${repoName}? (agent detection, truncation checking, LLM rules)`,
|
|
1441
|
+
default: true,
|
|
1442
|
+
});
|
|
1433
1443
|
if (installHooks) {
|
|
1434
1444
|
console.log('');
|
|
1435
1445
|
await hooksInstall({ skipEntire: true, force: true });
|
package/dist/index.js
CHANGED
|
@@ -74,23 +74,48 @@ CI signals, and review policies.
|
|
|
74
74
|
.action(initCommand);
|
|
75
75
|
program
|
|
76
76
|
.command('setup')
|
|
77
|
-
.description('
|
|
77
|
+
.description('Onboarding wizard — scan repos for rules/policies and write Haystack config')
|
|
78
|
+
.option('--json', 'Agent mode: emit NDJSON events on stdout, read answers on stdin (no TTY prompts)')
|
|
79
|
+
.option('--repo <repo...>', 'Repo(s) to configure (owner/name); skips the repo picker')
|
|
80
|
+
.option('-y, --yes', 'Accept defaults non-interactively (keep all discovered items, confirm write)')
|
|
81
|
+
// Defining --no-auto-merge also accepts --auto-merge; default is "ask" unless
|
|
82
|
+
// one is explicitly passed (detected via getOptionValueSource below).
|
|
83
|
+
.option('--no-auto-merge', 'Set auto-merge in the written config (--auto-merge / --no-auto-merge; omit to be asked)')
|
|
84
|
+
.option('--skip-entire', 'Skip the Entire CLI / git-hooks (session tracking) step')
|
|
85
|
+
.option('--answers <file>', 'JSON file of {questionId: value} pre-supplied answers')
|
|
78
86
|
.addHelpText('after', `
|
|
79
|
-
|
|
87
|
+
Steps: verify GitHub App → select repos → scan (rules/policies/instructions) →
|
|
88
|
+
review → write .haystack/pr-rules.yml + review-policy.md + .haystack.json.
|
|
80
89
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
Three ways to run:
|
|
91
|
+
• Interactive (default): prompts in your terminal.
|
|
92
|
+
• Autonomous/CI: supply every decision via flags, e.g.
|
|
93
|
+
haystack setup --repo owner/name --yes --no-auto-merge --skip-entire
|
|
94
|
+
• Coding agent: --json speaks NDJSON on stdout and reads answers on stdin, so an
|
|
95
|
+
agent can auto-answer or relay questions to the user. Pre-supplied answers
|
|
96
|
+
(flags / --answers) skip the matching question.
|
|
87
97
|
|
|
88
98
|
Requires authentication — run \`haystack login\` first.
|
|
89
99
|
|
|
90
100
|
Examples:
|
|
91
101
|
haystack setup
|
|
102
|
+
haystack setup --repo owner/name --yes
|
|
103
|
+
haystack setup --json --repo owner/name
|
|
92
104
|
`)
|
|
93
|
-
.action(
|
|
105
|
+
.action((options, command) => {
|
|
106
|
+
// Tri-state auto-merge: undefined (ask) unless --auto-merge/--no-auto-merge
|
|
107
|
+
// was explicitly passed. commander defaults autoMerge to true because of the
|
|
108
|
+
// --no- form, so check the value source rather than the value.
|
|
109
|
+
const autoMergeExplicit = command.getOptionValueSource('autoMerge') === 'cli';
|
|
110
|
+
return setupCommand({
|
|
111
|
+
json: options.json,
|
|
112
|
+
repos: options.repo,
|
|
113
|
+
yes: options.yes,
|
|
114
|
+
autoMerge: autoMergeExplicit ? options.autoMerge : undefined,
|
|
115
|
+
skipEntire: options.skipEntire,
|
|
116
|
+
answersFile: options.answers,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
94
119
|
program
|
|
95
120
|
.command('status')
|
|
96
121
|
.description('Check if .haystack.json exists and is valid')
|
package/dist/triage/prompts.js
CHANGED
|
@@ -45,7 +45,7 @@ const INTENT_DRIFT_SCHEMA = `{
|
|
|
45
45
|
"line": 42,
|
|
46
46
|
"severity": "error | warning | info",
|
|
47
47
|
"message": "Description of the drift or incomplete fulfillment",
|
|
48
|
-
"pattern": "intent_drift | incomplete_fulfillment | scope_creep"
|
|
48
|
+
"pattern": "intent_drift | incomplete_fulfillment | scope_creep | unspecified_decision | ignored_correction | weakened_posture | claimed_but_not_done"
|
|
49
49
|
}
|
|
50
50
|
],
|
|
51
51
|
"summary": "Brief 1-sentence summary",
|
|
@@ -286,6 +286,36 @@ The agent added functionality the user never asked for:
|
|
|
286
286
|
- User asks for one feature → agent bundles in extra features "while we're at it"
|
|
287
287
|
- Any new mechanism not traceable to a user instruction
|
|
288
288
|
|
|
289
|
+
### Unspecified Decision
|
|
290
|
+
The user authorized the task's GOAL, but the agent made a specific decision in HOW it carried it out that shapes the program's end output, externally observable behavior, or the data end-users/callers see — and the user never specified or approved that particular decision, and a reasonable user would plausibly want a say in it. Examples (general — do NOT pattern-match on specific keywords):
|
|
291
|
+
- Silently dropping, capping, sampling, or transforming data that flows to the output
|
|
292
|
+
- Picking a default that determines what end-users see
|
|
293
|
+
- Resolving an ambiguous requirement one way when other materially different behaviors were equally valid
|
|
294
|
+
- Choosing a fixed value where the choice changes results
|
|
295
|
+
Do NOT flag internal implementation choices with no observable effect (naming, file layout, helper structure), decisions forced by correctness, or cosmetic defaults a user would not care about. When unsure whether a user would care, do not flag it — reserve this for decisions with real, user-visible ramifications. The difference from scope creep: scope creep is an unrequested *flow*; an unspecified decision is an unrequested, output-shaping choice *inside a requested flow*.
|
|
296
|
+
|
|
297
|
+
### Ignored Correction
|
|
298
|
+
The user gave an explicit correction or redirection and the final code does NOT honor it:
|
|
299
|
+
- User said "don't use a global / use X instead / that's racy, do Y" and the agent shipped the thing it was told not to
|
|
300
|
+
- Agent applied the correction, then quietly reverted it in a later step
|
|
301
|
+
- A general later "looks good" does NOT cancel a specific earlier correction
|
|
302
|
+
This is high severity by default: the user actively steered and was overridden.
|
|
303
|
+
|
|
304
|
+
### Weakened Posture
|
|
305
|
+
In service of an authorized goal, the agent relaxed or removed a security, safety, or correctness guard the user never asked to weaken:
|
|
306
|
+
- Loosened an auth/permission/validation check, or broadened access (CORS, scopes)
|
|
307
|
+
- Swallowed or silenced an error, removed an assertion/guard, hardcoded a bypass or credential
|
|
308
|
+
- Disabled a test (.skip / xit), suppressed a type or lint check (any, @ts-ignore, disable comments)
|
|
309
|
+
- Lowered a threshold/timeout that existed as a safeguard
|
|
310
|
+
High severity by default. (If the user explicitly asked to remove the guard, it is not a finding.)
|
|
311
|
+
|
|
312
|
+
### Claimed But Not Done
|
|
313
|
+
The agent told the user it completed work that the diff does not actually contain, or contains only in a materially weaker form:
|
|
314
|
+
- "I added error handling for timeouts" but no timeout handling exists in the diff
|
|
315
|
+
- "Added tests for the edge case" but no test assertions exercise it
|
|
316
|
+
- "Made the limit configurable" but the value is still hardcoded
|
|
317
|
+
Compare each concrete claim the agent made to the user against what the diff actually shows. Only flag when the diff clearly contradicts or fails to support the claim; when unsure, do not flag.
|
|
318
|
+
|
|
289
319
|
## Red flags to search for in the diff
|
|
290
320
|
|
|
291
321
|
- Fixed/hardcoded values where dynamic behavior was requested
|
|
@@ -293,6 +323,7 @@ The agent added functionality the user never asked for:
|
|
|
293
323
|
- Empty function bodies or early returns
|
|
294
324
|
- Interface fields that are declared but never assigned anywhere
|
|
295
325
|
- New mechanisms (cooldowns, retries, caches, rate limits) not requested by the user
|
|
326
|
+
- Decisions that drop, limit, or reshape data flowing to the output, or pick a default that changes what end-users see, with no instruction specifying it
|
|
296
327
|
|
|
297
328
|
## Output
|
|
298
329
|
|
|
@@ -303,9 +334,9 @@ ${INTENT_DRIFT_SCHEMA}
|
|
|
303
334
|
\`\`\`
|
|
304
335
|
|
|
305
336
|
- Set \`sessionsChecked\` to the number of trace files you read
|
|
306
|
-
- Set \`passed\` to \`true\`
|
|
307
|
-
- Set \`passed\` to \`false\`
|
|
308
|
-
- For each issue, set \`pattern\` to "intent_drift", "incomplete_fulfillment",
|
|
337
|
+
- Set \`passed\` to \`true\` only when no intent-drift issues are found
|
|
338
|
+
- Set \`passed\` to \`false\` when any issue is found (any pattern, any severity)
|
|
339
|
+
- For each issue, set \`pattern\` to one of: "intent_drift", "incomplete_fulfillment", "scope_creep", "unspecified_decision", "ignored_correction", "weakened_posture", "claimed_but_not_done"
|
|
309
340
|
- You MUST write the result file even if no issues are found
|
|
310
341
|
|
|
311
342
|
## Severity guidelines
|
package/dist/triage/types.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export interface TriageIssue {
|
|
|
13
13
|
/** For rules validator: rule ID (e.g., PR001) */
|
|
14
14
|
rule?: string;
|
|
15
15
|
/** For intent drift: drift pattern */
|
|
16
|
-
pattern?: 'intent_drift' | 'incomplete_fulfillment' | 'scope_creep';
|
|
16
|
+
pattern?: 'intent_drift' | 'incomplete_fulfillment' | 'scope_creep' | 'unspecified_decision' | 'ignored_correction' | 'weakened_posture' | 'claimed_but_not_done';
|
|
17
17
|
}
|
|
18
18
|
export interface CodeReviewResult {
|
|
19
19
|
checker: 'code-review';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompter — decouples a wizard's *decisions* from *how they're answered*.
|
|
3
|
+
*
|
|
4
|
+
* `haystack setup` historically welded every decision to inquirer/TTY, so it
|
|
5
|
+
* couldn't run headless, in CI, or under a coding agent. The Prompter is the
|
|
6
|
+
* seam: step code calls `confirm` / `multiselect` / `action`, and the
|
|
7
|
+
* implementation decides how to source the answer.
|
|
8
|
+
*
|
|
9
|
+
* Three modes, one mechanism:
|
|
10
|
+
* - TtyPrompter — humans in a terminal (inquirer). Default.
|
|
11
|
+
* - JsonPrompter — a coding agent: NDJSON events on stdout, answers on stdin.
|
|
12
|
+
* The agent can relay a `question` to the user OR auto-answer.
|
|
13
|
+
* - Pre-supplied answers (flags / --answers file) short-circuit any question
|
|
14
|
+
* by id, so the same code runs fully autonomously when every answer is known.
|
|
15
|
+
*
|
|
16
|
+
* Human-facing chrome stays as `console.*`; in JSON mode the JsonPrompter
|
|
17
|
+
* redirects `console.*` to stderr so stdout carries only NDJSON.
|
|
18
|
+
*/
|
|
19
|
+
/** A selectable option. A `{ separator }` entry renders a non-selectable group
|
|
20
|
+
* header in TTY mode and is ignored in JSON mode. */
|
|
21
|
+
export type Choice<T> = {
|
|
22
|
+
name: string;
|
|
23
|
+
value: T;
|
|
24
|
+
checked?: boolean;
|
|
25
|
+
} | {
|
|
26
|
+
separator: string;
|
|
27
|
+
};
|
|
28
|
+
export interface Prompter {
|
|
29
|
+
/** True for the human TTY prompter — gates side effects like opening a browser
|
|
30
|
+
* that only make sense for a local interactive user. */
|
|
31
|
+
readonly interactive: boolean;
|
|
32
|
+
/** Yes/no decision. */
|
|
33
|
+
confirm(q: {
|
|
34
|
+
id: string;
|
|
35
|
+
message: string;
|
|
36
|
+
default?: boolean;
|
|
37
|
+
}): Promise<boolean>;
|
|
38
|
+
/** Pick zero-or-more values. Items default-checked via `checked: true`. */
|
|
39
|
+
multiselect<T>(q: {
|
|
40
|
+
id: string;
|
|
41
|
+
message: string;
|
|
42
|
+
choices: Choice<T>[];
|
|
43
|
+
}): Promise<T[]>;
|
|
44
|
+
/**
|
|
45
|
+
* An action the user must take out-of-band (e.g. install the GitHub App).
|
|
46
|
+
* Polls `check()` until it returns true (satisfied) or `timeoutMs` elapses.
|
|
47
|
+
* A pre-supplied / stdin answer of "skip" resolves false without polling.
|
|
48
|
+
* Returns true when satisfied, false when skipped or timed out.
|
|
49
|
+
*/
|
|
50
|
+
action(q: {
|
|
51
|
+
id: string;
|
|
52
|
+
message: string;
|
|
53
|
+
url?: string;
|
|
54
|
+
check: () => Promise<boolean>;
|
|
55
|
+
pollIntervalMs?: number;
|
|
56
|
+
timeoutMs?: number;
|
|
57
|
+
}): Promise<boolean>;
|
|
58
|
+
/** Transient progress line (scan ticker, per-repo write status). */
|
|
59
|
+
progress(message: string, detail?: string): void;
|
|
60
|
+
/** Clear the current progress line. */
|
|
61
|
+
clearProgress(): void;
|
|
62
|
+
/** Final structured outcome (emitted as a `result` event in JSON mode). */
|
|
63
|
+
result(data: Record<string, unknown>): void;
|
|
64
|
+
/** Release stdin/readline so the process can exit. */
|
|
65
|
+
close(): void;
|
|
66
|
+
}
|
|
67
|
+
/** Answers known up-front (from flags or an --answers file), keyed by question id. */
|
|
68
|
+
export type AnswerMap = Record<string, unknown>;
|
|
69
|
+
export declare function loadAnswersFile(path: string): AnswerMap;
|
|
70
|
+
export interface PrompterOptions {
|
|
71
|
+
json?: boolean;
|
|
72
|
+
answers?: AnswerMap;
|
|
73
|
+
}
|
|
74
|
+
export declare function createPrompter(options?: PrompterOptions): Prompter;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompter — decouples a wizard's *decisions* from *how they're answered*.
|
|
3
|
+
*
|
|
4
|
+
* `haystack setup` historically welded every decision to inquirer/TTY, so it
|
|
5
|
+
* couldn't run headless, in CI, or under a coding agent. The Prompter is the
|
|
6
|
+
* seam: step code calls `confirm` / `multiselect` / `action`, and the
|
|
7
|
+
* implementation decides how to source the answer.
|
|
8
|
+
*
|
|
9
|
+
* Three modes, one mechanism:
|
|
10
|
+
* - TtyPrompter — humans in a terminal (inquirer). Default.
|
|
11
|
+
* - JsonPrompter — a coding agent: NDJSON events on stdout, answers on stdin.
|
|
12
|
+
* The agent can relay a `question` to the user OR auto-answer.
|
|
13
|
+
* - Pre-supplied answers (flags / --answers file) short-circuit any question
|
|
14
|
+
* by id, so the same code runs fully autonomously when every answer is known.
|
|
15
|
+
*
|
|
16
|
+
* Human-facing chrome stays as `console.*`; in JSON mode the JsonPrompter
|
|
17
|
+
* redirects `console.*` to stderr so stdout carries only NDJSON.
|
|
18
|
+
*/
|
|
19
|
+
import chalk from 'chalk';
|
|
20
|
+
import inquirer from 'inquirer';
|
|
21
|
+
import * as readline from 'node:readline';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
function isSeparator(c) {
|
|
24
|
+
return c.separator !== undefined;
|
|
25
|
+
}
|
|
26
|
+
export function loadAnswersFile(path) {
|
|
27
|
+
const raw = readFileSync(path, 'utf8');
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
30
|
+
throw new Error(`--answers file must be a JSON object of {questionId: value}`);
|
|
31
|
+
}
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// TTY (human) prompter
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
class TtyPrompter {
|
|
38
|
+
answers;
|
|
39
|
+
interactive = true;
|
|
40
|
+
constructor(answers) {
|
|
41
|
+
this.answers = answers;
|
|
42
|
+
}
|
|
43
|
+
async confirm(q) {
|
|
44
|
+
if (q.id in this.answers)
|
|
45
|
+
return Boolean(this.answers[q.id]);
|
|
46
|
+
const { value } = await inquirer.prompt([
|
|
47
|
+
{ type: 'confirm', name: 'value', message: q.message, default: q.default ?? true },
|
|
48
|
+
]);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
async multiselect(q) {
|
|
52
|
+
if (q.id in this.answers)
|
|
53
|
+
return this.answers[q.id];
|
|
54
|
+
const choices = q.choices.map((c) => isSeparator(c) ? new inquirer.Separator(c.separator) : { name: c.name, value: c.value, checked: c.checked });
|
|
55
|
+
const { value } = await inquirer.prompt([
|
|
56
|
+
{ type: 'checkbox', name: 'value', message: q.message, choices: choices, pageSize: 20 },
|
|
57
|
+
]);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
async action(q) {
|
|
61
|
+
if (this.answers[q.id] === 'skip')
|
|
62
|
+
return false;
|
|
63
|
+
if (await q.check())
|
|
64
|
+
return true;
|
|
65
|
+
console.log(chalk.yellow(` ${q.message}`));
|
|
66
|
+
if (q.url)
|
|
67
|
+
console.log(` ${chalk.cyan(q.url)}`);
|
|
68
|
+
console.log(chalk.dim(' Waiting...'));
|
|
69
|
+
const started = Date.now();
|
|
70
|
+
const timeout = q.timeoutMs ?? 10 * 60 * 1000;
|
|
71
|
+
const interval = q.pollIntervalMs ?? 3000;
|
|
72
|
+
while (Date.now() - started < timeout) {
|
|
73
|
+
await sleep(interval);
|
|
74
|
+
if (await q.check())
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
progress(message, detail) {
|
|
80
|
+
process.stdout.write(`\r${chalk.dim(' ' + message)}${detail ? chalk.dim(' — ' + detail) : ''}\x1b[K`);
|
|
81
|
+
}
|
|
82
|
+
clearProgress() {
|
|
83
|
+
process.stdout.write('\r\x1b[K');
|
|
84
|
+
}
|
|
85
|
+
result(_data) {
|
|
86
|
+
// Human output is rendered inline by the step code; nothing structured to emit.
|
|
87
|
+
}
|
|
88
|
+
close() {
|
|
89
|
+
/* nothing to release */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// JSON (coding-agent) prompter
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
class JsonPrompter {
|
|
96
|
+
answers;
|
|
97
|
+
interactive = false;
|
|
98
|
+
rl = null;
|
|
99
|
+
pending = new Map();
|
|
100
|
+
buffered = new Map();
|
|
101
|
+
restoreConsole = null;
|
|
102
|
+
constructor(answers) {
|
|
103
|
+
this.answers = answers;
|
|
104
|
+
// Keep stdout pure NDJSON: route human chrome to stderr.
|
|
105
|
+
const origLog = console.log;
|
|
106
|
+
const origWarn = console.warn;
|
|
107
|
+
const origError = console.error;
|
|
108
|
+
const toErr = (...args) => process.stderr.write(args.map(stringifyArg).join(' ') + '\n');
|
|
109
|
+
console.log = toErr;
|
|
110
|
+
console.warn = toErr;
|
|
111
|
+
console.error = toErr;
|
|
112
|
+
this.restoreConsole = () => {
|
|
113
|
+
console.log = origLog;
|
|
114
|
+
console.warn = origWarn;
|
|
115
|
+
console.error = origError;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
emit(event) {
|
|
119
|
+
process.stdout.write(JSON.stringify(event) + '\n');
|
|
120
|
+
}
|
|
121
|
+
/** Lazily open stdin only when we actually need an answer the agent must send. */
|
|
122
|
+
ensureStdin() {
|
|
123
|
+
if (this.rl)
|
|
124
|
+
return;
|
|
125
|
+
this.rl = readline.createInterface({ input: process.stdin });
|
|
126
|
+
this.rl.on('line', (line) => {
|
|
127
|
+
const trimmed = line.trim();
|
|
128
|
+
if (!trimmed)
|
|
129
|
+
return;
|
|
130
|
+
let obj;
|
|
131
|
+
try {
|
|
132
|
+
obj = JSON.parse(trimmed);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return; // ignore non-JSON noise on stdin
|
|
136
|
+
}
|
|
137
|
+
if (!obj || typeof obj.id !== 'string')
|
|
138
|
+
return;
|
|
139
|
+
const resolve = this.pending.get(obj.id);
|
|
140
|
+
if (resolve) {
|
|
141
|
+
this.pending.delete(obj.id);
|
|
142
|
+
resolve(obj.value);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
this.buffered.set(obj.id, obj.value);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
awaitAnswer(id) {
|
|
150
|
+
if (this.buffered.has(id)) {
|
|
151
|
+
const v = this.buffered.get(id);
|
|
152
|
+
this.buffered.delete(id);
|
|
153
|
+
return Promise.resolve(v);
|
|
154
|
+
}
|
|
155
|
+
this.ensureStdin();
|
|
156
|
+
return new Promise((resolve) => this.pending.set(id, resolve));
|
|
157
|
+
}
|
|
158
|
+
async confirm(q) {
|
|
159
|
+
if (q.id in this.answers)
|
|
160
|
+
return Boolean(this.answers[q.id]);
|
|
161
|
+
this.emit({ type: 'question', id: q.id, kind: 'confirm', prompt: q.message, default: q.default ?? true });
|
|
162
|
+
return Boolean(await this.awaitAnswer(q.id));
|
|
163
|
+
}
|
|
164
|
+
async multiselect(q) {
|
|
165
|
+
if (q.id in this.answers)
|
|
166
|
+
return this.answers[q.id];
|
|
167
|
+
const options = q.choices
|
|
168
|
+
.filter((c) => !isSeparator(c))
|
|
169
|
+
.map((c) => ({ value: c.value, label: c.name, default: c.checked ?? false }));
|
|
170
|
+
this.emit({ type: 'question', id: q.id, kind: 'multiselect', prompt: q.message, options });
|
|
171
|
+
return (await this.awaitAnswer(q.id));
|
|
172
|
+
}
|
|
173
|
+
async action(q) {
|
|
174
|
+
if (this.answers[q.id] === 'skip')
|
|
175
|
+
return false;
|
|
176
|
+
if (await q.check())
|
|
177
|
+
return true;
|
|
178
|
+
// Tell the agent an out-of-band action is needed; then poll until satisfied.
|
|
179
|
+
// An agent may also push {"id","value":"skip"|"done"} on stdin to override.
|
|
180
|
+
this.emit({ type: 'action', id: q.id, prompt: q.message, url: q.url, poll: true });
|
|
181
|
+
const started = Date.now();
|
|
182
|
+
const timeout = q.timeoutMs ?? 10 * 60 * 1000;
|
|
183
|
+
const interval = q.pollIntervalMs ?? 3000;
|
|
184
|
+
let override = null;
|
|
185
|
+
// Resolve the moment the agent answers, OR when polling detects completion.
|
|
186
|
+
this.ensureStdin();
|
|
187
|
+
const answerPromise = this.awaitAnswer(q.id).then((v) => {
|
|
188
|
+
override = v === 'skip' ? 'skip' : 'done';
|
|
189
|
+
});
|
|
190
|
+
while (Date.now() - started < timeout) {
|
|
191
|
+
if (await q.check())
|
|
192
|
+
return true;
|
|
193
|
+
if (override === 'skip')
|
|
194
|
+
return false;
|
|
195
|
+
if (override === 'done')
|
|
196
|
+
return (await q.check()); // agent says installed; trust but verify
|
|
197
|
+
await Promise.race([sleep(interval), answerPromise]);
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
progress(message, detail) {
|
|
202
|
+
this.emit({ type: 'progress', message, ...(detail ? { detail } : {}) });
|
|
203
|
+
}
|
|
204
|
+
clearProgress() {
|
|
205
|
+
/* no ephemeral line in JSON mode */
|
|
206
|
+
}
|
|
207
|
+
result(data) {
|
|
208
|
+
this.emit({ type: 'result', ...data });
|
|
209
|
+
}
|
|
210
|
+
close() {
|
|
211
|
+
this.rl?.close();
|
|
212
|
+
this.rl = null;
|
|
213
|
+
this.restoreConsole?.();
|
|
214
|
+
this.restoreConsole = null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export function createPrompter(options = {}) {
|
|
218
|
+
const answers = options.answers ?? {};
|
|
219
|
+
return options.json ? new JsonPrompter(answers) : new TtyPrompter(answers);
|
|
220
|
+
}
|
|
221
|
+
function sleep(ms) {
|
|
222
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
223
|
+
}
|
|
224
|
+
function stringifyArg(a) {
|
|
225
|
+
return typeof a === 'string' ? a : String(a);
|
|
226
|
+
}
|