@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
@@ -71,6 +71,13 @@ export async function detectSvelteKit(ctx) {
71
71
  label: `SvelteKit ${version.version}`,
72
72
  manifestAdapter: `sveltekit@${version.version}`,
73
73
  blocked: blocked.length > 0 ? blocked.join('; ') : null,
74
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
75
+ manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
76
+ fromBuild: false,
77
+ rewriting: null,
78
+ publishNote: null,
79
+ buildPublishDirs: () => [],
80
+ buildNotes: () => [],
74
81
  looked: [templatePath, where, repoPath(ctx, join(ctx.appDir, routesDir))],
75
82
  staticDir: assets,
76
83
  async integrate(tags) {
@@ -79,6 +79,13 @@ export async function detectVite(ctx) {
79
79
  label: `Vite ${version.version}${router ? ` with ${router}` : ''}`,
80
80
  manifestAdapter: `vite@${version.version}`,
81
81
  blocked: blocked.length > 0 ? blocked.join('; ') : null,
82
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
83
+ manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
84
+ fromBuild: false,
85
+ rewriting: null,
86
+ publishNote: null,
87
+ buildPublishDirs: () => [],
88
+ buildNotes: () => [],
82
89
  looked: [indexPath, where, repoPath(ctx, srcDir)],
83
90
  staticDir: toPosix(posix.join(toPosix(root), toPosix(publicDir))),
84
91
  integrate: async (tags) => htmlEdit(index, tags, indexPath),
@@ -109,6 +116,13 @@ export async function detectStaticHtml(ctx) {
109
116
  label: 'a static HTML site',
110
117
  manifestAdapter: 'static-html',
111
118
  blocked: pages.length === 1 ? null : `the site has ${pages.length} HTML pages (${pages.slice(0, 5).join(', ')}${pages.length > 5 ? ', …' : ''}), each needing the tag`,
119
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
120
+ manifestBlocked: pages.length === 1 ? null : `the site has ${pages.length} HTML pages (${pages.slice(0, 5).join(', ')}${pages.length > 5 ? ', …' : ''}), each needing the tag`,
121
+ fromBuild: false,
122
+ rewriting: null,
123
+ publishNote: null,
124
+ buildPublishDirs: () => [],
125
+ buildNotes: () => [],
112
126
  looked: [indexPath],
113
127
  staticDir: '.',
114
128
  integrate: async (tags) => htmlEdit(index, tags, indexPath),
@@ -12,8 +12,9 @@
12
12
  * publish the directories (relative to the app) the build step publishes the manifest into, so haystack-capture-step,
13
13
  * which loads nothing of the CLI, can withdraw a stale current.json there when the manifest was not made.
14
14
  */
15
+ import { spawnSync } from 'node:child_process';
15
16
  import { existsSync, readFileSync } from 'node:fs';
16
- import { join } from 'node:path';
17
+ import { join, posix } from 'node:path';
17
18
  import { CAPTURE_KEY_PATTERN, CAPTURE_STATUS_PATH, CAPTURE_VERSION } from '../commands/capture-contract.js';
18
19
  import { haystackFetch, haystackJson } from '../utils/haystack-api.js';
19
20
  export const CAPTURE_CONFIG_PATH = '.haystack/capture.json';
@@ -36,11 +37,35 @@ export function readCaptureConfig(appDir) {
36
37
  if (parsed.version !== CAPTURE_VERSION || strings.some(name => typeof parsed[name] !== 'string')
37
38
  || !CAPTURE_KEY_PATTERN.test(parsed.key) || !(parsed.adapter === null || typeof parsed.adapter === 'string')
38
39
  || !Array.isArray(parsed.origins) || !parsed.origins.every(origin => typeof origin === 'string')
39
- || !Array.isArray(parsed.publish) || !parsed.publish.every(dir => typeof dir === 'string')) {
40
+ || !Array.isArray(parsed.publish) || !parsed.publish.every(dir => typeof dir === 'string')
41
+ || !(parsed.urlRewrites === undefined || parsed.urlRewrites === null || typeof parsed.urlRewrites === 'string')) {
40
42
  throw new Error(`${CAPTURE_CONFIG_PATH} is not a capture configuration init wrote; remove it and run \`haystack init\` again.`);
41
43
  }
42
44
  return parsed;
43
45
  }
46
+ /** The directories (repository-relative, posix; '.' for the root) holding a .haystack/capture.json: those git lists
47
+ * (committed, or not yet), and the candidates that have one even when ignored. */
48
+ export function captureConfigDirs(gitRoot, candidates) {
49
+ const listed = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', CAPTURE_CONFIG_PATH,
50
+ `:(glob)**/${CAPTURE_CONFIG_PATH}`], { cwd: gitRoot, encoding: 'utf8' });
51
+ if (listed.status !== 0)
52
+ throw new Error(`git ls-files failed in ${gitRoot}: ${listed.stderr.trim()}`);
53
+ return [...new Set([...listed.stdout.split('\0').filter(path => path !== '' && !path.split('/').includes('node_modules'))
54
+ .map(path => posix.dirname(posix.dirname(path))), ...candidates])].filter(dir => existsSync(captureConfigFile(join(gitRoot, dir)))).sort();
55
+ }
56
+ /** The apps (repository-relative, posix; '.' for the root) whose .haystack/capture.json records them: the user's earlier
57
+ * answer to which app serves production. Records git lists (committed, or not yet), and those in the candidate app
58
+ * directories even when ignored. */
59
+ export function recordedApps(gitRoot, candidates) {
60
+ return captureConfigDirs(gitRoot, candidates).filter(dir => {
61
+ try {
62
+ return readCaptureConfig(join(gitRoot, dir))?.app === dir;
63
+ }
64
+ catch (error) {
65
+ throw new Error(`${posix.join(dir, CAPTURE_CONFIG_PATH)}: ${error instanceof Error ? error.message : String(error)}`);
66
+ }
67
+ });
68
+ }
44
69
  export function formatCaptureConfig(config) {
45
70
  return `${JSON.stringify(config, null, 2)}\n`;
46
71
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import fg from 'fast-glob';
13
13
  import { readFileSync, statSync } from 'node:fs';
14
- import { extname, join } from 'node:path';
14
+ import { extname, join, relative } from 'node:path';
15
15
  import { SAXParser } from 'parse5-sax-parser';
16
16
  import { CAPTURE_ORIGIN } from '../commands/capture-contract.js';
17
17
  import { keyName, parseFile, parseJsonLike, unwrapTs, walk } from './js-ast.js';
@@ -159,7 +159,7 @@ function jsPolicies(text, file, json) {
159
159
  ? (value.properties.find((property) => property.type === 'ObjectProperty' && keyName(property) === 'directives')?.value ?? value) : value;
160
160
  const policy = resolvePolicy(directives, ast);
161
161
  // `csp` is a common key for other things; it counts only when it spells a policy.
162
- if (name !== 'csp' || policy !== null)
162
+ if (name !== 'csp' || (policy !== null && DIRECTIVE.test(policy)))
163
163
  found.push({ offset: node.start, policy, reportOnly: name.endsWith('report-only') });
164
164
  seen.add(node.value);
165
165
  }
@@ -203,8 +203,9 @@ function resolvePolicy(node, ast, depth = 0) {
203
203
  && ['replace', 'replaceAll', 'trim'].includes(current.callee.property.name)) {
204
204
  return resolvePolicy(current.callee.object, ast, depth + 1);
205
205
  }
206
- const policy = policyFromNode(current);
207
- return policy !== null && DIRECTIVE.test(policy) ? policy : null;
206
+ // A header's value is its policy whatever it says (`frame-ancestors 'none'` restricts nothing the tag does); a computed
207
+ // value init cannot read stays null.
208
+ return policyFromNode(current);
208
209
  }
209
210
  /** helmet()'s default policy (helmet 5–8), which applies when its options leave contentSecurityPolicy alone. */
210
211
  const HELMET_DEFAULT = "default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self';"
@@ -298,7 +299,7 @@ export function findCsp(repoRoot, appDir, nextLoader, origins) {
298
299
  if (!MARKER.test(text))
299
300
  continue;
300
301
  const extension = extname(file);
301
- const where = toPosix(file.slice(repoRoot.length + 1));
302
+ const where = toPosix(relative(repoRoot, file));
302
303
  let policies;
303
304
  if (['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx'].includes(extension))
304
305
  policies = jsPolicies(text, file, false);
@@ -327,5 +328,5 @@ export function findCsp(repoRoot, appDir, nextLoader, origins) {
327
328
  });
328
329
  }
329
330
  }
330
- return { findings, looked: `${toPosix(appDir.slice(repoRoot.length + 1)) || '.'} (headers config, middleware, meta tags, framework CSP options, server configs)` };
331
+ return { findings, looked: `${toPosix(relative(repoRoot, appDir)) || '.'} (headers config, middleware, meta tags, framework CSP options, server configs)` };
331
332
  }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * URL rewrites (CAPTURE-V1 rule 4). The tag matches a page's URL against the manifest's routes, so a route's rule must say
3
+ * which URLs show it. A middleware or proxy that rewrites (Rallly's proxy turns every /x into /<locale>/x) or a config
4
+ * rewrite into a page makes the URL and the route's path differ in ways only the app's code says. Init reads that code
5
+ * only where it can read it exactly (a middleware that rewrites nothing and calls nothing it cannot see into, or
6
+ * next-intl's middleware with a literal routing config); for anything else it never guesses: it asks the coding agent's
7
+ * user (who can read that code) for one answer, recorded in .haystack/capture.json:
8
+ *
9
+ * none rewrites never make a page's URL show another page
10
+ * hidden-segment:<name> the middleware or proxy adds the leading dynamic segment [<name>] to every page path, so
11
+ * URLs never contain it (the manifest drops that segment from the routes under it)
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { keyName, literalValue, parseFile, pathOf, readPathAliases, resolveImport, unwrapTs, UNREADABLE, walk } from './js-ast.js';
16
+ import { isFile } from './adapters/files.js';
17
+ import { repoPath, resolveVersion } from './project.js';
18
+ export function parseUrlRewrites(raw) {
19
+ const value = raw.trim();
20
+ if (value === 'none')
21
+ return { kind: 'none' };
22
+ const hidden = /^hidden-segment:([A-Za-z0-9_-]+)$/.exec(value);
23
+ if (hidden)
24
+ return { kind: 'hidden-segment', name: hidden[1] };
25
+ throw new Error(`--url-rewrites ${raw} is not an answer: use none, or hidden-segment:<name> for a leading [<name>] segment the app's middleware or proxy adds to every page path.`);
26
+ }
27
+ export const URL_REWRITES_QUESTION = 'Does it make a page\'s URL show another page? Ask your user (or read that code), then run `haystack init` again with'
28
+ + ' `--url-rewrites none` when its rewrites never do, or `--url-rewrites hidden-segment:<name>` when it adds a leading [<name>] segment to every'
29
+ + ' page path (so URLs never contain it).';
30
+ const MIDDLEWARE_FILES = ['middleware', 'proxy'].flatMap(name => ['ts', 'js', 'mjs', 'mts', 'cts', 'cjs', 'tsx', 'jsx'].map(extension => `${name}.${extension}`));
31
+ const INTL_MIDDLEWARE = 'next-intl/middleware';
32
+ const INTL_ROUTING = 'next-intl/routing';
33
+ /** next/server and the rest of Next's own modules: what they return init knows (NextResponse.rewrite is caught by name). */
34
+ function nextModule(source) {
35
+ return source === 'next' || source.startsWith('next/');
36
+ }
37
+ /** The identifier a call's callee starts from (`a.b.c()` → a), or null (a call on a call's result, `(…)()`). */
38
+ function calleeRoot(callee) {
39
+ let current = unwrapTs(callee);
40
+ while (current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression')
41
+ current = unwrapTs(current.object);
42
+ return current.type === 'Identifier' ? current : null;
43
+ }
44
+ /** The value a routing config names: an object literal, a top-level const bound to one or to defineRouting({...}), or
45
+ * the same exported from a local module; null when it is not literally there. */
46
+ function routingObject(node, ast, file, ctx, depth = 0) {
47
+ const current = unwrapTs(node);
48
+ if (depth > 4)
49
+ return null;
50
+ if (current.type === 'ObjectExpression')
51
+ return { value: current, ast };
52
+ if (current.type === 'CallExpression' && current.arguments.length === 1) {
53
+ const root = calleeRoot(current.callee);
54
+ const source = root ? importOf(ast, root.name) : null;
55
+ if (source?.module === INTL_ROUTING && source.imported === 'defineRouting')
56
+ return routingObject(current.arguments[0], ast, file, ctx, depth + 1);
57
+ return null;
58
+ }
59
+ if (current.type !== 'Identifier')
60
+ return null;
61
+ for (const statement of ast.program.body) {
62
+ const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement;
63
+ if (declaration?.type !== 'VariableDeclaration')
64
+ continue;
65
+ for (const declarator of declaration.declarations) {
66
+ if (declarator.id.type === 'Identifier' && declarator.id.name === current.name && declarator.init) {
67
+ return routingObject(declarator.init, ast, file, ctx, depth + 1);
68
+ }
69
+ }
70
+ }
71
+ const imported = importOf(ast, current.name);
72
+ if (!imported)
73
+ return null;
74
+ const target = resolveImport(file, imported.module, readPathAliases(ctx.appDir));
75
+ if (!target)
76
+ return null;
77
+ const targetAst = parseFile(readFileSync(target, 'utf8'), target);
78
+ if (!targetAst)
79
+ return null;
80
+ const exported = exportedExpression(targetAst, imported.imported);
81
+ return exported ? routingObject(exported, targetAst, target, ctx, depth + 1) : null;
82
+ }
83
+ function importOf(ast, local) {
84
+ for (const statement of ast.program.body) {
85
+ if (statement.type !== 'ImportDeclaration')
86
+ continue;
87
+ for (const specifier of statement.specifiers) {
88
+ if (specifier.local.name !== local)
89
+ continue;
90
+ const imported = specifier.type === 'ImportDefaultSpecifier' ? 'default'
91
+ : specifier.type === 'ImportNamespaceSpecifier' ? '*' : specifier.imported.name ?? specifier.imported.value;
92
+ return { module: statement.source.value, imported };
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+ /** The expression a module exports under `name` ('default' for its default export), or null. */
98
+ function exportedExpression(ast, name) {
99
+ for (const statement of ast.program.body) {
100
+ if (name === 'default' && statement.type === 'ExportDefaultDeclaration')
101
+ return statement.declaration;
102
+ if (statement.type !== 'ExportNamedDeclaration' || statement.source)
103
+ continue;
104
+ for (const declarator of statement.declaration?.type === 'VariableDeclaration' ? statement.declaration.declarations : []) {
105
+ if (declarator.id.type === 'Identifier' && declarator.id.name === name)
106
+ return declarator.init;
107
+ }
108
+ for (const specifier of statement.specifiers ?? []) {
109
+ if ((specifier.exported?.name ?? specifier.exported?.value) === name)
110
+ return specifier.local;
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+ /** next-intl's routing, read exactly, or why it cannot be. */
116
+ function intlRouting(config, ast, file, ctx, where) {
117
+ const unknown = (why) => ({ kind: 'unknown', why: `${where} uses next-intl's middleware, ${why}` });
118
+ const found = config ? routingObject(config, ast, file, ctx) : null;
119
+ if (!found)
120
+ return unknown('whose routing config init cannot read literally');
121
+ const value = literalValue(found.value, found.ast);
122
+ if (value === UNREADABLE || typeof value !== 'object' || value === null || Array.isArray(value)) {
123
+ return unknown('whose routing config is not literal');
124
+ }
125
+ const routing = value;
126
+ if (routing.pathnames !== undefined)
127
+ return unknown('whose localized pathnames make a URL show a page under another path');
128
+ if (routing.domains !== undefined)
129
+ return unknown('whose domains decide the locale by host');
130
+ const locales = Array.isArray(routing.locales) && routing.locales.every(locale => typeof locale === 'string') ? routing.locales : null;
131
+ const defaultLocale = typeof routing.defaultLocale === 'string' ? routing.defaultLocale : null;
132
+ if (!locales || !defaultLocale)
133
+ return unknown('whose locales or defaultLocale are not a literal list and string');
134
+ let mode;
135
+ let prefixes = {};
136
+ const prefix = routing.localePrefix;
137
+ if (prefix === undefined) {
138
+ // next-intl 3 made `always` the default; 2 had `as-needed`.
139
+ const version = resolveVersion(ctx, 'next-intl');
140
+ if (!version || version.major === null)
141
+ return unknown('whose default localePrefix depends on a next-intl version init cannot resolve');
142
+ mode = version.major >= 3 ? 'always' : 'as-needed';
143
+ }
144
+ else if (typeof prefix === 'string') {
145
+ mode = prefix;
146
+ }
147
+ else if (prefix && typeof prefix === 'object' && !Array.isArray(prefix)) {
148
+ mode = prefix.mode;
149
+ if (prefix.prefixes !== undefined) {
150
+ const given = prefix.prefixes;
151
+ if (!given || typeof given !== 'object' || Array.isArray(given) || !Object.values(given).every(item => typeof item === 'string' && item.startsWith('/'))) {
152
+ return unknown('whose localePrefix.prefixes are not literal paths');
153
+ }
154
+ prefixes = given;
155
+ }
156
+ }
157
+ if (mode !== 'always' && mode !== 'as-needed' && mode !== 'never')
158
+ return unknown(`whose localePrefix mode (${JSON.stringify(mode)}) init does not know`);
159
+ return { kind: 'locale-prefix', where, mode, locales, defaultLocale, prefixes };
160
+ }
161
+ /** One middleware or proxy file, read. */
162
+ function readMiddleware(ctx, file) {
163
+ const where = repoPath(ctx, file);
164
+ const ast = parseFile(readFileSync(file, 'utf8'), file);
165
+ if (!ast)
166
+ return { kind: 'unknown', why: `${where} (which init cannot parse)` };
167
+ const program = pathOf(ast, ast.program);
168
+ if (!program)
169
+ return { kind: 'unknown', why: `${where} (whose names init cannot resolve)` };
170
+ const reasons = [];
171
+ walk(ast.program, node => {
172
+ if (node.type === 'ExportAllDeclaration' || (node.type === 'ExportNamedDeclaration' && node.source)) {
173
+ reasons.push(`re-exports from ${node.source.value}`);
174
+ }
175
+ if (node.type === 'StringLiteral' && node.value.toLowerCase() === 'x-middleware-rewrite')
176
+ reasons.push('sets the x-middleware-rewrite header');
177
+ if ((node.type === 'CallExpression' || node.type === 'OptionalCallExpression') && node.callee.type === 'MemberExpression' && !node.callee.computed
178
+ && node.callee.property.name === 'rewrite')
179
+ reasons.push('calls rewrite()');
180
+ if (node.type === 'ObjectProperty' && keyName(node) === 'rewrite')
181
+ reasons.push('passes a rewrite');
182
+ if (node.type === 'ImportExpression' || (node.type === 'CallExpression' && (node.callee.type === 'Import'
183
+ || (node.callee.type === 'Identifier' && node.callee.name === 'require'))))
184
+ reasons.push('loads a module at runtime');
185
+ });
186
+ // Every value imported from outside Next is code init cannot see into, wherever it is used (called, passed to a local
187
+ // helper that calls it, exported as the middleware), except next-intl's createMiddleware and the routing it is given,
188
+ // which init reads.
189
+ const intlCalls = [];
190
+ for (const [local, binding] of Object.entries(program.scope.bindings)) {
191
+ const declaration = binding.path.parentPath?.node;
192
+ if (binding.kind !== 'module' || declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type'
193
+ || binding.path.node.importKind === 'type' || nextModule(declaration.source.value))
194
+ continue;
195
+ const factory = declaration.source.value === INTL_MIDDLEWARE && binding.path.node.type === 'ImportDefaultSpecifier';
196
+ for (const reference of binding.referencePaths) {
197
+ const parent = reference.parentPath?.node;
198
+ if (factory && parent?.type === 'CallExpression' && parent.callee === reference.node) {
199
+ intlCalls.push(parent);
200
+ continue;
201
+ }
202
+ const factoryArgument = parent?.type === 'CallExpression' && parent.arguments[0] === reference.node && parent.callee.type === 'Identifier'
203
+ && program.scope.getBinding(parent.callee.name)?.path.parentPath?.node.source?.value === INTL_MIDDLEWARE;
204
+ if (factoryArgument)
205
+ continue;
206
+ reasons.push(`uses ${local} from ${declaration.source.value}`);
207
+ break;
208
+ }
209
+ }
210
+ if (reasons.length > 0)
211
+ return { kind: 'unknown', why: `${where}, which ${reasons[0]} (init cannot read what that does to page paths)` };
212
+ if (intlCalls.length === 0)
213
+ return { kind: 'none' };
214
+ // next-intl: every createMiddleware call must read as the same routing.
215
+ const readings = intlCalls.map(call => intlRouting(call.arguments[0], ast, file, ctx, where));
216
+ const first = readings[0];
217
+ if (first.kind !== 'locale-prefix')
218
+ return first;
219
+ return readings.every(reading => JSON.stringify(reading) === JSON.stringify(first)) ? first
220
+ : { kind: 'unknown', why: `${where}, which calls next-intl's createMiddleware with several routing configs` };
221
+ }
222
+ /** What the app's middleware or proxy does to page paths (the first one found, as Next loads one). Needs loadBabel(). */
223
+ export function middlewareRewrites(ctx) {
224
+ for (const dir of [ctx.appDir, join(ctx.appDir, 'src')]) {
225
+ for (const name of MIDDLEWARE_FILES) {
226
+ const file = join(dir, name);
227
+ if (isFile(file))
228
+ return readMiddleware(ctx, file);
229
+ }
230
+ }
231
+ return { kind: 'none' };
232
+ }
@@ -42,6 +42,9 @@ try {
42
42
  if (written) {
43
43
  const files = Array.isArray(result.files) ? result.files.join(', ') : '';
44
44
  console.log(`Haystack route manifest ${String(result.release)}: ${files}`);
45
+ // What the person must still do for it to be reachable (a Next.js basePath): said on every build until done.
46
+ for (const note of Array.isArray(result.notes) ? result.notes : [])
47
+ console.warn(`haystack capture manifest: ${String(note)}.`);
45
48
  }
46
49
  }
47
50
  catch { /* the command did not answer: no manifest was written */ }
@@ -1,42 +1,37 @@
1
1
  /** CAPTURE-V1 rule 7b in `haystack pre-verify`: the routes a change touches (the brief's blast-radius files and the changed
2
2
  * files, joined through the route manifest's source entries) with their share of captured sessions over the window, the union
3
- * over all of them, freshness and drops; sessions on routes no manifest names are unmapped, never zero. The application is the
4
- * one `.haystack/capture.json` names (rule 8b, written by `haystack init`); a checkout without it has no capture and the brief
5
- * is exactly as before. A capture read that fails is reported in the block and never fails the command. */
6
- import { existsSync, readFileSync } from 'node:fs';
7
- import { join } from 'node:path';
3
+ * over all of them, freshness and drops; sessions on routes no manifest names are unmapped, never zero. One section per app
4
+ * whose `.haystack/capture.json` (rule 8b, written by `haystack init` in the app's directory, apps/web in a monorepo) records it,
5
+ * found as init finds them (app-config.ts); a checkout without one has no capture and the brief is exactly as before. A capture
6
+ * read that fails is reported in its section and never fails the command. */
7
+ import { join, posix } from 'node:path';
8
8
  import chalk from 'chalk';
9
+ import { CAPTURE_CONFIG_PATH, captureConfigDirs, readCaptureConfig } from '../capture/app-config.js';
9
10
  import { classifyHttpError } from '../utils/haystack-api.js';
10
11
  import { gatewayFetch } from './case-batch.js';
11
12
  import { CAPTURE_APPLICATION_ID, CAPTURE_WINDOW_PATH, captureChangedRoutes, } from './capture-contract.js';
12
- /** Rule 8b: the application identity init commits (key, repository id, application id, adapter). */
13
- export const CAPTURE_IDENTITY_FILE = '.haystack/capture.json';
14
- /** The checkout's application id, null without the identity file, or why the file cannot be read. */
15
- function captureApplication(gitRoot) {
16
- const path = join(gitRoot, CAPTURE_IDENTITY_FILE);
17
- if (!existsSync(path))
18
- return null;
19
- let value;
13
+ /** An app's application id from its record, or why the record cannot give one. */
14
+ function captureApplication(gitRoot, app) {
15
+ const file = posix.join(app, CAPTURE_CONFIG_PATH);
16
+ let config;
20
17
  try {
21
- value = JSON.parse(readFileSync(path, 'utf8'));
18
+ config = readCaptureConfig(join(gitRoot, app));
22
19
  }
23
20
  catch (error) {
24
- return { unreadable: `${CAPTURE_IDENTITY_FILE} is not JSON (${error instanceof Error ? error.message : String(error)})` };
25
- }
26
- const applicationId = value?.applicationId;
27
- if (typeof applicationId !== 'string' || !CAPTURE_APPLICATION_ID.test(applicationId)) {
28
- return { unreadable: `${CAPTURE_IDENTITY_FILE} names no valid applicationId` };
21
+ return { unreadable: `${file}: ${error instanceof Error ? error.message : String(error)}` };
29
22
  }
30
- return { applicationId };
23
+ if (config === null)
24
+ return { unreadable: `${file} is gone` };
25
+ if (config.app !== app)
26
+ return { unreadable: `${file} records the app ${config.app}, not its own directory; run \`haystack init --app ${app}\`` };
27
+ if (!CAPTURE_APPLICATION_ID.test(config.applicationId))
28
+ return { unreadable: `${file} names no valid applicationId` };
29
+ return { applicationId: config.applicationId };
31
30
  }
32
- /** The pre-verify capture block, or null for a checkout with no capture identity. `files`: the brief's function files and the
33
- * changed files. */
34
- export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
35
- const application = captureApplication(gitRoot);
36
- if (application === null)
37
- return null;
31
+ async function readApp(gitRoot, app, repository, token, files, now) {
32
+ const application = captureApplication(gitRoot, app);
38
33
  if ('unreadable' in application)
39
- return { applicationId: null, state: 'unavailable', reason: application.unreadable };
34
+ return { app, applicationId: null, state: 'unavailable', reason: application.unreadable };
40
35
  const { applicationId } = application;
41
36
  try {
42
37
  const response = await gatewayFetch(`${CAPTURE_WINDOW_PATH}?repository=${encodeURIComponent(repository)}&app=${encodeURIComponent(applicationId)}`, token);
@@ -46,18 +41,32 @@ export async function readPreVerifyCapture(gitRoot, repository, token, files, no
46
41
  if (answer.version !== 'capture-v1' || answer.applicationId !== applicationId)
47
42
  throw new Error('the capture window answer names another application');
48
43
  if (answer.window === null)
49
- return { applicationId, state: 'no-data' };
50
- return { applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
44
+ return { app, applicationId, state: 'no-data' };
45
+ return { app, applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
51
46
  }
52
47
  catch (error) {
53
- return { applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
48
+ return { app, applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
54
49
  }
55
50
  }
51
+ /** The pre-verify capture sections, one per app with a record, or null for a checkout with none. `files`: the brief's function
52
+ * files and the changed files. */
53
+ export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
54
+ let apps;
55
+ try {
56
+ apps = captureConfigDirs(gitRoot, []);
57
+ }
58
+ catch (error) {
59
+ return [{ app: '.', applicationId: null, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) }];
60
+ }
61
+ if (apps.length === 0)
62
+ return null;
63
+ return Promise.all(apps.map(app => readApp(gitRoot, app, repository, token, files, now)));
64
+ }
56
65
  const percent = (share) => share === null ? 'n/a' : `${(share * 100).toFixed(1)}%`;
57
66
  const plain = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '); // eslint-disable-line no-control-regex
58
- /** The block as a coding agent reads it, after the brief. */
67
+ /** One app's block as a coding agent reads it, after the brief. */
59
68
  export function formatCapture(capture) {
60
- const lines = [chalk.bold('What real users do where your change is (captured sessions)')];
69
+ const lines = [chalk.bold(`What real users do where your change is (captured sessions of ${capture.app === '.' ? 'the app at the repository root' : plain(capture.app)})`)];
61
70
  if (capture.state === 'unavailable') {
62
71
  lines.push(` Captured sessions could not be read${capture.applicationId ? ` for ${plain(capture.applicationId)}` : ''}: ${plain(capture.reason)}`);
63
72
  return lines.join('\n');
@@ -5,13 +5,13 @@
5
5
  * current.json. No credential is involved: whoever deploys the app publishes its manifest.
6
6
  *
7
7
  * It runs inside the customer's build (the package script init extends, or the Rails rake file), from the CLI the app pins
8
- * as an exact devDependency. It never fails that build (rule 13): whatever goes wrong, it prints one warning line and exits
8
+ * as an exact production dependency. It never fails that build (rule 13): whatever goes wrong, it prints one warning line and exits
9
9
  * 0, because a missing manifest only stops the tag on this release (rule 13a: no manifest, no work). It needs no git
10
10
  * checkout: repository paths come from the app path init recorded.
11
11
  */
12
12
  import chalk from 'chalk';
13
13
  import { relative, resolve, sep } from 'node:path';
14
- import { detectApp } from '../capture/adapters/index.js';
14
+ import { ADAPTER_IDS, detectApp, detectWith } from '../capture/adapters/index.js';
15
15
  import { isManual } from '../capture/adapters/types.js';
16
16
  import { CAPTURE_CONFIG_PATH, readCaptureConfig } from '../capture/app-config.js';
17
17
  import { loadBabel } from '../capture/js-ast.js';
@@ -44,35 +44,43 @@ async function publish(options, locations) {
44
44
  if (!config)
45
45
  throw new Error(`${appDir} has no ${CAPTURE_CONFIG_PATH}: run \`haystack init\` in the repository to set capture up.`);
46
46
  locations.push(...config.publish.map(dir => resolve(appDir, dir)));
47
- if (config.adapter === null)
48
- throw new Error(`${CAPTURE_CONFIG_PATH} names no framework adapter: init left this app's manifest to a manual step.`);
47
+ if (options.adapter !== undefined && !ADAPTER_IDS.includes(options.adapter)) {
48
+ throw new Error(`--adapter ${options.adapter} is not one of ${ADAPTER_IDS.join(', ')}`);
49
+ }
50
+ const adapter = (options.adapter ?? config.adapter);
51
+ if (adapter === null)
52
+ throw new Error(`${CAPTURE_CONFIG_PATH} names no framework adapter: run it with --adapter <${ADAPTER_IDS.join('|')}>`);
49
53
  // The checkout's root when the app sits at its recorded path; else (a build that sees only the app) the app itself.
50
54
  const suffix = config.app === '.' ? '' : config.app.split('/').join(sep);
51
55
  const repoRoot = suffix !== '' && appDir.endsWith(`${sep}${suffix}`) ? appDir.slice(0, -suffix.length - 1) : appDir;
52
56
  await loadBabel();
53
57
  const ctx = appContext(repoRoot, appDir, config.app);
54
- const detected = await detectApp(ctx);
55
- if (!detected || detected.id !== config.adapter) {
56
- throw new Error(`${CAPTURE_CONFIG_PATH} was written for ${config.adapter}, and this app is now ${detected?.label ?? 'no framework init recognizes'}; run \`haystack init\` again.`);
57
- }
58
- if (!isManual(detected.publishDirs))
59
- locations.push(...detected.publishDirs.map(dir => resolve(appDir, dir)).filter(dir => !locations.includes(dir)));
60
- if (detected.blocked)
61
- throw new Error(`${detected.label}: ${detected.blocked}.`);
62
- const routes = exactRoutes(await detected.routes({ railsRoutes: options.railsRoutes ? resolve(options.railsRoutes) : undefined }));
63
- if (isManual(routes))
64
- throw new Error(routes.manual);
58
+ const detected = await detectWith(ctx, adapter);
59
+ if (!detected)
60
+ throw new Error(`the ${adapter} adapter does not recognize ${appDir} (${(await detectApp(ctx))?.label ?? 'no framework init recognizes'}); run \`haystack init\` again`);
61
+ // Checked on every build, so once the user removes the cause the next build publishes, with nothing to rerun.
62
+ if (detected.manifestBlocked)
63
+ throw new Error(`${detected.label}: ${detected.manifestBlocked}`);
65
64
  if (isManual(detected.publishDirs))
66
65
  throw new Error(detected.publishDirs.manual);
66
+ const publishDirs = [...detected.publishDirs, ...detected.buildPublishDirs()].map(dir => resolve(appDir, dir));
67
+ locations.push(...publishDirs.filter(dir => !locations.includes(dir)));
68
+ const routes = exactRoutes(await detected.routes({ railsRoutes: options.railsRoutes ? resolve(options.railsRoutes) : undefined,
69
+ urlRewrites: config.urlRewrites ?? null }));
70
+ if (isManual(routes))
71
+ throw new Error(routes.manual);
67
72
  const manifest = buildManifest(config, detected.manifestAdapter, routes, detected.chunks());
68
- const written = writeManifest(detected.publishDirs.map(dir => resolve(appDir, dir)), manifest);
73
+ const written = writeManifest(publishDirs, manifest);
74
+ const notes = detected.buildNotes();
69
75
  if (options.json) {
70
76
  process.stdout.write(`${JSON.stringify({ written: true, release: manifest.release, routes: manifest.routes.length, chunks: manifest.chunks.length,
71
- files: written.map(file => relative(appDir, file)) }, null, 2)}\n`);
77
+ files: written.map(file => relative(appDir, file)), notes }, null, 2)}\n`);
72
78
  return;
73
79
  }
74
80
  const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
75
81
  console.log(chalk.green(`Haystack route manifest ${manifest.release}: ${count(manifest.routes.length, 'route')}, ${count(manifest.chunks.length, 'chunk')} (${detected.label})`));
76
82
  for (const file of written)
77
83
  console.log(chalk.dim(` ${relative(appDir, file)}`));
84
+ for (const note of notes)
85
+ console.warn(chalk.yellow(`haystack capture manifest: ${note}.`));
78
86
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * haystack feedback <message...>
3
+ *
4
+ * Tells the Haystack team something, in a sentence: a verify that went wrong,
5
+ * an output that confused you, a missing option. Meant for coding agents as
6
+ * much as for people: when Haystack gets in the way, say so here and keep
7
+ * going. The message lands in the team's Slack at once, with the CLI version,
8
+ * the platform and the repository (when run inside a GitHub checkout).
9
+ *
10
+ * `-` as the whole message reads it from stdin.
11
+ */
12
+ import { readFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import chalk from 'chalk';
16
+ import { withSchema } from '../schema.js';
17
+ import { resolveAuthContext } from '../utils/auth.js';
18
+ import { parseRemoteUrl } from '../utils/git.js';
19
+ import { haystackJson } from '../utils/haystack-api.js';
20
+ export const FEEDBACK_MAX_CHARS = 4000;
21
+ function cliVersion() {
22
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
23
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
24
+ }
25
+ /** The checkout's GitHub repository, or null outside one (feedback never needs a repository). */
26
+ function currentRepository() {
27
+ try {
28
+ const remote = parseRemoteUrl();
29
+ return `${remote.owner}/${remote.repo}`;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function buildFeedbackRequest(message, options, repository) {
36
+ const text = message.trim();
37
+ if (!text)
38
+ throw new Error('Say what happened: haystack feedback "<message>" (or - to read it from stdin)');
39
+ if (text.length > FEEDBACK_MAX_CHARS) {
40
+ throw new Error(`Feedback is ${text.length} characters; keep it under ${FEEDBACK_MAX_CHARS}`);
41
+ }
42
+ return {
43
+ message: text,
44
+ context: {
45
+ cliVersion: cliVersion(),
46
+ platform: `${process.platform}-${process.arch} node ${process.versions.node}`,
47
+ repository,
48
+ runId: options.run ?? null,
49
+ },
50
+ };
51
+ }
52
+ export async function feedbackCommand(words, options) {
53
+ const message = words.length === 1 && words[0] === '-' ? readFileSync(0, 'utf8') : words.join(' ');
54
+ const request = buildFeedbackRequest(message, options, currentRepository());
55
+ const { token } = await resolveAuthContext({ preferredLogin: options.account });
56
+ await haystackJson('/api/cli/feedback', token, {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify(request),
60
+ });
61
+ if (options.json) {
62
+ process.stdout.write(JSON.stringify(withSchema('feedback', { status: 'sent' }), null, 2) + '\n');
63
+ return;
64
+ }
65
+ console.log(chalk.green('Sent to the Haystack team.'));
66
+ }