@haystackeditor/cli 0.14.6 → 0.15.1
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 +181 -178
- package/dist/index.js +34 -9
- package/dist/utils/prompter.d.ts +74 -0
- package/dist/utils/prompter.js +254 -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
|
}
|
|
@@ -694,16 +726,24 @@ function tryOpenBrowser(url) {
|
|
|
694
726
|
return false;
|
|
695
727
|
}
|
|
696
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
|
+
}
|
|
697
735
|
async function stepEnsureAppInstalled(token) {
|
|
698
736
|
console.log(chalk.bold(' Step 0: Verify GitHub App'));
|
|
699
|
-
|
|
737
|
+
// Initial check — distinguish a genuine auth failure (bail, prompt re-auth)
|
|
738
|
+
// from a transient probe error (refuse-to-fail and proceed).
|
|
700
739
|
try {
|
|
701
|
-
|
|
740
|
+
if (await isAppInstalled(token)) {
|
|
741
|
+
console.log(chalk.green(' ✓ Haystack App installed\n'));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
702
744
|
}
|
|
703
745
|
catch (err) {
|
|
704
746
|
if (err instanceof InstallationsAuthError) {
|
|
705
|
-
// Token is dead — retrying won't help. Prompt re-auth and exit so the
|
|
706
|
-
// user doesn't waste a 10-minute poll on something that will never work.
|
|
707
747
|
console.log(chalk.yellow(`\n Your GitHub token is no longer valid (${err.status}).`));
|
|
708
748
|
console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
|
|
709
749
|
trackError('haystack_setup_installations_auth_failure', { status: err.status });
|
|
@@ -711,81 +751,47 @@ async function stepEnsureAppInstalled(token) {
|
|
|
711
751
|
}
|
|
712
752
|
console.log(chalk.yellow(` Could not check App installations: ${err.message}`));
|
|
713
753
|
console.log(chalk.dim(` Continuing — install manually at ${HAYSTACK_APP_INSTALL_URL} if not yet installed.\n`));
|
|
714
|
-
|
|
715
|
-
// brick setup). Still log so we can see if probes start failing
|
|
716
|
-
// systemically across users.
|
|
717
|
-
trackError('haystack_setup_installations_probe_failed', {
|
|
718
|
-
error_message: err.message,
|
|
719
|
-
});
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
if (installed.length > 0) {
|
|
723
|
-
const accounts = installed.map((i) => i.account?.login).filter(Boolean).join(', ');
|
|
724
|
-
console.log(chalk.green(` ✓ Haystack App installed${accounts ? ` (${accounts})` : ''}\n`));
|
|
754
|
+
trackError('haystack_setup_installations_probe_failed', { error_message: err.message });
|
|
725
755
|
return;
|
|
726
756
|
}
|
|
727
757
|
console.log(chalk.yellow(' The Haystack GitHub App is not installed yet.'));
|
|
728
|
-
console.log(chalk.dim(
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
+
});
|
|
734
779
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
await new Promise((resolve) => setTimeout(resolve, APP_INSTALL_POLL_INTERVAL_MS));
|
|
742
|
-
let current;
|
|
743
|
-
try {
|
|
744
|
-
current = await fetchHaystackInstallations(token);
|
|
745
|
-
consecutiveFailures = 0;
|
|
746
|
-
lastError = null;
|
|
747
|
-
}
|
|
748
|
-
catch (err) {
|
|
749
|
-
// Auth errors during the poll loop mean the token went bad mid-flow
|
|
750
|
-
// (e.g. user revoked it from GitHub settings). Same response as the
|
|
751
|
-
// initial check — bail with re-auth guidance instead of grinding for
|
|
752
|
-
// 10 minutes against a permanent failure.
|
|
753
|
-
if (err instanceof InstallationsAuthError) {
|
|
754
|
-
console.log(chalk.yellow(`\n Your GitHub token became invalid (${err.status}) while waiting.`));
|
|
755
|
-
console.log(chalk.dim(' Run `haystack login` to re-authenticate, then re-run `haystack setup`.\n'));
|
|
756
|
-
trackError('haystack_setup_installations_auth_failure', { status: err.status, during: 'poll' });
|
|
757
|
-
process.exit(1);
|
|
758
|
-
}
|
|
759
|
-
// Per-tick failures are usually transient (token refresh race, brief
|
|
760
|
-
// GitHub API blip). Log the first one quietly so the user sees we're
|
|
761
|
-
// not silently spinning, but don't spam the terminal every 3s.
|
|
762
|
-
consecutiveFailures += 1;
|
|
763
|
-
lastError = err instanceof Error ? err.message : String(err);
|
|
764
|
-
if (consecutiveFailures === 1) {
|
|
765
|
-
console.log(chalk.dim(` (Transient error checking installations: ${lastError} — retrying...)`));
|
|
766
|
-
}
|
|
767
|
-
continue;
|
|
768
|
-
}
|
|
769
|
-
if (current.length > 0) {
|
|
770
|
-
const accounts = current.map((i) => i.account?.login).filter(Boolean).join(', ');
|
|
771
|
-
console.log(chalk.green(`\n ✓ App installed${accounts ? ` (${accounts})` : ''}\n`));
|
|
772
|
-
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);
|
|
773
786
|
}
|
|
787
|
+
throw err;
|
|
774
788
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
// non-null after the final tick) so we can tell "user didn't click install"
|
|
779
|
-
// apart from "GitHub API was down."
|
|
780
|
-
trackError('haystack_setup_app_install_timeout', {
|
|
781
|
-
consecutive_failures: consecutiveFailures,
|
|
782
|
-
last_error: lastError,
|
|
783
|
-
timeout_ms: APP_INSTALL_TIMEOUT_MS,
|
|
784
|
-
});
|
|
785
|
-
console.log(chalk.yellow('\n Timed out waiting for App install.'));
|
|
786
|
-
if (lastError) {
|
|
787
|
-
console.log(chalk.dim(` Last error checking installations: ${lastError}`));
|
|
789
|
+
if (installed) {
|
|
790
|
+
console.log(chalk.green('\n ✓ App installed\n'));
|
|
791
|
+
return;
|
|
788
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.'));
|
|
789
795
|
console.log(chalk.dim(` Install at ${HAYSTACK_APP_INSTALL_URL}, then re-run \`haystack setup\`.\n`));
|
|
790
796
|
process.exit(1);
|
|
791
797
|
}
|
|
@@ -797,22 +803,15 @@ async function stepSelectRepos(token) {
|
|
|
797
803
|
console.log(chalk.yellow('\n No repositories found.\n'));
|
|
798
804
|
process.exit(1);
|
|
799
805
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
{
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
validate: (answer) => {
|
|
810
|
-
if (answer.length === 0)
|
|
811
|
-
return 'Select at least one repository.';
|
|
812
|
-
return true;
|
|
813
|
-
},
|
|
814
|
-
},
|
|
815
|
-
]);
|
|
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
|
+
}
|
|
816
815
|
console.log(chalk.green(` ${selectedRepos.length} repo(s) selected\n`));
|
|
817
816
|
return selectedRepos;
|
|
818
817
|
}
|
|
@@ -886,14 +885,11 @@ async function stepReview(byRepo) {
|
|
|
886
885
|
result.instructions.forEach((ins, i) => printInstruction(ins, i));
|
|
887
886
|
}
|
|
888
887
|
}
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
default: false,
|
|
895
|
-
},
|
|
896
|
-
]);
|
|
888
|
+
const wantToToggle = await ui.confirm({
|
|
889
|
+
id: 'want_to_toggle',
|
|
890
|
+
message: 'Would you like to toggle any items?',
|
|
891
|
+
default: false,
|
|
892
|
+
});
|
|
897
893
|
if (!wantToToggle)
|
|
898
894
|
return;
|
|
899
895
|
// NUL separates repo / kind / id in the checkbox value so it can't collide
|
|
@@ -903,7 +899,7 @@ async function stepReview(byRepo) {
|
|
|
903
899
|
for (const [repo, result] of byRepo) {
|
|
904
900
|
if (result.rules.length + result.policies.length + result.instructions.length === 0)
|
|
905
901
|
continue;
|
|
906
|
-
allItems.push(
|
|
902
|
+
allItems.push({ separator: `── ${repo} ──` });
|
|
907
903
|
result.rules.forEach((r) => {
|
|
908
904
|
allItems.push({
|
|
909
905
|
name: `${r.name} ${chalk.dim(`[rule · ${CATEGORY_LABELS[r.category] || r.category}]`)}`,
|
|
@@ -926,15 +922,11 @@ async function stepReview(byRepo) {
|
|
|
926
922
|
});
|
|
927
923
|
});
|
|
928
924
|
}
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
choices: allItems,
|
|
935
|
-
pageSize: 20,
|
|
936
|
-
},
|
|
937
|
-
]);
|
|
925
|
+
const enabled = await ui.multiselect({
|
|
926
|
+
id: 'review_enabled_items',
|
|
927
|
+
message: 'Select items to enable:',
|
|
928
|
+
choices: allItems,
|
|
929
|
+
});
|
|
938
930
|
const enabledSet = new Set(enabled);
|
|
939
931
|
for (const [repo, result] of byRepo) {
|
|
940
932
|
result.rules.forEach((r) => { r.enabled = enabledSet.has(`${repo}${SEP}rule${SEP}${r.id}`); });
|
|
@@ -1090,22 +1082,16 @@ async function stepConfirm(byRepo, token) {
|
|
|
1090
1082
|
// + the repo-level preferences.auto_merge opt-in model). When off, the
|
|
1091
1083
|
// bootstrap PR is opened but NOT labeled, so the user merges it manually and
|
|
1092
1084
|
// no PRs auto-merge.
|
|
1093
|
-
const
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
type: 'confirm',
|
|
1104
|
-
name: 'confirmed',
|
|
1105
|
-
message: `Write Haystack config to ${selectedRepos.length} repo(s)?`,
|
|
1106
|
-
default: true,
|
|
1107
|
-
},
|
|
1108
|
-
]);
|
|
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
|
+
});
|
|
1109
1095
|
if (!confirmed) {
|
|
1110
1096
|
console.log(chalk.yellow('\n Setup cancelled.\n'));
|
|
1111
1097
|
return { confirmed: false, bootstrapPRs: new Map() };
|
|
@@ -1117,9 +1103,9 @@ async function stepConfirm(byRepo, token) {
|
|
|
1117
1103
|
const result = byRepo.get(repoFullName);
|
|
1118
1104
|
const files = buildOnboardingFiles(result, autoMerge);
|
|
1119
1105
|
try {
|
|
1120
|
-
|
|
1106
|
+
ui.progress(`Writing to ${repoFullName}...`);
|
|
1121
1107
|
const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token);
|
|
1122
|
-
|
|
1108
|
+
ui.clearProgress();
|
|
1123
1109
|
if (writeResult.bootstrapPR) {
|
|
1124
1110
|
// Default branch was protected — config landed in a bootstrap PR. With
|
|
1125
1111
|
// auto-merge on, Haystack merges it once its labels are processed; with
|
|
@@ -1137,7 +1123,7 @@ async function stepConfirm(byRepo, token) {
|
|
|
1137
1123
|
}
|
|
1138
1124
|
}
|
|
1139
1125
|
catch (err) {
|
|
1140
|
-
|
|
1126
|
+
ui.clearProgress();
|
|
1141
1127
|
console.log(chalk.red(` ✗ ${repoFullName}: ${err.message}`));
|
|
1142
1128
|
failedRepos.push(repoFullName);
|
|
1143
1129
|
}
|
|
@@ -1152,7 +1138,18 @@ async function stepConfirm(byRepo, token) {
|
|
|
1152
1138
|
// =============================================================================
|
|
1153
1139
|
// Main command
|
|
1154
1140
|
// =============================================================================
|
|
1155
|
-
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) {
|
|
1156
1153
|
console.log(chalk.cyan('\n Haystack Setup Wizard\n'));
|
|
1157
1154
|
// Step 0: Check login
|
|
1158
1155
|
let authContext;
|
|
@@ -1189,12 +1186,27 @@ export async function setupCommand() {
|
|
|
1189
1186
|
await stepReview(byRepo);
|
|
1190
1187
|
// Step 4: Confirm & write the config files
|
|
1191
1188
|
const { confirmed, bootstrapPRs } = await stepConfirm(byRepo, token);
|
|
1192
|
-
if (!confirmed)
|
|
1189
|
+
if (!confirmed) {
|
|
1190
|
+
ui.result({ status: 'cancelled' });
|
|
1193
1191
|
return;
|
|
1192
|
+
}
|
|
1194
1193
|
// Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it
|
|
1195
1194
|
// which repos had a protected default branch so it can commit
|
|
1196
1195
|
// .entire/settings.json to the same PR instead of a doomed direct PUT.
|
|
1197
|
-
|
|
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
|
+
});
|
|
1198
1210
|
}
|
|
1199
1211
|
// =============================================================================
|
|
1200
1212
|
// Step 7: Install Entire CLI for session tracking
|
|
@@ -1337,14 +1349,11 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
|
|
|
1337
1349
|
}
|
|
1338
1350
|
else if (status.installed) {
|
|
1339
1351
|
console.log(chalk.dim(` Stock Entire CLI detected: ${status.version.split('\n')[0]}`));
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
default: true,
|
|
1346
|
-
},
|
|
1347
|
-
]);
|
|
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
|
+
});
|
|
1348
1357
|
if (upgrade) {
|
|
1349
1358
|
binaryReady = await installEntireBinary();
|
|
1350
1359
|
}
|
|
@@ -1353,14 +1362,11 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
|
|
|
1353
1362
|
}
|
|
1354
1363
|
}
|
|
1355
1364
|
else {
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
default: true,
|
|
1362
|
-
},
|
|
1363
|
-
]);
|
|
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
|
+
});
|
|
1364
1370
|
if (install) {
|
|
1365
1371
|
binaryReady = await installEntireBinary();
|
|
1366
1372
|
}
|
|
@@ -1399,14 +1405,14 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
|
|
|
1399
1405
|
continue;
|
|
1400
1406
|
}
|
|
1401
1407
|
try {
|
|
1402
|
-
|
|
1408
|
+
ui.progress(`Configuring ${repoFullName}...`);
|
|
1403
1409
|
await configureEntireSettingsViaAPI(repoFullName, token);
|
|
1404
|
-
|
|
1410
|
+
ui.clearProgress();
|
|
1405
1411
|
console.log(chalk.green(` ✓ ${repoFullName}`));
|
|
1406
1412
|
configured++;
|
|
1407
1413
|
}
|
|
1408
1414
|
catch (err) {
|
|
1409
|
-
|
|
1415
|
+
ui.clearProgress();
|
|
1410
1416
|
console.log(chalk.yellow(` ⚠ ${repoFullName}: ${err instanceof Error ? err.message : err}`));
|
|
1411
1417
|
}
|
|
1412
1418
|
}
|
|
@@ -1429,14 +1435,11 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
|
|
|
1429
1435
|
});
|
|
1430
1436
|
if (repoName) {
|
|
1431
1437
|
try {
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
default: true,
|
|
1438
|
-
},
|
|
1439
|
-
]);
|
|
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
|
+
});
|
|
1440
1443
|
if (installHooks) {
|
|
1441
1444
|
console.log('');
|
|
1442
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')
|
|
@@ -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,254 @@
|
|
|
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 (err) {
|
|
135
|
+
// Surface malformed input on stderr (PR001: no silent swallow) so an
|
|
136
|
+
// agent can tell its line was rejected and resend, instead of the
|
|
137
|
+
// prompt hanging on input that never parsed. stderr keeps stdout NDJSON.
|
|
138
|
+
process.stderr.write(`haystack setup: ignoring unparseable stdin line: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!obj || typeof obj !== 'object')
|
|
142
|
+
return;
|
|
143
|
+
// Mirror OpenCode: question replies are keyed by `requestID`, permission
|
|
144
|
+
// replies by `permissionID`. Legacy `id` accepted too.
|
|
145
|
+
const key = (obj.requestID ?? obj.permissionID ?? obj.id);
|
|
146
|
+
if (typeof key !== 'string')
|
|
147
|
+
return;
|
|
148
|
+
const resolve = this.pending.get(key);
|
|
149
|
+
if (resolve) {
|
|
150
|
+
this.pending.delete(key);
|
|
151
|
+
resolve(obj);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
this.buffered.set(key, obj);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/** Wait for the agent's reply object, keyed by the request/permission id. */
|
|
159
|
+
awaitReply(id) {
|
|
160
|
+
if (this.buffered.has(id)) {
|
|
161
|
+
const v = this.buffered.get(id);
|
|
162
|
+
this.buffered.delete(id);
|
|
163
|
+
return Promise.resolve(v);
|
|
164
|
+
}
|
|
165
|
+
this.ensureStdin();
|
|
166
|
+
return new Promise((resolve) => this.pending.set(id, resolve));
|
|
167
|
+
}
|
|
168
|
+
async confirm(q) {
|
|
169
|
+
if (q.id in this.answers)
|
|
170
|
+
return Boolean(this.answers[q.id]);
|
|
171
|
+
// A confirm is a single-choice question; emit OpenCode's `question` envelope.
|
|
172
|
+
// Reply: {requestID, answers: <bool>}; reject: {requestID, reject: true}.
|
|
173
|
+
this.emit({ type: 'question', requestID: q.id, kind: 'confirm', prompt: q.message, default: q.default ?? true });
|
|
174
|
+
const reply = await this.awaitReply(q.id);
|
|
175
|
+
if (reply.reject)
|
|
176
|
+
return false;
|
|
177
|
+
const a = reply.answers ?? reply.value; // `answers` is canonical; `value` accepted
|
|
178
|
+
return typeof a === 'boolean' ? a : Boolean(a);
|
|
179
|
+
}
|
|
180
|
+
async multiselect(q) {
|
|
181
|
+
if (q.id in this.answers)
|
|
182
|
+
return this.answers[q.id];
|
|
183
|
+
const options = q.choices
|
|
184
|
+
.filter((c) => !isSeparator(c))
|
|
185
|
+
.map((c) => ({ value: c.value, label: c.name, default: c.checked ?? false }));
|
|
186
|
+
// Reply: {requestID, answers: <value[]>}; reject: {requestID, reject: true}.
|
|
187
|
+
this.emit({ type: 'question', requestID: q.id, kind: 'multiselect', prompt: q.message, options });
|
|
188
|
+
const reply = await this.awaitReply(q.id);
|
|
189
|
+
if (reply.reject)
|
|
190
|
+
return [];
|
|
191
|
+
const a = reply.answers ?? reply.value;
|
|
192
|
+
return Array.isArray(a) ? a : [];
|
|
193
|
+
}
|
|
194
|
+
async action(q) {
|
|
195
|
+
if (this.answers[q.id] === 'skip')
|
|
196
|
+
return false;
|
|
197
|
+
if (await q.check())
|
|
198
|
+
return true;
|
|
199
|
+
// An out-of-band action maps to OpenCode's `permission` request. Reply:
|
|
200
|
+
// {permissionID, response: "once"|"always"|"reject"}. We also keep polling
|
|
201
|
+
// check() so it resolves on its own once the action completes.
|
|
202
|
+
this.emit({ type: 'permission', permissionID: q.id, prompt: q.message, url: q.url, poll: true });
|
|
203
|
+
const started = Date.now();
|
|
204
|
+
const timeout = q.timeoutMs ?? 10 * 60 * 1000;
|
|
205
|
+
const interval = q.pollIntervalMs ?? 3000;
|
|
206
|
+
let override = null;
|
|
207
|
+
this.ensureStdin();
|
|
208
|
+
const answerPromise = this.awaitReply(q.id).then((reply) => {
|
|
209
|
+
const r = (reply.response ?? reply.value);
|
|
210
|
+
override = r === 'reject' || r === 'skip' ? 'skip' : 'done';
|
|
211
|
+
});
|
|
212
|
+
while (Date.now() - started < timeout) {
|
|
213
|
+
if (await q.check())
|
|
214
|
+
return true;
|
|
215
|
+
if (override === 'skip')
|
|
216
|
+
return false;
|
|
217
|
+
// 'done' means the agent reports the action complete — but completion may
|
|
218
|
+
// be eventually-consistent, so keep polling check() at the interval until
|
|
219
|
+
// it confirms or we time out (matching TTY behavior), rather than failing
|
|
220
|
+
// on a single immediate check.
|
|
221
|
+
if (override === 'done') {
|
|
222
|
+
await sleep(interval);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
await Promise.race([sleep(interval), answerPromise]);
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
progress(message, detail) {
|
|
230
|
+
this.emit({ type: 'progress', message, ...(detail ? { detail } : {}) });
|
|
231
|
+
}
|
|
232
|
+
clearProgress() {
|
|
233
|
+
/* no ephemeral line in JSON mode */
|
|
234
|
+
}
|
|
235
|
+
result(data) {
|
|
236
|
+
this.emit({ type: 'result', ...data });
|
|
237
|
+
}
|
|
238
|
+
close() {
|
|
239
|
+
this.rl?.close();
|
|
240
|
+
this.rl = null;
|
|
241
|
+
this.restoreConsole?.();
|
|
242
|
+
this.restoreConsole = null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
export function createPrompter(options = {}) {
|
|
246
|
+
const answers = options.answers ?? {};
|
|
247
|
+
return options.json ? new JsonPrompter(answers) : new TtyPrompter(answers);
|
|
248
|
+
}
|
|
249
|
+
function sleep(ms) {
|
|
250
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
251
|
+
}
|
|
252
|
+
function stringifyArg(a) {
|
|
253
|
+
return typeof a === 'string' ? a : String(a);
|
|
254
|
+
}
|