@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.
- package/dist/commands/setup.js +43 -16
- package/dist/utils/prompter.js +66 -22
- package/package.json +1 -1
package/dist/commands/setup.js
CHANGED
|
@@ -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:
|
|
43
|
-
// discovered item)
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 —
|
|
738
|
-
//
|
|
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
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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;
|
package/dist/utils/prompter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
155
|
+
const resolve = this.pending.get(key);
|
|
140
156
|
if (resolve) {
|
|
141
|
-
this.pending.delete(
|
|
142
|
-
resolve(obj
|
|
157
|
+
this.pending.delete(key);
|
|
158
|
+
resolve(obj);
|
|
143
159
|
}
|
|
144
160
|
else {
|
|
145
|
-
this.buffered.set(
|
|
161
|
+
this.buffered.set(key, obj);
|
|
146
162
|
}
|
|
147
163
|
});
|
|
148
164
|
}
|
|
149
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
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.
|
|
188
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
}
|