@haystackeditor/cli 0.15.2 → 0.15.4

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
  /**
@@ -744,6 +744,13 @@ async function isAppInstalled(token) {
744
744
  // keeps waiting instead of crashing the wizard.
745
745
  if (err instanceof InstallationsAuthError)
746
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
+ });
747
754
  return false;
748
755
  }
749
756
  }
@@ -783,9 +790,9 @@ async function stepEnsureAppInstalled(token) {
783
790
  // ui.action emits an `action` event (JSON) or prints + spins (TTY), then polls
784
791
  // until the App is detected or it times out. A mid-poll auth failure surfaces
785
792
  // as a thrown InstallationsAuthError.
786
- let installed = false;
793
+ let result;
787
794
  try {
788
- installed = await ui.action({
795
+ result = await ui.action({
789
796
  id: 'install_app',
790
797
  message: 'Install the Haystack GitHub App, then it is detected automatically.',
791
798
  url: HAYSTACK_APP_INSTALL_URL,
@@ -803,10 +810,18 @@ async function stepEnsureAppInstalled(token) {
803
810
  }
804
811
  throw err;
805
812
  }
806
- if (installed) {
813
+ if (result === 'ok') {
807
814
  console.log(chalk.green('\n ✓ App installed\n'));
808
815
  return;
809
816
  }
817
+ if (result === 'skip') {
818
+ // Caller intentionally skipped — proceed (refuse-to-fail), but warn that the
819
+ // App is needed for Haystack to actually act on the configured repos.
820
+ console.log(chalk.yellow('\n Skipping GitHub App verification.'));
821
+ console.log(chalk.dim(' Haystack needs the App installed to analyze, triage, or merge your PRs.\n'));
822
+ return;
823
+ }
824
+ // result === 'timeout'
810
825
  trackError('haystack_setup_app_install_timeout', { timeout_ms: APP_INSTALL_TIMEOUT_MS });
811
826
  console.log(chalk.yellow('\n Timed out waiting for App install.'));
812
827
  console.log(chalk.dim(` Install at ${HAYSTACK_APP_INSTALL_URL}, then re-run \`haystack setup\`.\n`));
@@ -829,11 +844,12 @@ async function stepSelectRepos(token) {
829
844
  });
830
845
  if (selectedRepos.length > 0)
831
846
  break;
832
- // Interactive: keep the picker open so the user can correct an empty pick
833
- // (restores the old inquirer `validate` behavior). Non-interactive: a
834
- // pre-supplied / agent-sent empty selection can't be fixed by re-prompting,
835
- // so fail clearly instead of looping forever.
836
- if (!ui.interactive) {
847
+ // Interactive live pick: keep the picker open so the user can correct an
848
+ // empty selection (restores the old inquirer `validate`). But a PRE-SUPPLIED
849
+ // empty selection (--answers select_repos:[]) would be returned unchanged
850
+ // every iteration — re-prompting can't fix it, so fail instead of looping.
851
+ // Non-interactive likewise can't be corrected.
852
+ if (!ui.interactive || ui.hasPreset('select_repos')) {
837
853
  console.log(chalk.yellow('\n No repositories selected. Pass --repo <owner/name>.\n'));
838
854
  process.exit(1);
839
855
  }
@@ -1172,6 +1188,15 @@ export async function setupCommand(options = {}) {
1172
1188
  try {
1173
1189
  await runSetupFlow(options);
1174
1190
  }
1191
+ catch (err) {
1192
+ // An agent cancelling a prompt is a clean exit, not a crash.
1193
+ if (err instanceof PromptCancelledError) {
1194
+ console.log(chalk.yellow('\n Setup cancelled.\n'));
1195
+ ui.result({ status: 'cancelled' });
1196
+ return;
1197
+ }
1198
+ throw err;
1199
+ }
1175
1200
  finally {
1176
1201
  ui.close();
1177
1202
  }
@@ -25,10 +25,22 @@ 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
+ }
34
+ /** Outcome of an `action`: satisfied, intentionally skipped, or timed out —
35
+ * distinct so callers don't report a skip as a timeout. */
36
+ export type ActionResult = 'ok' | 'skip' | 'timeout';
28
37
  export interface Prompter {
29
38
  /** True for the human TTY prompter — gates side effects like opening a browser
30
39
  * that only make sense for a local interactive user. */
31
40
  readonly interactive: boolean;
41
+ /** Whether an answer for `id` was pre-supplied (flag / --answers), so the
42
+ * prompt is skipped. Callers use this to avoid re-prompting a fixed answer. */
43
+ hasPreset(id: string): boolean;
32
44
  /** Yes/no decision. */
33
45
  confirm(q: {
34
46
  id: string;
@@ -43,9 +55,8 @@ export interface Prompter {
43
55
  }): Promise<T[]>;
44
56
  /**
45
57
  * 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.
58
+ * Polls `check()` until it returns true (`'ok'`) or `timeoutMs` elapses
59
+ * (`'timeout'`). A pre-supplied / stdin "skip"/reject resolves `'skip'`.
49
60
  */
50
61
  action(q: {
51
62
  id: string;
@@ -54,7 +65,7 @@ export interface Prompter {
54
65
  check: () => Promise<boolean>;
55
66
  pollIntervalMs?: number;
56
67
  timeoutMs?: number;
57
- }): Promise<boolean>;
68
+ }): Promise<ActionResult>;
58
69
  /** Transient progress line (scan ticker, per-repo write status). */
59
70
  progress(message: string, detail?: string): void;
60
71
  /** Clear the current progress line. */
@@ -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);
@@ -40,9 +50,12 @@ class TtyPrompter {
40
50
  constructor(answers) {
41
51
  this.answers = answers;
42
52
  }
53
+ hasPreset(id) {
54
+ return id in this.answers;
55
+ }
43
56
  async confirm(q) {
44
57
  if (q.id in this.answers)
45
- return Boolean(this.answers[q.id]);
58
+ return toBool(this.answers[q.id]);
46
59
  const { value } = await inquirer.prompt([
47
60
  { type: 'confirm', name: 'value', message: q.message, default: q.default ?? true },
48
61
  ]);
@@ -59,9 +72,9 @@ class TtyPrompter {
59
72
  }
60
73
  async action(q) {
61
74
  if (this.answers[q.id] === 'skip')
62
- return false;
75
+ return 'skip';
63
76
  if (await q.check())
64
- return true;
77
+ return 'ok';
65
78
  console.log(chalk.yellow(` ${q.message}`));
66
79
  if (q.url)
67
80
  console.log(` ${chalk.cyan(q.url)}`);
@@ -72,9 +85,9 @@ class TtyPrompter {
72
85
  while (Date.now() - started < timeout) {
73
86
  await sleep(interval);
74
87
  if (await q.check())
75
- return true;
88
+ return 'ok';
76
89
  }
77
- return false;
90
+ return 'timeout';
78
91
  }
79
92
  progress(message, detail) {
80
93
  // Only emit carriage-return / clear-line ANSI to a real TTY; piped to a file
@@ -122,6 +135,9 @@ class JsonPrompter {
122
135
  console.error = origError;
123
136
  };
124
137
  }
138
+ hasPreset(id) {
139
+ return id in this.answers;
140
+ }
125
141
  emit(event) {
126
142
  process.stdout.write(JSON.stringify(event) + '\n');
127
143
  }
@@ -174,15 +190,16 @@ class JsonPrompter {
174
190
  }
175
191
  async confirm(q) {
176
192
  if (q.id in this.answers)
177
- return Boolean(this.answers[q.id]);
193
+ return toBool(this.answers[q.id]);
178
194
  // A confirm is a single-choice question; emit OpenCode's `question` envelope.
179
195
  // Reply: {requestID, answers: <bool>}; reject: {requestID, reject: true}.
180
196
  this.emit({ type: 'question', requestID: q.id, kind: 'confirm', prompt: q.message, default: q.default ?? true });
181
197
  const reply = await this.awaitReply(q.id);
198
+ // Reject = cancellation (distinct from "No"); parse the answer robustly so a
199
+ // string "false" isn't coerced truthy.
182
200
  if (reply.reject)
183
- return false;
184
- const a = reply.answers ?? reply.value; // `answers` is canonical; `value` accepted
185
- return typeof a === 'boolean' ? a : Boolean(a);
201
+ throw new PromptCancelledError(q.id);
202
+ return toBool(reply.answers ?? reply.value);
186
203
  }
187
204
  async multiselect(q) {
188
205
  if (q.id in this.answers)
@@ -193,16 +210,18 @@ class JsonPrompter {
193
210
  // Reply: {requestID, answers: <value[]>}; reject: {requestID, reject: true}.
194
211
  this.emit({ type: 'question', requestID: q.id, kind: 'multiselect', prompt: q.message, options });
195
212
  const reply = await this.awaitReply(q.id);
213
+ // Surface a rejection as a distinct cancellation rather than masking it as an
214
+ // empty selection, and surface a non-array reply as a protocol error rather
215
+ // than silently coercing to [] (PR001: no silent fallback).
196
216
  if (reply.reject)
197
- return [];
198
- const a = reply.answers ?? reply.value;
199
- return Array.isArray(a) ? a : [];
217
+ throw new PromptCancelledError(q.id);
218
+ return asArray(reply.answers ?? reply.value, q.id);
200
219
  }
201
220
  async action(q) {
202
221
  if (this.answers[q.id] === 'skip')
203
- return false;
222
+ return 'skip';
204
223
  if (await q.check())
205
- return true;
224
+ return 'ok';
206
225
  // An out-of-band action maps to OpenCode's `permission` request. Reply:
207
226
  // {permissionID, response: "once"|"always"|"reject"}. We also keep polling
208
227
  // check() so it resolves on its own once the action completes.
@@ -216,22 +235,29 @@ class JsonPrompter {
216
235
  const r = (reply.response ?? reply.value);
217
236
  override = r === 'reject' || r === 'skip' ? 'skip' : 'done';
218
237
  });
219
- while (Date.now() - started < timeout) {
220
- if (await q.check())
221
- return true;
222
- if (override === 'skip')
223
- return false;
224
- // 'done' means the agent reports the action complete — but completion may
225
- // be eventually-consistent, so keep polling check() at the interval until
226
- // it confirms or we time out (matching TTY behavior), rather than failing
227
- // on a single immediate check.
228
- if (override === 'done') {
229
- await sleep(interval);
230
- continue;
238
+ try {
239
+ while (Date.now() - started < timeout) {
240
+ if (await q.check())
241
+ return 'ok';
242
+ if (override === 'skip')
243
+ return 'skip';
244
+ // 'done' means the agent reports the action complete but completion may
245
+ // be eventually-consistent, so keep polling check() at the interval until
246
+ // it confirms or we time out (matching TTY behavior), rather than failing
247
+ // on a single immediate check.
248
+ if (override === 'done') {
249
+ await sleep(interval);
250
+ continue;
251
+ }
252
+ await Promise.race([sleep(interval), answerPromise]);
231
253
  }
232
- await Promise.race([sleep(interval), answerPromise]);
254
+ return 'timeout';
255
+ }
256
+ finally {
257
+ // If polling or the timeout won before any stdin reply arrived, drop the
258
+ // dangling resolver so it doesn't accumulate across action() calls.
259
+ this.pending.delete(q.id);
233
260
  }
234
- return false;
235
261
  }
236
262
  progress(message, detail) {
237
263
  this.emit({ type: 'progress', message, ...(detail ? { detail } : {}) });
@@ -256,6 +282,20 @@ export function createPrompter(options = {}) {
256
282
  function sleep(ms) {
257
283
  return new Promise((resolve) => setTimeout(resolve, ms));
258
284
  }
285
+ /** Coerce an answer to a boolean without the `Boolean("false") === true` trap:
286
+ * recognizes string forms ("true"/"false", "yes"/"no", "1"/"0") explicitly. */
287
+ function toBool(v) {
288
+ if (typeof v === 'boolean')
289
+ return v;
290
+ if (typeof v === 'string') {
291
+ const s = v.trim().toLowerCase();
292
+ if (['true', 'yes', 'y', '1'].includes(s))
293
+ return true;
294
+ if (['false', 'no', 'n', '0', ''].includes(s))
295
+ return false;
296
+ }
297
+ return Boolean(v);
298
+ }
259
299
  /** Validate a pre-supplied multiselect answer is actually an array before use,
260
300
  * so a bad --answers value (e.g. a string) fails fast with a clear message
261
301
  * instead of crashing downstream array operations. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.2",
3
+ "version": "0.15.4",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {