@haystackeditor/cli 0.15.5 → 0.15.6

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.
@@ -125,7 +125,7 @@ const CONFIG_COMMIT_MSG = 'chore: configure Haystack via CLI setup wizard';
125
125
  const AUTO_MERGE_LABEL = 'haystack:auto-merge';
126
126
  const ONBOARDING_BOOTSTRAP_LABEL = 'haystack:onboarding-bootstrap';
127
127
  const BOOTSTRAP_PR_TITLE = 'Configure Haystack';
128
- const BOOTSTRAP_PR_BODY_INTRO = 'This PR was opened automatically by the Haystack CLI setup wizard because the default branch is protected.\n\n' +
128
+ const BOOTSTRAP_PR_BODY_INTRO = 'This PR was opened automatically by the Haystack CLI setup wizard to configure Haystack for this repo.\n\n' +
129
129
  'It adds the Haystack onboarding config:\n' +
130
130
  '- `.haystack/pr-rules.yml` — rules Haystack enforces on every PR\n' +
131
131
  '- `.haystack/review-policy.md` — path-scoped review policies + review instructions\n' +
@@ -146,47 +146,6 @@ const BOOTSTRAP_PR_OUTRO_MANUAL = 'Auto-merge is off for this repo, so this PR i
146
146
  function encodeBranchPath(branch) {
147
147
  return branch.split('/').map(encodeURIComponent).join('/');
148
148
  }
