@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.
- package/dist/commands/setup.js +34 -9
- package/dist/utils/prompter.d.ts +15 -4
- package/dist/utils/prompter.js +68 -28
- 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
|
/**
|
|
@@ -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
|
|
793
|
+
let result;
|
|
787
794
|
try {
|
|
788
|
-
|
|
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 (
|
|
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
|
|
833
|
-
// (restores the old inquirer `validate`
|
|
834
|
-
//
|
|
835
|
-
// so fail
|
|
836
|
-
|
|
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
|
}
|
package/dist/utils/prompter.d.ts
CHANGED
|
@@ -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 (
|
|
47
|
-
* A pre-supplied / stdin
|
|
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<
|
|
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. */
|
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);
|
|
@@ -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
|
|
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
|
|
75
|
+
return 'skip';
|
|
63
76
|
if (await q.check())
|
|
64
|
-
return
|
|
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
|
|
88
|
+
return 'ok';
|
|
76
89
|
}
|
|
77
|
-
return
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
222
|
+
return 'skip';
|
|
204
223
|
if (await q.check())
|
|
205
|
-
return
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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. */
|