@haystackeditor/cli 0.15.0 → 0.15.2

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.
@@ -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,26 @@ 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
+ return false;
748
+ }
734
749
  }
735
750
  async function stepEnsureAppInstalled(token) {
736
751
  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).
752
+ // Initial check — use the raw fetch (not isAppInstalled, which swallows
753
+ // transient errors) so we can distinguish three outcomes: installed (done),
754
+ // genuine auth failure (bail + prompt re-auth), or a transient probe error
755
+ // (refuse-to-fail and proceed rather than brick setup over a blip).
739
756
  try {
740
- if (await isAppInstalled(token)) {
757
+ if ((await fetchHaystackInstallations(token)).length > 0) {
741
758
  console.log(chalk.green(' ✓ Haystack App installed\n'));
742
759
  return;
743
760
  }
@@ -803,14 +820,24 @@ async function stepSelectRepos(token) {
803
820
  console.log(chalk.yellow('\n No repositories found.\n'));
804
821
  process.exit(1);
805
822
  }
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);
823
+ let selectedRepos = [];
824
+ for (;;) {
825
+ selectedRepos = await ui.multiselect({
826
+ id: 'select_repos',
827
+ message: 'Select repositories to configure:',
828
+ choices: repos.map((r) => ({ name: r, value: r })),
829
+ });
830
+ if (selectedRepos.length > 0)
831
+ 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) {
837
+ console.log(chalk.yellow('\n No repositories selected. Pass --repo <owner/name>.\n'));
838
+ process.exit(1);
839
+ }
840
+ console.log(chalk.yellow(' Select at least one repository.'));
814
841
  }
815
842
  console.log(chalk.green(` ${selectedRepos.length} repo(s) selected\n`));
816
843
  return selectedRepos;
@@ -50,7 +50,7 @@ class TtyPrompter {
50
50
  }
