@haystackeditor/cli 0.25.0 → 0.26.0

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.
Files changed (113) hide show
  1. package/README.md +39 -455
  2. package/dist/capture/adapters/django.js +7 -0
  3. package/dist/capture/adapters/index.js +10 -0
  4. package/dist/capture/adapters/next-build.js +113 -0
  5. package/dist/capture/adapters/next.js +272 -105
  6. package/dist/capture/adapters/nuxt.js +7 -0
  7. package/dist/capture/adapters/rails.js +7 -0
  8. package/dist/capture/adapters/react-router.js +7 -0
  9. package/dist/capture/adapters/sveltekit.js +7 -0
  10. package/dist/capture/adapters/vite.js +14 -0
  11. package/dist/capture/app-config.js +27 -2
  12. package/dist/capture/csp.js +7 -6
  13. package/dist/capture/url-rewrites.js +232 -0
  14. package/dist/capture-step.js +3 -0
  15. package/dist/commands/capture-brief.js +41 -32
  16. package/dist/commands/capture-manifest.js +25 -17
  17. package/dist/commands/feedback.js +66 -0
  18. package/dist/commands/init-capture.js +44 -27
  19. package/dist/commands/init-telemetry.js +77 -14
  20. package/dist/commands/init.js +19 -4
  21. package/dist/commands/lockfile-pin.js +307 -0
  22. package/dist/commands/verify-onboarding.js +16 -0
  23. package/dist/commands/verify.js +70 -15
  24. package/dist/index.js +29 -923
  25. package/dist/schema.js +4 -10
  26. package/dist/utils/haystack-api.js +0 -36
  27. package/package.json +1 -5
  28. package/schemas/feedback.v1.json +13 -0
  29. package/schemas/init.v1.json +1 -1
  30. package/schemas/pre-verify.v2.json +240 -0
  31. package/schemas/verify-raw.v1.json +1132 -0
  32. package/schemas/verify.v2.json +655 -0
  33. package/dist/assets/hooks/agent-context/detect.ts +0 -316
  34. package/dist/assets/hooks/agent-context/format.ts +0 -100
  35. package/dist/assets/hooks/agent-context/index.ts +0 -41
  36. package/dist/assets/hooks/agent-context/parsers/claude.ts +0 -262
  37. package/dist/assets/hooks/agent-context/parsers/codex.ts +0 -416
  38. package/dist/assets/hooks/agent-context/parsers/gemini.ts +0 -155
  39. package/dist/assets/hooks/agent-context/parsers/opencode.ts +0 -174
  40. package/dist/assets/hooks/agent-context/tsconfig.json +0 -14
  41. package/dist/assets/hooks/agent-context/types.ts +0 -58
  42. package/dist/assets/hooks/llm-rules-template.md +0 -59
  43. package/dist/assets/hooks/package-lock.json +0 -598
  44. package/dist/assets/hooks/package.json +0 -12
  45. package/dist/assets/hooks/scripts/commit-msg.sh +0 -5
  46. package/dist/assets/hooks/scripts/post-commit.sh +0 -5
  47. package/dist/assets/hooks/scripts/pre-commit.sh +0 -175
  48. package/dist/assets/hooks/scripts/pre-push.sh +0 -25
  49. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +0 -5
  50. package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +0 -528
  51. package/dist/assets/hooks/truncation-checker/index.ts +0 -595
  52. package/dist/assets/hooks/truncation-checker/tsconfig.json +0 -13
  53. package/dist/assets/skills/map-cloud-verifier-universe/SKILL.md +0 -2051
  54. package/dist/assets/skills/map-cloud-verifier-universe/agents/openai.yaml +0 -4
  55. package/dist/assets/skills/map-cloud-verifier-universe/references/output-contract.md +0 -3411
  56. package/dist/assets/skills/map-your-system.md +0 -143
  57. package/dist/assets/skills/submit.md +0 -200
  58. package/dist/commands/ask.js +0 -20
  59. package/dist/commands/cloud-verifier-behaviors.js +0 -218
  60. package/dist/commands/cloud-verifier-data-store-census.js +0 -539
  61. package/dist/commands/cloud-verifier-data-store-drift.js +0 -158
  62. package/dist/commands/cloud-verifier-identity-census.js +0 -4060
  63. package/dist/commands/cloud-verifier-materialization.js +0 -704
  64. package/dist/commands/cloud-verifier-pascal-selector-census.js +0 -1382
  65. package/dist/commands/cloud-verifier-python-manifest-selector-census.js +0 -2015
  66. package/dist/commands/cloud-verifier-specialized-operational-census.js +0 -11432
  67. package/dist/commands/cloud-verifier-universe.js +0 -10178
  68. package/dist/commands/config.js +0 -549
  69. package/dist/commands/design-verify.js +0 -311
  70. package/dist/commands/dismiss.js +0 -159
  71. package/dist/commands/hooks.js +0 -226
  72. package/dist/commands/inbox.js +0 -137
  73. package/dist/commands/mcp.js +0 -201
  74. package/dist/commands/policy.js +0 -371
  75. package/dist/commands/pr-status.js +0 -207
  76. package/dist/commands/pr.js +0 -105
  77. package/dist/commands/prepare-universe-review.js +0 -1092
  78. package/dist/commands/production-source-deny-policy.js +0 -100
  79. package/dist/commands/request-review.js +0 -74
  80. package/dist/commands/review.js +0 -191
  81. package/dist/commands/rules.js +0 -98
  82. package/dist/commands/scaffold-provisional-universe.js +0 -806
  83. package/dist/commands/setup.js +0 -1170
  84. package/dist/commands/skills.js +0 -447
  85. package/dist/commands/submit.js +0 -745
  86. package/dist/commands/system-map.js +0 -228
  87. package/dist/commands/triage.js +0 -598
  88. package/dist/commands/webhooks.js +0 -241
  89. package/dist/states.js +0 -46
  90. package/dist/tools/detect.js +0 -832
  91. package/dist/triage/astra.js +0 -202
  92. package/dist/triage/prompts.js +0 -188
  93. package/dist/triage/runner.js +0 -200
  94. package/dist/triage/types.js +0 -7
  95. package/dist/utils/action-output.js +0 -26
  96. package/dist/utils/analysis-api.js +0 -416
  97. package/dist/utils/design-verifier-api.js +0 -294
  98. package/dist/utils/design-verifier-history.js +0 -79
  99. package/dist/utils/design-verifier-result.js +0 -424
  100. package/dist/utils/github-api.js +0 -324
  101. package/dist/utils/pending-state.js +0 -86
  102. package/dist/utils/pr-ref.js +0 -56
  103. package/dist/utils/prompter.js +0 -328
  104. package/schemas/action.v1.json +0 -22
  105. package/schemas/ask.v1.json +0 -40
  106. package/schemas/inbox.v1.json +0 -27
  107. package/schemas/pr-status.v1.json +0 -61
  108. package/schemas/pr.v1.json +0 -97
  109. package/schemas/pr.v3.json +0 -45
  110. package/schemas/setup.v1.json +0 -75
  111. package/schemas/submit.v1.json +0 -90
  112. package/schemas/triage.v1.json +0 -103
  113. package/schemas/triage.v2.json +0 -64
@@ -31,8 +31,9 @@ import { findCsp } from '../capture/csp.js';
31
31
  import { applyEdits, keyName, loadBabel, parseJsonLike } from '../capture/js-ast.js';
32
32
  import { buildManifest, exactRoutes, manifestDocument, MANIFEST_DIR } from '../capture/manifest.js';
33
33
  import { appContext } from '../capture/project.js';
34
+ import { parseUrlRewrites, URL_REWRITES_QUESTION } from '../capture/url-rewrites.js';
34
35
  import { loadCaptureScript } from '../capture/script-release.js';
35
- import { htmlTags, STATIC_SUBDIR } from '../capture/tag.js';
36
+ import { STATIC_SUBDIR } from '../capture/tag.js';
36
37
  import { CAPTURE_ORIGIN, CAPTURE_VERSION } from './capture-contract.js';
37
38
  import { HaystackApiError } from '../utils/haystack-api.js';
38
39
  import { lineDiff } from './init.js';
@@ -50,9 +51,10 @@ const COMPOSABLE_SCRIPT = /^[A-Za-z0-9_./=:@\s&-]+$/;
50
51
  const STAGED_DIR = `.haystack/${STATIC_SUBDIR}`;
51
52
  /** The answers in effect: the flags, else the ones init recorded in .haystack/capture.json (rule 8b: the user's answers,
52
53
  * never inferred, so a rerun that only updates the script needs none). */
53
- export function captureAnswers(appDir, origins, consent) {
54
+ export function captureAnswers(appDir, origins, consent, urlRewrites) {
54
55
  const recorded = readCaptureConfig(appDir);
55
- return { origins: origins.length > 0 ? origins : recorded?.origins ?? [], consent: consent ?? recorded?.consent };
56
+ return { origins: origins.length > 0 ? origins : recorded?.origins ?? [], consent: consent ?? recorded?.consent,
57
+ urlRewrites: urlRewrites ?? recorded?.urlRewrites ?? undefined };
56
58
  }
57
59
  function readText(path) {
58
60
  try {
@@ -126,6 +128,9 @@ function scriptFiles(dir) {
126
128
  return [];
127
129
  }
128
130
  }
131
+ function formatUrlRewrites(rewrites) {
132
+ return rewrites.kind === 'none' ? 'none' : `hidden-segment:${rewrites.name}`;
133
+ }
129
134
  function manifestCommand(appPath) {
130
135
  return `haystack capture manifest --app ${appPath}`;
131
136
  }
@@ -144,6 +149,7 @@ export async function planCapture(input) {
144
149
  if (input.answers.consent === undefined)
145
150
  throw new Error('planCapture needs the consent answer.');
146
151
  const consent = parseConsentChoice(input.answers.consent);
152
+ const urlRewrites = input.answers.urlRewrites === undefined ? null : formatUrlRewrites(parseUrlRewrites(input.answers.urlRewrites));
147
153
  const plan = { changes: [], packageText: null, needsCli: false, registration: null, notices: [], part: part([]) };
148
154
  if (input.registration && 'failed' in input.registration) {
149
155
  plan.part = part([`Run \`haystack init\` again: registering the app for capture failed (${input.registration.failed}).`]);
@@ -182,18 +188,36 @@ export async function planCapture(input) {
182
188
  key: tags.key,
183
189
  repositoryId: identity?.repositoryId ?? ID_PLACEHOLDER,
184
190
  applicationId: identity?.applicationId ?? ID_PLACEHOLDER,
185
- adapter: detected && !detected.blocked ? detected.id : null,
191
+ // The adapter init recognized, whatever it left manual: the manifest command uses it once the cause is fixed.
192
+ adapter: detected ? detected.id : null,
186
193
  app: ctx.appPath,
187
194
  origins,
188
195
  consent: formatConsentChoice(consent),
189
196
  // Where a build step publishes (none for a static site, whose manifest init writes and the repository keeps).
190
- publish: detected && !detected.blocked && !isManual(detected.publishDirs) && !isManual(detected.build)
191
- && detected.build.kind !== 'init-writes' ? detected.publishDirs : [],
197
+ publish: detected && !isManual(detected.publishDirs) && !isManual(detected.build) && detected.build.kind !== 'init-writes'
198
+ ? detected.publishDirs : [],
199
+ urlRewrites,
192
200
  };
193
201
  planner.text(captureConfigFile(appDir), formatCaptureConfig(config), 'The app\'s capture identity, committed for the build step that publishes its route manifest (CAPTURE-V1 rule 8b).');
194
202
  const consentStep = manualConsentStep(consent);
195
- const automatic = detected !== null && detected.blocked === null;
196
- const staticDir = automatic && typeof detected.staticDir === 'string' ? detected.staticDir : null;
203
+ if (!detected) {
204
+ plan.part = { status: 'unsupported', why: `init recognized no web framework at ${ctx.appPath} (it looked for package.json, Gemfile,`
205
+ + ' manage.py and index.html there), and the tag records nothing without the route manifest only a recognized framework\'s routes'
206
+ + ' give; run `haystack init --app <dir>` with the app\'s directory' };
207
+ plan.changes = planner.changes;
208
+ return plan;
209
+ }
210
+ // A manifest that cannot be made exactly (a base path, a config init cannot read) leaves nothing worth placing: a tag
211
+ // without a manifest records nothing. Two steps that work: remove the cause, run init again (the recorded answers carry
212
+ // over, and the manifest command checks the cause on every build).
213
+ if (detected.manifestBlocked) {
214
+ steps.push(`Remove what stops ${detected.label}'s route manifest: ${detected.manifestBlocked} (init looked at ${detected.looked.join(', ')}).`);
215
+ steps.push('Then run `haystack init` again: it places the tag, the files and the build step that publishes the manifest itself.');
216
+ plan.changes = planner.changes;
217
+ plan.part = part(steps);
218
+ return plan;
219
+ }
220
+ const staticDir = typeof detected.staticDir === 'string' ? detected.staticDir : null;
197
221
  // The files: in the app's static output, or staged for a manual step.
198
222
  const filesDir = staticDir === null ? join(appDir, STAGED_DIR) : join(appDir, staticDir, STATIC_SUBDIR);
199
223
  const wanted = new Set([script.file, ...(consentFile ? [consentFile.file] : [])]);
@@ -205,24 +229,8 @@ export async function planCapture(input) {
205
229
  if (!wanted.has(old))
206
230
  planner.remove(join(filesDir, old), 'An earlier capture file, replaced by this version.');
207
231
  const servedAt = `/${STATIC_SUBDIR}/`;
208
- if (!automatic) {
209
- const why = detected ? `${detected.label}: ${detected.blocked}` : `init recognized no framework at ${ctx.appPath}`;
210
- const looked = detected?.looked.join(', ') ?? `${ctx.appPath} (package.json, Gemfile, manage.py, index.html)`;
211
- steps.push(`Add the tag yourself (${why}; init looked at ${looked}):\n${(detected ? detected.manualTag(tags) : ['Inside <head> of every page:', ...htmlTags(tags)]).map(line => ` ${line}`).join('\n')}`);
212
- steps.push(`Serve the files init staged in ${posix.join(ctx.appPath, STAGED_DIR)}/ at ${servedAt} on each production origin.`);
213
- steps.push(`Publish the route manifest (CAPTURE-V1 rule 4) after each build at /${MANIFEST_DIR}/current.json and`
214
- + ` /${MANIFEST_DIR}/<release>.json on each production origin; until one is published the tag records nothing.`
215
- + ` \`${manifestCommand(ctx.appPath)}\` generates it only for an app init sets up itself: once the reason above is gone,`
216
- + ' run `haystack init` again and it adds that build step.');
217
- steps.push(`If production sends a Content-Security-Policy, it needs: ${cspFallbackLine()}.`);
218
- if (consentStep)
219
- steps.push(consentStep);
220
- plan.changes = planner.changes;
221
- plan.part = part(steps);
222
- return plan;
223
- }
224
232
  // The tag.
225
- const integration = await detected.integrate(tags);
233
+ const integration = detected.blocked ? { manual: detected.blocked } : await detected.integrate(tags);
226
234
  if (isManual(integration)) {
227
235
  steps.push(`Add the tag yourself (${integration.manual}; init looked at ${detected.looked.join(', ')}):\n${detected.manualTag(tags).map(line => ` ${line}`).join('\n')}`);
228
236
  }
@@ -234,8 +242,17 @@ export async function planCapture(input) {
234
242
  steps.push(`Serve ${posix.join(ctx.appPath, STAGED_DIR)} at ${servedAt}: ${detected.staticDir.manual}.`);
235
243
  // The build step and where the manifest goes.
236
244
  await planBuild(plan, planner, steps, ctx, detected, config);
237
- // Routes now, so a router init cannot read is a step today rather than a manifest missing after the next build.
238
- if (!isManual(detected.build) && detected.build.kind !== 'rake') {
245
+ if (detected.publishNote)
246
+ plan.notices.push(`Capture: ${detected.publishNote}`);
247
+ // A middleware or proxy that may rewrite page URLs: the routes are exact only with the user's answer.
248
+ if (detected.rewriting && urlRewrites === null)
249
+ steps.push(`Answer one question about URL rewrites: ${detected.rewriting}. ${URL_REWRITES_QUESTION}`);
250
+ // Routes now, so a router init cannot read is a step today rather than a manifest missing after the next build; an
251
+ // adapter whose routes come from the build reads them there.
252
+ if (detected.fromBuild) {
253
+ plan.notices.push(`Capture: the routes come from each production build (${detected.label}), read after it by the build step.`);
254
+ }
255
+ else if (!isManual(detected.build) && detected.build.kind !== 'rake') {
239
256
  const routes = exactRoutes(await detected.routes({}));
240
257
  if (isManual(routes))
241
258
  steps.push(`Make the routes readable or publish the manifest yourself: ${routes.manual}.`);
@@ -20,8 +20,8 @@
20
20
  * - the report (8e). InitTelemetryReport, plus the person's steps beside it: the ingest token (init never mints, prints,
21
21
  * stores or reads one), production's environment, the start command, the deploy and the off switches (13c).
22
22
  */
23
- import { spawnSync } from 'node:child_process';
24
- import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
23
+ import { execFileSync, spawnSync } from 'node:child_process';
24
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
25
25
  import { createRequire } from 'node:module';
26
26
  import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
@@ -31,8 +31,10 @@ import { gatewayFetch } from './case-batch.js';
31
31
  import { CAPTURE_EVIDENCE_FRESH_HOURS, CAPTURE_STATUS_PATH, } from './capture-contract.js';
32
32
  import { captureAnswers, planCapture } from './init-capture.js';
33
33
  import { discoverApps } from '../capture/adapters/index.js';
34
+ import { CAPTURE_CONFIG_PATH, recordedApps } from '../capture/app-config.js';
34
35
  import { loadBabel } from '../capture/js-ast.js';
35
36
  import { lineDiff } from './init.js';
37
+ import { lockfileDrift, mergePnpmLockfile } from './lockfile-pin.js';
36
38
  import { readTelemetryServerStatus } from './telemetry-token.js';
37
39
  import { readFacts } from './verify-onboarding.js';
38
40
  /** Lane D1: with the user's yes, init checks browser capture's registration with Haystack around the writes. */
@@ -214,15 +216,20 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
214
216
  const spec = `${CLI_PACKAGE}@${version}`;
215
217
  const dev = field === 'devDependencies';
216
218
  const workspaceRoot = isFile(join(appDir, 'pnpm-workspace.yaml')) ? ['--workspace-root'] : [];
219
+ // Each package manager's add, as it changes no lockfile entry but the CLI's own (lockfile-pin.ts): npm with its tree
220
+ // nested under the CLI (hoisting would replace packages the app already locks), pnpm through the merge.
217
221
  const argv = manager === null ? null : {
218
222
  pnpm: ['pnpm', 'add', '--save-exact', dev ? '--save-dev' : '--save-prod', ...workspaceRoot, spec],
219
- npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', spec],
223
+ npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', '--install-strategy=nested', spec],
220
224
  yarn: ['yarn', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
221
225
  bun: ['bun', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
222
226
  }[manager.name];
223
227
  const action = promoting ? `Moved from devDependencies to dependencies at ${version}` : `Pinned to ${version}`;
228
+ const lockfileNote = manager?.name === 'pnpm'
229
+ ? `, keeping every other entry of pnpm's lockfile as it was (pnpm's own add re-resolves packages the CLI shares with the app; init adds only the CLI's entries, then \`pnpm install --frozen-lockfile\`)`
230
+ : `, which adds only the CLI's entries to ${manager?.name}'s lockfile`;
224
231
  const reason = argv
225
- ? `${why} ${action} with \`${argv.join(' ')}\` in ${inside(gitRoot, appDir)}, which updates ${manager?.name}'s lockfile too.`
232
+ ? `${why} ${action} with \`${argv.join(' ')}\` in ${inside(gitRoot, appDir)}${lockfileNote}.`
226
233
  : `${why} ${action}: a newer release never reaches your build unless you change it.`;
227
234
  // The package manager's own word is not taken for it: the manifest must say what the integration needs.
228
235
  const check = () => {
@@ -235,7 +242,39 @@ function planDependency(gitRoot, appDir, pkg, after, need) {
235
242
  return null;
236
243
  };
237
244
  const touches = manager === null ? [] : LOCKFILES.map(([lockfile]) => join(manager.root, lockfile));
238
- return { kind: 'change', change: change(gitRoot, pkg.path, reason, after, content, argv ? { argv, cwd: appDir, touches, check } : undefined) };
245
+ const exec = manager === null || argv === null ? undefined : () => pin(manager, appDir, argv);
246
+ return { kind: 'change', change: change(gitRoot, pkg.path, reason, after, content, argv ? { argv, cwd: appDir, touches, check, exec } : undefined) };
247
+ }
248
+ /** The lockfile a package manager writes, in a format lockfile-pin.ts reads (bun's binary lockb aside). */
249
+ function textLockfile(manager) {
250
+ const name = { pnpm: 'pnpm-lock.yaml', npm: 'package-lock.json', yarn: 'yarn.lock', bun: 'bun.lock' }[manager.name];
251
+ const path = join(manager.root, name);
252
+ return isFile(path) ? { path, format: manager.name } : null;
253
+ }
254
+ /** Runs the add, so that the lockfile gains the CLI's entries and nothing else moves (rule 13); throws (init then restores
255
+ * every file) when the package manager moved anything else. */
256
+ function pin(manager, appDir, argv) {
257
+ const run = (command) => { execFileSync(command[0], command.slice(1), { cwd: appDir, stdio: ['ignore', 2, 2] }); };
258
+ const lockfile = textLockfile(manager);
259
+ const before = lockfile ? readFileSync(lockfile.path, 'utf8') : null;
260
+ const importer = posix.normalize(relative(manager.root, appDir).split(sep).join('/')) || '.';
261
+ if (manager.name === 'pnpm' && lockfile && before !== null) {
262
+ // pnpm resolves the CLI, init keeps the rest as it was, and the install takes exactly that lockfile (proving it).
263
+ run([...argv, '--lockfile-only']);
264
+ writeFileSync(lockfile.path, mergePnpmLockfile(before, readFileSync(lockfile.path, 'utf8'), importer, CLI_PACKAGE));
265
+ run(['pnpm', 'install', '--frozen-lockfile']);
266
+ }
267
+ else {
268
+ run(argv);
269
+ }
270
+ if (!lockfile || before === null)
271
+ return;
272
+ const moved = lockfileDrift(lockfile.format, before, readFileSync(lockfile.path, 'utf8'), importer, CLI_PACKAGE);
273
+ if (moved.length > 0) {
274
+ throw new Error(`\`${argv.join(' ')}\` also changed ${moved.length} lockfile ${moved.length === 1 ? 'entry' : 'entries'} the CLI does not own`
275
+ + ` (${moved.slice(0, 5).join(', ')}${moved.length > 5 ? ', …' : ''}), so init put every file back. Add ${CLI_PACKAGE} at its exact version`
276
+ + ` to ${inside(manager.root, appDir) || '.'} yourself, keeping the lockfile's other entries, then run \`haystack init\` again`);
277
+ }
239
278
  }
240
279
  function isNode(value) {
241
280
  return !!value && typeof value === 'object' && typeof value.type === 'string' && typeof value.start === 'number';
@@ -819,13 +858,18 @@ export async function planTelemetry(gitRoot, repository, options) {
819
858
  // One rule for which directories are apps (capture/adapters/index.ts): a web framework an adapter claims, or a package
820
859
  // with a start script.
821
860
  await loadBabel();
822
- const apps = await discoverApps(gitRoot);
861
+ // The user's earlier answer first: the one app a .haystack/capture.json records (with its origins and consent, which
862
+ // captureAnswers reads from it). Several records are as ambiguous as several apps.
863
+ const discovered = await discoverApps(gitRoot);
864
+ const recorded = recordedApps(gitRoot, discovered);
865
+ const apps = recorded.length > 0 ? recorded : discovered;
823
866
  if (apps.length !== 1) {
824
867
  // Rule 8(a): init never picks among several apps, and never guesses one where it found none.
825
868
  const ask = apps.length === 0
826
869
  ? 'init found no app here (no web framework it recognizes and no package with a start script). If the repository has one that serves production, run '
827
870
  + '`haystack init` again with `--app <its directory>`.'
828
- : `Ask your user which of these apps serves production: ${apps.join(', ')}; then run \`haystack init\` again with \`--app <that directory>\`.`;
871
+ : `Ask your user which of these apps serves production: ${apps.join(', ')}${recorded.length > 0 ? ` (each has a ${CAPTURE_CONFIG_PATH})` : ''};`
872
+ + ' then run `haystack init` again with `--app <that directory>`.';
829
873
  const questions = [ask, ...captureQuestions(options.origins, options.consent)];
830
874
  // A notice too, so a preview (which reports no telemetry yet) already says what to ask.
831
875
  notices.push(`Telemetry waits for your user's answers: ${questions.join(' ')}`);
@@ -834,6 +878,8 @@ export async function planTelemetry(gitRoot, repository, options) {
834
878
  captureRegistration: null };
835
879
  }
836
880
  appDir = realpathSync(join(gitRoot, apps[0]));
881
+ if (recorded.length > 0)
882
+ notices.push(`Telemetry: the app is ${apps[0]}, as its ${CAPTURE_CONFIG_PATH} records (\`--app\` picks another).`);
837
883
  }
838
884
  const app = inside(gitRoot, appDir);
839
885
  const changes = [];
@@ -841,7 +887,7 @@ export async function planTelemetry(gitRoot, repository, options) {
841
887
  const manifest = join(appDir, 'package.json');
842
888
  // CONNECT(lane D1): browser capture (init-capture.ts) plans with the answers in effect (flags, else the ones init recorded
843
889
  // for this app); a missing answer is a question, never a guess.
844
- const answers = captureAnswers(appDir, options.origins, options.consent);
890
+ const answers = captureAnswers(appDir, options.origins, options.consent, options.urlRewrites);
845
891
  const questions = captureQuestions(answers.origins, answers.consent);
846
892
  const capture = questions.length > 0 ? null
847
893
  : await planCapture({ gitRoot, appDir, repository, answers, registration: options.registration });
@@ -940,19 +986,36 @@ function dbProfile(repository, onboarding, notes) {
940
986
  + '`haystack db profile upload --help` says how a profile names the one it describes.'] : []),
941
987
  ] };
942
988
  }
989
+ function serverPartOf(plan) {
990
+ return plan.server.status === 'unsupported' ? { status: 'unsupported', why: plan.server.why }
991
+ : plan.server.status === 'manual' ? { status: 'manual', steps: plan.server.steps, received: null } : { status: 'installed', received: null };
992
+ }
993
+ const WAITING_STEP = 'Telemetry waits for answers only your user can give: ask what the `steps` of each `manual` part say, then run `haystack init` '
994
+ + 'again with them.';
995
+ /** Rule 8(e) before anything is changed (a preview without --yes, or no login yet): the report the apply gives, with
996
+ * `received` null (checking it needs Haystack), so the coding agent can show its user what telemetry will do; with the steps that need
997
+ * nothing from Haystack (the token and environment steps follow with --yes). */
998
+ export function previewTelemetry(plan, repository, notes) {
999
+ const steps = [];
1000
+ if (plan.app === null || plan.capture.status === 'manual')
1001
+ steps.push(WAITING_STEP);
1002
+ if (plan.app !== null && plan.server.status !== 'unsupported') {
1003
+ steps.push(`With --yes, init then says how production gets its telemetry token (\`haystack telemetry token --repo ${repository}\`, run by your`
1004
+ + ' user in their own terminal) and which variables production\'s server environment needs.');
1005
+ }
1006
+ const profile = dbProfile(repository, null, notes);
1007
+ return { telemetry: { capture: plan.capture, server: serverPartOf(plan), dbProfileCommand: profile?.command ?? null }, steps };
1008
+ }
943
1009
  /** Rule 8(e): the report, and what the person does for telemetry to arrive (the token, production's environment, the start,
944
1010
  * the deploy) with the off switches (rule 13c). `onboarding` and `notes` feed the data profile. */
945
1011
  export async function telemetryReport(plan, context) {
946
1012
  const { repository, token } = context;
947
1013
  const steps = [];
948
- const serverPart = plan.server.status === 'unsupported' ? { status: 'unsupported', why: plan.server.why }
949
- : plan.server.status === 'manual' ? { status: 'manual', steps: plan.server.steps, received: null } : { status: 'installed', received: null };
1014
+ const serverPart = serverPartOf(plan);
950
1015
  let server = serverPart;
951
1016
  // Answers only the user can give come first: until then the rest of telemetry waits (rule 8: init never guesses).
952
- if (plan.app === null || plan.capture.status === 'manual') {
953
- steps.push('Telemetry waits for answers only your user can give: ask what the `steps` of each `manual` part say, then run `haystack init` '
954
- + 'again with them.');
955
- }
1017
+ if (plan.app === null || plan.capture.status === 'manual')
1018
+ steps.push(WAITING_STEP);
956
1019
  if (plan.app !== null && plan.server.status !== 'unsupported') {
957
1020
  const status = await readTelemetryServerStatus(repository, token);
958
1021
  server = withReceived(serverPart, status.newestFlush === null ? null : received(status.newestFlush.storedAt, status.newestFlush.buildId));
@@ -54,9 +54,11 @@ export const AGENT_NOTE = [
54
54
  '- Fix each bug it reports that your change caused, then run `haystack verify` again.',
55
55
  '- If it says onboarding needs an answer, ask the user that question and file their choice with',
56
56
  ' `haystack verify answer <question-id> <choice>`.',
57
- '- Read its short report. `--json` prints the full result, often hundreds of kilobytes, for scripts;',
57
+ '- Read its short report. `--json` prints the same report as data; `--raw` prints the crawl\'s whole record (large), for scripts;',
58
58
  ' `haystack verify --help` explains the output and exit codes.',
59
59
  '- If `haystack` is not installed, run each command as `npx -y @haystackeditor/cli@latest <command>`.',
60
+ '- If Haystack fails, confuses you or gets in your way, say so in a sentence with',
61
+ ' `haystack feedback "<what happened>"` and keep going: the Haystack team sees it at once.',
60
62
  NOTE_END,
61
63
  ].join('\n');
62
64
  const EXIT_CODES = { planned: 2, 'login-required': 4, 'app-required': 5 };
@@ -310,7 +312,10 @@ function applyChange(gitRoot, planned) {
310
312
  if (planned.run) {
311
313
  const before = existsSync(planned.target) ? readFileSync(planned.target, 'utf8') : '';
312
314
  // Its output goes to stderr: in --json mode stdout carries only the one document.
313
- execFileSync(planned.run.argv[0], planned.run.argv.slice(1), { cwd: planned.run.cwd, stdio: ['ignore', 2, 2] });
315
+ if (planned.run.exec)
316
+ planned.run.exec();
317
+ else
318
+ execFileSync(planned.run.argv[0], planned.run.argv.slice(1), { cwd: planned.run.cwd, stdio: ['ignore', 2, 2] });
314
319
  const failed = planned.run.check?.() ?? null;
315
320
  if (failed !== null)
316
321
  throw new Error(`\`${planned.run.argv.join(' ')}\` ran, but ${failed}.`);
@@ -359,18 +364,26 @@ export async function initCommand(options) {
359
364
  const plan = planInit(gitRoot);
360
365
  // Telemetry is part of onboarding (CAPTURE-V1 rule 8): planned on every run; a missing answer stops only it.
361
366
  const telemetryModule = await import('./init-telemetry.js');
362
- const telemetryOptions = { app: options.app, origins: options.origin ?? [], consent: options.consent };
367
+ const telemetryOptions = { app: options.app, origins: options.origin ?? [], consent: options.consent, urlRewrites: options.urlRewrites };
363
368
  let telemetryPlan = await telemetryModule.planTelemetry(gitRoot, repository, telemetryOptions);
364
369
  const telemetryFrom = plan.changes.length;
365
370
  plan.changes.push(...telemetryPlan.changes);
366
371
  plan.notices.push(...telemetryPlan.notices);
367
372
  let notesWritten = null;
368
373
  let telemetry = null;
374
+ // Before the apply (a preview, or no login), the report is the preview: what telemetry will do, `received` unchecked (null).
375
+ const report = () => telemetry ?? telemetryModule.previewTelemetry(telemetryPlan, repository, notes);
376
+ const showPreview = () => {
377
+ const preview = telemetryModule.previewTelemetry(telemetryPlan, repository, notes);
378
+ for (const line of telemetryModule.formatTelemetry(preview.telemetry, preview.steps))
379
+ say(line);
380
+ say('');
381
+ };
369
382
  const finish = (status, onboarding, exitCode) => {
370
383
  if (options.json) {
371
384
  const changes = plan.changes.map(({ path, action, reason, diff }) => ({ path, action, reason, diff }));
372
385
  process.stdout.write(`${JSON.stringify(withSchema('init', { status, repository, changes, notices: plan.notices, notes: notesWritten,
373
- onboarding, telemetry: telemetry?.telemetry ?? null, telemetrySteps: telemetry?.steps ?? [] }), null, 2)}\n`);
386
+ onboarding, telemetry: report().telemetry, telemetrySteps: report().steps }), null, 2)}\n`);
374
387
  }
375
388
  process.exitCode = exitCode;
376
389
  };
@@ -397,6 +410,7 @@ export async function initCommand(options) {
397
410
  catch (error) {
398
411
  if (!(error instanceof NotLoggedInError))
399
412
  throw error;
413
+ showPreview();
400
414
  say(chalk.yellow('Not logged in, so nothing was changed. Run `haystack login`, then `haystack init` again.'));
401
415
  finish('login-required', null, EXIT_CODES['login-required']);
402
416
  return;
@@ -404,6 +418,7 @@ export async function initCommand(options) {
404
418
  if (plan.changes.length > 0 && !options.yes) {
405
419
  const interactive = !options.json && process.stdin.isTTY === true && process.stdout.isTTY === true;
406
420
  if (!interactive || !await confirmed()) {
421
+ showPreview();
407
422
  say('Nothing was changed. Run `haystack init --yes` to make these changes.');
408
423
  finish('planned', null, EXIT_CODES.planned);
409
424
  return;