149
- /**
150
- * Is the given branch protected such that a direct commit can't land —
151
- * either by legacy branch protection OR a repository ruleset?
152
- *
153
- * Decided from GitHub's *structured* signals, not error-message text:
154
- * - `GET /repos/{o}/{r}/branches/{b}` → `.protected` (legacy protection)
155
- * - `GET /repos/{o}/{r}/rules/branches/{b}` → a `pull_request` rule means
156
- * "changes must go through a PR" (rulesets)
157
- *
158
- * Called on the write-failure path: a 409/422 from the direct commit tells
159
- * us *that* something rejected the write; this tells us *whether* it was
160
- * protection (→ open a bootstrap PR) vs. a genuine bad request (→ surface
161
- * the error). If the probe itself can't be completed we throw — guessing
162
- * from message wording is exactly the brittleness we're avoiding here.
163
- */
164
- async function isBranchProtected(owner, repo, branch, token) {
165
- const headers = ghHeaders(token);
166
- const enc = encodeBranchPath(branch);
167
- // Legacy branch protection — the `protected` boolean is authoritative.
168
- const branchRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/branches/${enc}`, { headers });
169
- if (branchRes.ok) {
170
- const data = (await branchRes.json());
171
- if (data.protected === true)
172
- return true;
173
- }
174
- else if (branchRes.status !== 404) {
175
- throw new Error(`Couldn't check branch protection: ${branchRes.status} — ${await branchRes.text()}`);
176
- }
177
- // Repository rulesets — `rules/branches` collapses every active rule on
178
- // the branch into one list. A `pull_request` rule = "must merge via PR".
179
- const rulesRes = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/rules/branches/${enc}`, { headers });
180
- if (rulesRes.ok) {
181
- const rules = (await rulesRes.json());
182
- if (rules.some((r) => r.type === 'pull_request'))
183
- return true;
184
- }
185
- else if (rulesRes.status !== 404) {
186
- throw new Error(`Couldn't check repository rulesets: ${rulesRes.status} — ${await rulesRes.text()}`);
187
- }
188
- return false;
189
- }
190
149
  function ghHeaders(token) {
191
150
  return {
192
151
  Authorization: `Bearer ${token}`,
@@ -381,56 +340,6 @@ async function addBootstrapLabels(owner, repo, prNumber, prUrl, token, autoMerge
381
340
  `Add these labels to the PR manually so Haystack picks it up: ${labels.join(', ')}. ` +
382
341
  `Don't re-run \`haystack setup\` — that opens a duplicate PR.`);
383
342
  }
384
- /** Wraps a contents-API write failure with its HTTP status so the caller can
385
- * distinguish a protection rejection (409/422 → PR fallback) from a genuine
386
- * error (→ surface it). */
387
- class GitHubWriteError extends Error {
388
- status;
389
- body;
390
- constructor(status, body) {
391
- super(`GitHub write failed: ${status}`);
392
- this.status = status;
393
- this.body = body;
394
- this.name = 'GitHubWriteError';
395
- }
396
- }
397
- /** Read the blob SHA of an existing file (needed to update it). Non-OK (incl.
398
- * 404 = file doesn't exist yet) returns null; the subsequent PUT then creates
399
- * the file, and surfaces a real error if the write itself fails. */
400
- async function fetchFileSha(owner, repo, path, token) {
401
- const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/${path}`, { headers: ghHeaders(token) });
402
- if (!res.ok) {
403
- if (res.status !== 404) {
404
- trackError('haystack_setup_fetch_file_sha_failed', {
405
- owner,
406
- repo,
407
- path,
408
- status: res.status,
409
- });
410
- }
411
- return null;
412
- }
413
- const data = (await res.json());
414
- return data.sha ?? null;
415
- }
416
- /** PUT a single file to the default branch via the contents API. Throws
417
- * GitHubWriteError on any non-OK response so the caller can branch on status. */
418
- async function putFile(owner, repo, path, content, message, sha, token) {
419
- const body = {
420
- message,
421
- content: Buffer.from(content).toString('base64'),
422
- };
423
- if (sha)
424
- body.sha = sha;
425
- const res = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/${path}`, {
426
- method: 'PUT',
427
- headers: ghHeaders(token),
428
- body: JSON.stringify(body),
429
- });
430
- if (!res.ok) {
431
- throw new GitHubWriteError(res.status, await res.text().catch(() => ''));
432
- }
433
- }
434
343
  /**
435
344
  * Protected-branch fallback: build ONE commit containing ALL the onboarding
436
345
  * files (`configFiles` + `.entire/settings.json`) via the Git Data API, CREATE
@@ -495,50 +404,17 @@ async function openBootstrapConfigPR(owner, repo, configFiles, token, defaultBra
495
404
  return { prUrl: pr.html_url, entireSettingsBundled };
496
405
  }
497
406
  /**
498
- * Write the onboarding config files to a repo. Tries a direct commit per file
499
- * to the default branch first; on a protected-branch / ruleset rejection
500
- * (409/422 confirmed by a structured protection probe) falls back to ONE
501
- * bootstrap PR containing all files. Mirrors the web wizard's
502
- * writeOnboardingFiles.
503
- *
504
- * Returns `{ bootstrapPR }` when the PR fallback was used (so the caller can
505
- * thread the branch into step 7), `{}` on clean direct commits.
407
+ * Write the onboarding config files to a repo by ALWAYS opening a PR
408
+ * (branch one commit PR) — never a direct commit to the default branch.
409
+ * Matches the web wizard (#1936): a silent push to someone's default branch is
410
+ * jarring and hard to undo, so onboarding always goes through a "Configure
411
+ * Haystack" PR. The PR is enrolled for auto-merge only when the user kept
412
+ * auto-merge on; otherwise it waits for them to review and merge it.
506
413
  */
507
414
  async function writeOnboardingFilesToRepo(owner, repo, files, autoMergeEnabled, token) {
508
- // Look up existing SHAs once on the default branch so updates to files that
509
- // already exist carry the right sha. Done before any write so a mid-loop
510
- // failure doesn't leave us guessing.
511
- const shaByPath = new Map();
512
- for (const f of files) {
513
- shaByPath.set(f.path, await fetchFileSha(owner, repo, f.path, token));
514
- }
515
- let successfulWrites = 0;
516
- try {
517
- for (const f of files) {
518
- await putFile(owner, repo, f.path, f.content, f.message, shaByPath.get(f.path) ?? null, token);
519
- successfulWrites++;
520
- }
521
- return {};
522
- }
523
- catch (err) {
524
- // Only a 409/422 can be a protection rejection. Anything else (auth, 404,
525
- // 5xx, network) is a genuine error — surface it.
526
- const status = err instanceof GitHubWriteError ? err.status : 0;
527
- if (status !== 409 && status !== 422)
528
- throw err;
529
- if (successfulWrites > 0) {
530
- throw err;
531
- }
532
- // Probe GitHub's *structured* protection signals to decide: protection
533
- // (→ PR fallback) vs. a genuine bad request / sha conflict (→ surface the
534
- // error). This replaces brittle error-message substring matching.
535
- const defaultBranch = await getDefaultBranch(owner, repo, token);
536
- if (!(await isBranchProtected(owner, repo, defaultBranch, token))) {
537
- throw err;
538
- }
539
- const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled);
540
- return { bootstrapPR };
541
- }
415
+ const defaultBranch = await getDefaultBranch(owner, repo, token);
416
+ const bootstrapPR = await openBootstrapConfigPR(owner, repo, files, token, defaultBranch, autoMergeEnabled);
417
+ return { bootstrapPR };
542
418
  }
543
419
  const SCAN_POLL_INTERVAL_MS = 1500;
544
420
  // A run still marked `running` but whose KV state hasn't been touched for this
@@ -864,8 +740,6 @@ async function stepSelectRepos(token) {
864
740
  const PR_RULES_PATH = '.haystack/pr-rules.yml';
865
741
  const REVIEW_POLICY_PATH = '.haystack/review-policy.md';
866
742
  const HAYSTACK_CONFIG_PATH = '.haystack.json';
867
- const RULES_COMMIT_MSG = 'chore: configure Haystack rules via CLI setup wizard';
868
- const POLICY_COMMIT_MSG = 'chore: add review policies via CLI setup wizard';
869
743
  /**
870
744
  * Scan each selected repo with one unified scan and collect the proposed
871
745
  * rules / policies / instructions per repo. A failed scan for one repo records
@@ -1093,13 +967,13 @@ function buildOnboardingFiles(result, autoMerge) {
1093
967
  const files = [];
1094
968
  const prRules = buildPrRules(result.rules);
1095
969
  if (prRules.length > 0) {
1096
- files.push({ path: PR_RULES_PATH, content: serializePrRulesYaml(prRules), message: RULES_COMMIT_MSG });
970
+ files.push({ path: PR_RULES_PATH, content: serializePrRulesYaml(prRules) });
1097
971
  }
1098
972
  const reviewPolicyMd = buildReviewPolicyMarkdown(result.policies, result.instructions);
1099
973
  if (reviewPolicyMd) {
1100
- files.push({ path: REVIEW_POLICY_PATH, content: reviewPolicyMd, message: POLICY_COMMIT_MSG });
974
+ files.push({ path: REVIEW_POLICY_PATH, content: reviewPolicyMd });
1101
975
  }
1102
- files.push({ path: HAYSTACK_CONFIG_PATH, content: buildHaystackConfigJson(autoMerge), message: CONFIG_COMMIT_MSG });
976
+ files.push({ path: HAYSTACK_CONFIG_PATH, content: buildHaystackConfigJson(autoMerge) });
1103
977
  return files;
1104
978
  }
1105
979
  async function stepConfirm(byRepo, token) {
@@ -1146,24 +1020,17 @@ async function stepConfirm(byRepo, token) {
1146
1020
  const result = byRepo.get(repoFullName);
1147
1021
  const files = buildOnboardingFiles(result, autoMerge);
1148
1022
  try {
1149
- ui.progress(`Writing to ${repoFullName}...`);
1023
+ ui.progress(`Opening Configure Haystack PR for ${repoFullName}...`);
1150
1024
  const writeResult = await writeOnboardingFilesToRepo(owner, repo, files, autoMerge, token);
1151
1025
  ui.clearProgress();
1152
- if (writeResult.bootstrapPR) {
1153
- // Default branch was protected config landed in a bootstrap PR. With
1154
- // auto-merge on, Haystack merges it once its labels are processed; with
1155
- // auto-merge off it waits for the user. Step 7 ensures
1156
- // .entire/settings.json is on the same PR.
1157
- bootstrapPRs.set(repoFullName, writeResult.bootstrapPR);
1158
- const note = autoMerge
1159
- ? ' (default branch protected — opened a PR)'
1160
- : ' (default branch protected — opened a PR for you to review and merge)';
1161
- console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(note));
1162
- console.log(chalk.dim(` ${writeResult.bootstrapPR.prUrl}`));
1163
- }
1164
- else {
1165
- console.log(chalk.green(` ✓ ${repoFullName}`));
1166
- }
1026
+ // Onboarding always opens a PR (never a direct commit). With auto-merge on,
1027
+ // Haystack merges it once its labels are processed; with auto-merge off it
1028
+ // waits for the user. Step 7 ensures .entire/settings.json rode along in
1029
+ // the same commit.
1030
+ bootstrapPRs.set(repoFullName, writeResult.bootstrapPR);
1031
+ const note = autoMerge ? ' (opened a PR)' : ' (opened a PR for you to review and merge)';
1032
+ console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(note));
1033
+ console.log(chalk.dim(` ${writeResult.bootstrapPR.prUrl}`));
1167
1034
  }
1168
1035
  catch (err) {
1169
1036
  ui.clearProgress();
@@ -1242,11 +1109,11 @@ async function runSetupFlow(options) {
1242
1109
  ui.result({ status: 'cancelled' });
1243
1110
  return;
1244
1111
  }
1245
- // Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it
1246
- // which repos had a protected default branch so it can commit
1247
- // .entire/settings.json to the same PR instead of a doomed direct PUT.
1248
- // --skip-entire skips the whole step (binary install, hooks, AND the remote
1249
- // .entire/settings.json write) — not just the install prompt.
1112
+ // Step 7: Install Entire CLI (session tracking). bootstrapPRs tells it which
1113
+ // repos got a Configure Haystack PR (all of them) so it can report that
1114
+ // .entire/settings.json rode along in that PR's commit.
1115
+ // --skip-entire skips the whole step (binary install + hooks) not just the
1116
+ // install prompt.
1250
1117
  if (options.skipEntire) {
1251
1118
  console.log(chalk.dim(' Skipping session tracking (--skip-entire).\n'));
1252
1119
  }
@@ -1344,53 +1211,6 @@ async function installEntireBinary() {
1344
1211
  return false;
1345
1212
  }
1346
1213
  }
1347
- /**
1348
- * Write .entire/settings.json directly to the default branch.
1349
- *
1350
- * This is the DIRECT path only. Repos whose default branch is protected
1351
- * never reach here — step 6's openBootstrapConfigPR already bundled
1352
- * .entire/settings.json into the bootstrap PR's single commit (on a ~ALL
1353
- * ruleset you can't add it as a later commit, so it must ride along in the
1354
- * branch-creation commit). stepInstallEntire skips this call for those
1355
- * repos. So if we're here, step 6 proved the default branch accepts a
1356
- * direct commit.
1357
- */
1358
- async function configureEntireSettingsViaAPI(repoFullName, token) {
1359
- const [owner, repo] = repoFullName.split('/');
1360
- const result = await buildEntireSettingsContent(repoFullName, token);
1361
- // null → file already exists unchanged; nothing to write.
1362
- if (result === null)
1363
- return;
1364
- // buildEntireSettingsContent already fetched the file — reuse its
1365
- // content + existingSha. A second GET here would (a) be redundant and
1366
- // (b) on a transient non-404 failure fall back to existingSha=null,
1367
- // turning the PUT into a create that GitHub rejects 422 — masking the
1368
- // real read error. (Non-404 read failures already threw upstream.)
1369
- const { content, existingSha } = result;
1370
- const headers = ghHeaders(token);
1371
- const body = {
1372
- message: 'chore: configure Entire CLI settings',
1373
- content: Buffer.from(content).toString('base64'),
1374
- };
1375
- if (existingSha)
1376
- body.sha = existingSha;
1377
- const putResp = await fetch(`${GITHUB_PROXY}/repos/${owner}/${repo}/contents/.entire/settings.json`, {
1378
- method: 'PUT',
1379
- headers,
1380
- body: JSON.stringify(body),
1381
- });
1382
- if (!putResp.ok) {
1383
- const errText = await putResp.text().catch(() => '');
1384
- const error = new Error(`GitHub API ${putResp.status}: ${errText}`);
1385
- trackError('entire_settings_write_failed', {
1386
- error_message: error.message,
1387
- repo: repoFullName,
1388
- status: putResp.status,
1389
- had_existing: !!existingSha,
1390
- });
1391
- throw error;
1392
- }
1393
- }
1394
1214
  async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1395
1215
  console.log(chalk.bold('\n Step 7: Session tracking (Entire CLI)\n'));
1396
1216
  const status = isEntireInstalled();
@@ -1429,48 +1249,28 @@ async function stepInstallEntire(selectedRepos, token, bootstrapPRs) {
1429
1249
  if (!userConsented) {
1430
1250
  console.log(chalk.dim(' Skipping session tracking.\n'));
1431
1251
  }
1432
- // Configure .entire/settings.json on each repo via GitHub API
1433
- // (works even if binary install failed settings are remote; skip if user declined).
1434
- // Repos whose default branch was protected went the bootstrap-PR route in
1435
- // step 6, which ALREADY bundled .entire/settings.json into that PR's
1436
- // single commit (on a ~ALL ruleset it can't be added as a later commit).
1437
- // So we skip those here and only direct-write the non-protected repos.
1252
+ // Onboarding always opens a PR, and .entire/settings.json rides along in that
1253
+ // PR's single commit (see openBootstrapConfigPR), so there's nothing to write
1254
+ // separately here just report what landed.
1438
1255
  if (userConsented) {
1439
- let configured = 0;
1440
1256
  let bundled = 0;
1441
1257
  for (const repoFullName of selectedRepos) {
1442
1258
  const bootstrapPR = bootstrapPRs.get(repoFullName);
1443
- if (bootstrapPR) {
1444
- if (bootstrapPR.entireSettingsBundled) {
1445
- // .entire/settings.json made it into the bootstrap PR's commit.
1446
- console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(' (in bootstrap PR)'));
1447
- bundled++;
1448
- }
1449
- else {
1450
- // The bootstrap PR shipped the config files but not
1451
- // .entire/settings.json — its optional read failed transiently. Don't
1452
- // count it as configured; the warning was already shown when the PR
1453
- // was opened.
1454
- console.log(chalk.yellow(` ⚠ ${repoFullName}`) +
1455
- chalk.dim(' — bootstrap PR has config files only; re-run setup to add session tracking'));
1456
- }
1457
- continue;
1259
+ if (bootstrapPR?.entireSettingsBundled) {
1260
+ console.log(chalk.green(` ✓ ${repoFullName}`) + chalk.dim(' (in PR)'));
1261
+ bundled++;
1458
1262
  }
1459
- try {
1460
- ui.progress(`Configuring ${repoFullName}...`);
1461
- await configureEntireSettingsViaAPI(repoFullName, token);
1462
- ui.clearProgress();
1463
- console.log(chalk.green(` ${repoFullName}`));
1464
- configured++;
1465
- }
1466
- catch (err) {
1467
- ui.clearProgress();
1468
- console.log(chalk.yellow(` ⚠ ${repoFullName}: ${err instanceof Error ? err.message : err}`));
1263
+ else {
1264
+ // The PR shipped the config files but not .entire/settings.json — its
1265
+ // optional read failed transiently. The warning was already shown when
1266
+ // the PR was opened.
1267
+ console.log(chalk.yellow(` ${repoFullName}`) +
1268
+ chalk.dim(' — PR has config files only; re-run setup to add session tracking'));
1469
1269
  }
1470
1270
  }
1471
- if (configured > 0 || bundled > 0) {
1472
- console.log(chalk.green(`\n ✓ Session tracking configured on ${configured + bundled} repo(s) (transcripts auto-compacted)\n`));
1473
- trackSetupEvent('entire_configured', { configured: configured + bundled, total: selectedRepos.length });
1271
+ if (bundled > 0) {
1272
+ console.log(chalk.green(`\n ✓ Session tracking configured on ${bundled} repo(s) (transcripts auto-compacted)\n`));
1273
+ trackSetupEvent('entire_configured', { configured: bundled, total: selectedRepos.length });
1474
1274
  }
1475
1275
  else {
1476
1276
  console.log(chalk.yellow('\n ⚠ Could not configure session tracking on any repos.\n'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.15.5",
3
+ "version": "0.15.6",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {