@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.
- package/dist/commands/setup.js +60 -17
- package/dist/utils/prompter.d.ts +6 -0
- package/dist/utils/prompter.js +54 -19
- package/package.json +1 -1
package/dist/commands/setup.js
CHANGED
|
@@ -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:
|
|
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,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
|
-
|
|
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 —
|
|
738
|
-
//
|
|
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
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
}
|
package/dist/utils/prompter.d.ts
CHANGED
|
@@ -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. */
|
package/dist/utils/prompter.js
CHANGED
|
@@ -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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
}
|