@haystackeditor/cli 0.15.1 → 0.15.3

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.
@@ -19,7 +19,7 @@ import { resolveAuthContext } from '../utils/auth.js';
19
19
  import { hooksInstall } from './hooks.js';
20
20
  import { findGitRoot } from '../utils/hooks.js';
21
21
  import { trackError, trackSetupEvent } from '../utils/telemetry.js';
22
- import { createPrompter, loadAnswersFile } from '../utils/prompter.js';
22
+ import { createPrompter, loadAnswersFile, PromptCancelledError } from '../utils/prompter.js';
23
23
  /** The active prompter for this `setup` invocation (TTY or JSON). */
24
24
  let ui;
25
25
  /**
@@ -39,8 +39,11 @@ function buildAnswerMap(options) {
39
39
  answers.install_hooks = false;
40
40
  }
41
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.
42
+ // Accept the interactive wizard's defaults non-interactively: keep every
43
+ // discovered item (skip the toggle UI), confirm the write, and say yes to
44
+ // the same things the prompts default to (auto-merge, session tracking,
45
+ // hooks). Use --skip-entire / --no-auto-merge to opt out. Only fill ids not
46
+ // already set by an explicit flag or --answers.
44
47
  if (!('want_to_toggle' in answers))
45
48
  answers.want_to_toggle = false;
46
49
  if (!('confirm_write' in answers))
@@ -48,9 +51,11 @@ function buildAnswerMap(options) {
48
51
  if (!('auto_merge' in answers))
49
52
  answers.auto_merge = true;
50
53
  if (!('install_entire' in answers))
51
- answers.install_entire = false;
54
+ answers.install_entire = true;
55
+ if (!('upgrade_entire' in answers))
56
+ answers.upgrade_entire = true;
52
57
  if (!('install_hooks' in answers))
53
- answers.install_hooks = false;
58
+ answers.install_hooks = true;
54
59
  }
55
60
  return answers;
56
61
  }
@@ -730,14 +735,33 @@ function tryOpenBrowser(url) {
730
735
  * the poll keeps waiting) but rethrows a genuine auth failure so the caller can
731
736
  * bail and prompt re-auth. */
732
737
  async function isAppInstalled(token) {
733
- return (await fetchHaystackInstallations(token)).length > 0;
738
+ try {
739
+ return (await fetchHaystackInstallations(token)).length > 0;
740
+ }
741
+ catch (err) {
742
+ // Auth failures are permanent — surface them. Anything else (5xx, network
743
+ // blip) is transient during the install poll: return false so the poll
744
+ // keeps waiting instead of crashing the wizard.
745
+ if (err instanceof InstallationsAuthError)
746
+ throw err;
747
+ // Transient (5xx / network / rate limit): log so it isn't a silent fallback
748
+ // (PR001), then return false so the install poll keeps waiting. Re-throwing
749
+ // here — a literal reading of "no silent fallback" — would reintroduce the
750
+ // poll-crash this function exists to prevent, so we log instead.
751
+ trackError('haystack_setup_app_check_transient_error', {
752
+ error_message: err instanceof Error ? err.message : String(err),
753
+ });
754
+ return false;
755
+ }
734
756
  }