51
51
  async multiselect(q) {
52
52
  if (q.id in this.answers)
53
- return this.answers[q.id];
53
+ return asArray(this.answers[q.id], q.id);
54
54
  const choices = q.choices.map((c) => isSeparator(c) ? new inquirer.Separator(c.separator) : { name: c.name, value: c.value, checked: c.checked });
55
55
  const { value } = await inquirer.prompt([
56
56
  { type: 'checkbox', name: 'value', message: q.message, choices: choices, pageSize: 20 },
@@ -77,9 +77,16 @@ class TtyPrompter {
77
77
  return false;
78
78
  }
79
79
  progress(message, detail) {
80
+ // Only emit carriage-return / clear-line ANSI to a real TTY; piped to a file
81
+ // or CI log (non-interactive, no --json) those sequences corrupt the output,
82
+ // so stay silent there.
83
+ if (!process.stdout.isTTY)
84
+ return;
80
85
  process.stdout.write(`\r${chalk.dim(' ' + message)}${detail ? chalk.dim(' — ' + detail) : ''}\x1b[K`);
81
86
  }
82
87
  clearProgress() {
88
+ if (!process.stdout.isTTY)
89
+ return;
83
90
  process.stdout.write('\r\x1b[K');
84
91
  }
85
92
  result(_data) {
@@ -131,22 +138,32 @@ class JsonPrompter {
131
138
  try {
132
139
  obj = JSON.parse(trimmed);
133
140
  }
134
- catch {
135
- return; // ignore non-JSON noise on stdin
141
+ catch (err) {
142
+ // Surface malformed input on stderr (PR001: no silent swallow) so an
143
+ // agent can tell its line was rejected and resend, instead of the
144
+ // prompt hanging on input that never parsed. stderr keeps stdout NDJSON.
145
+ process.stderr.write(`haystack setup: ignoring unparseable stdin line: ${err instanceof Error ? err.message : String(err)}\n`);
146
+ return;
136
147
  }
137
- if (!obj || typeof obj.id !== 'string')
148
+ if (!obj || typeof obj !== 'object')
149
+ return;
150
+ // Mirror OpenCode: question replies are keyed by `requestID`, permission
151
+ // replies by `permissionID`. Legacy `id` accepted too.
152
+ const key = (obj.requestID ?? obj.permissionID ?? obj.id);
153
+ if (typeof key !== 'string')
138
154
  return;
139
- const resolve = this.pending.get(obj.id);
155
+ const resolve = this.pending.get(key);
140
156
  if (resolve) {
141
- this.pending.delete(obj.id);
142
- resolve(obj.value);
157
+ this.pending.delete(key);
158
+ resolve(obj);
143
159
  }
144
160
  else {
145
- this.buffered.set(obj.id, obj.value);
161
+ this.buffered.set(key, obj);
146
162
  }
147
163
  });
148
164
  }
149
- awaitAnswer(id) {
165
+ /** Wait for the agent's reply object, keyed by the request/permission id. */
166
+ awaitReply(id) {
150
167
  if (this.buffered.has(id)) {
151
168
  const v = this.buffered.get(id);
152
169
  this.buffered.delete(id);
@@ -158,42 +175,60 @@ class JsonPrompter {
158
175
  async confirm(q) {
159
176
  if (q.id in this.answers)
160
177
  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));
178
+ // A confirm is a single-choice question; emit OpenCode's `question` envelope.
179
+ // Reply: {requestID, answers: <bool>}; reject: {requestID, reject: true}.
180
+ this.emit({ type: 'question', requestID: q.id, kind: 'confirm', prompt: q.message, default: q.default ?? true });
181
+ const reply = await this.awaitReply(q.id);
182
+ 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);
163
186
  }
164
187
  async multiselect(q) {
165
188
  if (q.id in this.answers)
166
- return this.answers[q.id];
189
+ return asArray(this.answers[q.id], q.id);
167
190
  const options = q.choices
168
191
  .filter((c) => !isSeparator(c))
169
192
  .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));
193
+ // Reply: {requestID, answers: <value[]>}; reject: {requestID, reject: true}.
194
+ this.emit({ type: 'question', requestID: q.id, kind: 'multiselect', prompt: q.message, options });
195
+ const reply = await this.awaitReply(q.id);
196
+ if (reply.reject)
197
+ return [];
198
+ const a = reply.answers ?? reply.value;
199
+ return Array.isArray(a) ? a : [];
172
200
  }
173
201
  async action(q) {
174
202
  if (this.answers[q.id] === 'skip')
175
203
  return false;
176
204
  if (await q.check())
177
205
  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 });
206
+ // An out-of-band action maps to OpenCode's `permission` request. Reply:
207
+ // {permissionID, response: "once"|"always"|"reject"}. We also keep polling
208
+ // check() so it resolves on its own once the action completes.
209
+ this.emit({ type: 'permission', permissionID: q.id, prompt: q.message, url: q.url, poll: true });
181
210
  const started = Date.now();
182
211
  const timeout = q.timeoutMs ?? 10 * 60 * 1000;
183
212
  const interval = q.pollIntervalMs ?? 3000;
184
213
  let override = null;
185
- // Resolve the moment the agent answers, OR when polling detects completion.
186
214
  this.ensureStdin();
187
- const answerPromise = this.awaitAnswer(q.id).then((v) => {
188
- override = v === 'skip' ? 'skip' : 'done';
215
+ const answerPromise = this.awaitReply(q.id).then((reply) => {
216
+ const r = (reply.response ?? reply.value);
217
+ override = r === 'reject' || r === 'skip' ? 'skip' : 'done';
189
218
  });
190
219
  while (Date.now() - started < timeout) {
191
220
  if (await q.check())
192
221
  return true;
193
222
  if (override === 'skip')
194
223
  return false;
195
- if (override === 'done')
196
- return (await q.check()); // agent says installed; trust but verify
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;
231
+ }
197
232
  await Promise.race([sleep(interval), answerPromise]);
198
233
  }
199
234
  return false;
@@ -221,6 +256,15 @@ export function createPrompter(options = {}) {
221
256
  function sleep(ms) {
222
257
  return new Promise((resolve) => setTimeout(resolve, ms));
223
258
  }
259
+ /** Validate a pre-supplied multiselect answer is actually an array before use,
260
+ * so a bad --answers value (e.g. a string) fails fast with a clear message
261
+ * instead of crashing downstream array operations. */
262
+ function asArray(value, id) {
263
+ if (!Array.isArray(value)) {
264
+ throw new Error(`Answer for "${id}" must be a JSON array, got ${typeof value}.`);
265
+ }
266
+ return value;
267
+ }
224
268
  function stringifyArg(a) {
225
269
  return typeof a === 'string' ? a : String(a);
226
270
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {