@haystackeditor/cli 0.15.3 → 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.
@@ -790,9 +790,9 @@ async function stepEnsureAppInstalled(token) {
790
790
  // ui.action emits an `action` event (JSON) or prints + spins (TTY), then polls
791
791
  // until the App is detected or it times out. A mid-poll auth failure surfaces
792
792
  // as a thrown InstallationsAuthError.
793
- let installed = false;
793
+ let result;
794
794
  try {
795
- installed = await ui.action({
795
+ result = await ui.action({
796
796
  id: 'install_app',
797
797
  message: 'Install the Haystack GitHub App, then it is detected automatically.',
798
798
  url: HAYSTACK_APP_INSTALL_URL,
@@ -810,10 +810,18 @@ async function stepEnsureAppInstalled(token) {
810
810
  }
811
811
  throw err;
812
812
  }
813
- if (installed) {
813
+ if (result === 'ok') {
814
814
  console.log(chalk.green('\n ✓ App installed\n'));
815
815
  return;
816
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'
817
825
  trackError('haystack_setup_app_install_timeout', { timeout_ms: APP_INSTALL_TIMEOUT_MS });
818
826
  console.log(chalk.yellow('\n Timed out waiting for App install.'));
819
827
  console.log(chalk.dim(` Install at ${HAYSTACK_APP_INSTALL_URL}, then re-run \`haystack setup\`.\n`));
@@ -836,11 +844,12 @@ async function stepSelectRepos(token) {
836
844
  });
837
845
  if (selectedRepos.length > 0)
838
846
  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) {
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')) {
844
853
  console.log(chalk.yellow('\n No repositories selected. Pass --repo <owner/name>.\n'));
845
854
  process.exit(1);
846
855
  }
@@ -31,10 +31,16 @@ export declare class PromptCancelledError extends Error {
31
31
  readonly requestID: string;
32
32
  constructor(requestID: string);
33
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';
34
37
  export interface Prompter {
35
38
  /** True for the human TTY prompter — gates side effects like opening a browser
36
39
  * that only make sense for a local interactive user. */
37
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;
38
44
  /** Yes/no decision. */
39
45
  confirm(q: {
40
46
  id: string;
@@ -49,9 +55,8 @@ export interface Prompter {
49
55
  }): Promise<T[]>;
50
56
  /**
51
57
  * An action the user must take out-of-band (e.g. install the GitHub App).
52
- * Polls `check()` until it returns true (satisfied) or `timeoutMs` elapses.
53
- * A pre-supplied / stdin answer of "skip" resolves false without polling.
54
- * 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'`.
55
60
  */
56
61
  action(q: {
57
62
  id: string;
@@ -60,7 +65,7 @@ export interface Prompter {
60
65
  check: () => Promise<boolean>;
61
66
  pollIntervalMs?: number;
62
67
  timeoutMs?: number;
63
- }): Promise<boolean>;
68
+ }): Promise<ActionResult>;
64
69
  /** Transient progress line (scan ticker, per-repo write status). */
65
70
  progress(message: string, detail?: string): void;
66
71
  /** Clear the current progress line. */
@@ -50,9 +50,12 @@ class TtyPrompter {
50
50
  constructor(answers) {
51
51
  this.answers = answers;
52
52
  }
53
+ hasPreset(id) {
54
+ return id in this.answers;
55
+ }
53
56
  async confirm(q) {
54
57
  if (q.id in this.answers)
55
- return Boolean(this.answers[q.id]);
58
+ return toBool(this.answers[q.id]);
56
59
  const { value } = await inquirer.prompt([
57
60
  { type: 'confirm', name: 'value', message: q.message, default: q.default ?? true },
58
61
  ]);
@@ -69,9 +72,9 @@ class TtyPrompter {
69
72
  }
70
73
  async action(q) {
71
74
  if (this.answers[q.id] === 'skip')
72
- return false;
75
+ return 'skip';
73
76
  if (await q.check())
74
- return true;
77
+ return 'ok';
75
78
  console.log(chalk.yellow(` ${q.message}`));
76
79
  if (q.url)
77
80
  console.log(` ${chalk.cyan(q.url)}`);
@@ -82,9 +85,9 @@ class TtyPrompter {
82
85
  while (Date.now() - started < timeout) {
83
86
  await sleep(interval);
84
87
  if (await q.check())
85
- return true;
88
+ return 'ok';
86
89
  }
87
- return false;
90
+ return 'timeout';
88
91
  }
89
92
  progress(message, detail) {
90
93
  // Only emit carriage-return / clear-line ANSI to a real TTY; piped to a file
@@ -132,6 +135,9 @@ class JsonPrompter {
132
135
  console.error = origError;
133
136
  };
134
137
  }
138
+ hasPreset(id) {
139
+ return id in this.answers;
140
+ }
135
141
  emit(event) {
136
142
  process.stdout.write(JSON.stringify(event) + '\n');
137
143
  }
@@ -184,15 +190,16 @@ class JsonPrompter {
184
190
  }
185
191
  async confirm(q) {
186
192
  if (q.id in this.answers)
187
- return Boolean(this.answers[q.id]);
193
+ return toBool(this.answers[q.id]);
188
194
  // A confirm is a single-choice question; emit OpenCode's `question` envelope.
189
195
  // Reply: {requestID, answers: <bool>}; reject: {requestID, reject: true}.
190
196
  this.emit({ type: 'question', requestID: q.id, kind: 'confirm', prompt: q.message, default: q.default ?? true });
191
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.
192
200
  if (reply.reject)
193
- return false;
194
- const a = reply.answers ?? reply.value; // `answers` is canonical; `value` accepted
195
- return typeof a === 'boolean' ? a : Boolean(a);
201
+ throw new PromptCancelledError(q.id);
202
+ return toBool(reply.answers ?? reply.value);
196
203
  }
197
204
  async multiselect(q) {
198
205
  if (q.id in this.answers)
@@ -212,9 +219,9 @@ class JsonPrompter {
212
219
  }
213
220
  async action(q) {
214
221
  if (this.answers[q.id] === 'skip')
215
- return false;
222
+ return 'skip';
216
223
  if (await q.check())
217
- return true;
224
+ return 'ok';
218
225
  // An out-of-band action maps to OpenCode's `permission` request. Reply:
219
226
  // {permissionID, response: "once"|"always"|"reject"}. We also keep polling
220
227
  // check() so it resolves on its own once the action completes.
@@ -231,9 +238,9 @@ class JsonPrompter {
231
238
  try {
232
239
  while (Date.now() - started < timeout) {
233
240
  if (await q.check())
234
- return true;
241
+ return 'ok';
235
242
  if (override === 'skip')
236
- return false;
243
+ return 'skip';
237
244
  // 'done' means the agent reports the action complete — but completion may
238
245
  // be eventually-consistent, so keep polling check() at the interval until
239
246
  // it confirms or we time out (matching TTY behavior), rather than failing
@@ -244,7 +251,7 @@ class JsonPrompter {
244
251
  }
245
252
  await Promise.race([sleep(interval), answerPromise]);
246
253
  }
247
- return false;
254
+ return 'timeout';
248
255
  }
249
256
  finally {
250
257
  // If polling or the timeout won before any stdin reply arrived, drop the
@@ -275,6 +282,20 @@ export function createPrompter(options = {}) {
275
282
  function sleep(ms) {
276
283
  return new Promise((resolve) => setTimeout(resolve, ms));
277
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
+ }
278
299
  /** Validate a pre-supplied multiselect answer is actually an array before use,
279
300
  * so a bad --answers value (e.g. a string) fails fast with a clear message
280
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.3",
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": {