735
757
  async function stepEnsureAppInstalled(token) {
736
758
  console.log(chalk.bold(' Step 0: Verify GitHub App'));
737
- // Initial check — distinguish a genuine auth failure (bail, prompt re-auth)
738
- // from a transient probe error (refuse-to-fail and proceed).
759
+ // Initial check — use the raw fetch (not isAppInstalled, which swallows
760
+ // transient errors) so we can distinguish three outcomes: installed (done),
761
+ // genuine auth failure (bail + prompt re-auth), or a transient probe error
762
+ // (refuse-to-fail and proceed rather than brick setup over a blip).
739
763
  try {
740
- if (await isAppInstalled(token)) {
764
+ if ((await fetchHaystackInstallations(token)).length > 0) {
741
765
  console.log(chalk.green(' ✓ Haystack App installed\n'));
742
766
  return;
743
767
  }
@@ -803,14 +827,24 @@ async function stepSelectRepos(token) {
803
827
  console.log(chalk.yellow('\n No repositories found.\n'));
804
828
  process.exit(1);
805
829
  }
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);
830
+ let selectedRepos = [];
831
+ for (;;) {
832
+ selectedRepos = await ui.multiselect({
833
+ id: 'select_repos',
834
+ message: 'Select repositories to configure:',
835
+ choices: repos.map((r) => ({ name: r, value: r })),
836
+ });
837
+ if (selectedRepos.length > 0)
838
+ break;
839
+ // Interactive: keep the picker open so the user can correct an empty pick
840
+ // (restores the old inquirer `validate` behavior). Non-interactive: a
841
+ // pre-supplied / agent-sent empty selection can't be fixed by re-prompting,
842
+ // so fail clearly instead of looping forever.
843
+ if (!ui.interactive) {
844
+ console.log(chalk.yellow('\n No repositories selected. Pass --repo <owner/name>.\n'));
845
+ process.exit(1);
846
+ }
847
+ console.log(chalk.yellow(' Select at least one repository.'));
814
848
  }
815
849
  console.log(chalk.green(` ${selectedRepos.length} repo(s) selected\n`));
816
850
  return selectedRepos;
@@ -1145,6 +1179,15 @@ export async function setupCommand(options = {}) {
1145
1179
  try {
1146
1180
  await runSetupFlow(options);
1147
1181
  }
1182
+ catch (err) {
1183
+ // An agent cancelling a prompt is a clean exit, not a crash.
1184
+ if (err instanceof PromptCancelledError) {
1185
+ console.log(chalk.yellow('\n Setup cancelled.\n'));
1186
+ ui.result({ status: 'cancelled' });
1187
+ return;
1188
+ }
1189
+ throw err;
1190
+ }
1148
1191
  finally {
1149
1192
  ui.close();
1150
1193
  }
@@ -25,6 +25,12 @@ export type Choice<T> = {
25
25
  } | {
26
26
  separator: string;
27
27
  };
28
+ /** Thrown when an agent explicitly rejects/cancels a prompt (vs. answering it),
29
+ * so callers can distinguish a cancellation from a valid empty answer. */
30
+ export declare class PromptCancelledError extends Error {
31
+ readonly requestID: string;
32
+ constructor(requestID: string);
33
+ }
28
34
  export interface Prompter {
29
35
  /** True for the human TTY prompter — gates side effects like opening a browser
30
36
  * that only make sense for a local interactive user. */
@@ -23,6 +23,16 @@ import { readFileSync } from 'node:fs';
23
23
  function isSeparator(c) {
24
24
  return c.separator !== undefined;
25
25
  }
26
+ /** Thrown when an agent explicitly rejects/cancels a prompt (vs. answering it),
27
+ * so callers can distinguish a cancellation from a valid empty answer. */
28
+ export class PromptCancelledError extends Error {
29
+ requestID;
30
+ constructor(requestID) {
31
+ super(`Prompt "${requestID}" was cancelled.`);
32
+ this.requestID = requestID;
33
+ this.name = 'PromptCancelledError';
34
+ }
35
+ }
26
36
  export function loadAnswersFile(path) {
27
37
  const raw = readFileSync(path, 'utf8');
28
38
  const parsed = JSON.parse(raw);
@@ -50,7 +60,7 @@ class TtyPrompter {
50
60
  }
51
61
  async multiselect(q) {
52
62
  if (q.id in this.answers)
53
- return this.answers[q.id];
63
+ return asArray(this.answers[q.id], q.id);
54
64
  const choices = q.choices.map((c) => isSeparator(c) ? new inquirer.Separator(c.separator) : { name: c.name, value: c.value, checked: c.checked });
55
65
  const { value } = await inquirer.prompt([
56
66
  { type: 'checkbox', name: 'value', message: q.message, choices: choices, pageSize: 20 },
@@ -77,9 +87,16 @@ class TtyPrompter {
77
87
  return false;
78
88
  }
79
89
  progress(message, detail) {
90
+ // Only emit carriage-return / clear-line ANSI to a real TTY; piped to a file
91
+ // or CI log (non-interactive, no --json) those sequences corrupt the output,
92
+ // so stay silent there.
93
+ if (!process.stdout.isTTY)
94
+ return;
80
95
  process.stdout.write(`\r${chalk.dim(' ' + message)}${detail ? chalk.dim(' — ' + detail) : ''}\x1b[K`);
81
96
  }
82
97
  clearProgress() {
98
+ if (!process.stdout.isTTY)
99
+ return;
83
100
  process.stdout.write('\r\x1b[K');
84
101
  }
85
102
  result(_data) {
@@ -179,17 +196,19 @@ class JsonPrompter {
179
196
  }
180
197
  async multiselect(q) {
181
198
  if (q.id in this.answers)
182
- return this.answers[q.id];
199
+ return asArray(this.answers[q.id], q.id);
183
200
  const options = q.choices
184
201
  .filter((c) => !isSeparator(c))
185
202
  .map((c) => ({ value: c.value, label: c.name, default: c.checked ?? false }));
186
203
  // Reply: {requestID, answers: <value[]>}; reject: {requestID, reject: true}.
187
204
  this.emit({ type: 'question', requestID: q.id, kind: 'multiselect', prompt: q.message, options });
188
205
  const reply = await this.awaitReply(q.id);
206
+ // Surface a rejection as a distinct cancellation rather than masking it as an
207
+ // empty selection, and surface a non-array reply as a protocol error rather
208
+ // than silently coercing to [] (PR001: no silent fallback).
189
209
  if (reply.reject)
190
- return [];
191
- const a = reply.answers ?? reply.value;
192
- return Array.isArray(a) ? a : [];
210
+ throw new PromptCancelledError(q.id);
211
+ return asArray(reply.answers ?? reply.value, q.id);
193
212
  }
194
213
  async action(q) {
195
214
  if (this.answers[q.id] === 'skip')
@@ -209,22 +228,29 @@ class JsonPrompter {
209
228
  const r = (reply.response ?? reply.value);
210
229
  override = r === 'reject' || r === 'skip' ? 'skip' : 'done';
211
230
  });
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;
231
+ try {
232
+ while (Date.now() - started < timeout) {
233
+ if (await q.check())
234
+ return true;
235
+ if (override === 'skip')
236
+ return false;
237
+ // 'done' means the agent reports the action complete but completion may
238
+ // be eventually-consistent, so keep polling check() at the interval until
239
+ // it confirms or we time out (matching TTY behavior), rather than failing
240
+ // on a single immediate check.
241
+ if (override === 'done') {
242
+ await sleep(interval);
243
+ continue;
244
+ }
245
+ await Promise.race([sleep(interval), answerPromise]);
224
246
  }
225
- await Promise.race([sleep(interval), answerPromise]);
247
+ return false;
248
+ }
249
+ finally {
250
+ // If polling or the timeout won before any stdin reply arrived, drop the
251
+ // dangling resolver so it doesn't accumulate across action() calls.
252
+ this.pending.delete(q.id);
226
253
  }
227
- return false;
228
254
  }
229
255
  progress(message, detail) {
230
256
  this.emit({ type: 'progress', message, ...(detail ? { detail } : {}) });
@@ -249,6 +275,15 @@ export function createPrompter(options = {}) {
249
275
  function sleep(ms) {
250
276
  return new Promise((resolve) => setTimeout(resolve, ms));
251
277
  }
278
+ /** Validate a pre-supplied multiselect answer is actually an array before use,
279
+ * so a bad --answers value (e.g. a string) fails fast with a clear message
280
+ * instead of crashing downstream array operations. */
281
+ function asArray(value, id) {
282
+ if (!Array.isArray(value)) {
283
+ throw new Error(`Answer for "${id}" must be a JSON array, got ${typeof value}.`);
284
+ }
285
+ return value;
286
+ }
252
287
  function stringifyArg(a) {
253
288
  return typeof a === 'string' ? a : String(a);
254
289
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {