@haystackeditor/cli 0.24.1 → 0.25.1

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 (51) hide show
  1. package/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
  2. package/dist/assets/capture/release.json +4 -0
  3. package/dist/assets/telemetry/runtime.cjs +829 -1254
  4. package/dist/capture/adapters/client-routes.js +383 -0
  5. package/dist/capture/adapters/django.js +134 -0
  6. package/dist/capture/adapters/files.js +77 -0
  7. package/dist/capture/adapters/index.js +74 -0
  8. package/dist/capture/adapters/jsx-edit.js +81 -0
  9. package/dist/capture/adapters/next-build.js +113 -0
  10. package/dist/capture/adapters/next.js +494 -0
  11. package/dist/capture/adapters/nuxt.js +199 -0
  12. package/dist/capture/adapters/rails.js +178 -0
  13. package/dist/capture/adapters/react-router.js +439 -0
  14. package/dist/capture/adapters/sveltekit.js +109 -0
  15. package/dist/capture/adapters/types.js +4 -0
  16. package/dist/capture/adapters/vite.js +135 -0
  17. package/dist/capture/app-config.js +107 -0
  18. package/dist/capture/consent.js +127 -0
  19. package/dist/capture/csp.js +332 -0
  20. package/dist/capture/html.js +74 -0
  21. package/dist/capture/js-ast.js +400 -0
  22. package/dist/capture/manifest.js +95 -0
  23. package/dist/capture/project.js +177 -0
  24. package/dist/capture/route-pattern.js +119 -0
  25. package/dist/capture/script-release.js +47 -0
  26. package/dist/capture/tag.js +74 -0
  27. package/dist/capture/url-rewrites.js +232 -0
  28. package/dist/capture-step.js +56 -0
  29. package/dist/commands/capture-brief.js +92 -0
  30. package/dist/commands/capture-contract.js +46 -0
  31. package/dist/commands/capture-manifest.js +86 -0
  32. package/dist/commands/init-capture.js +426 -0
  33. package/dist/commands/init-telemetry.js +1028 -0
  34. package/dist/commands/init.js +78 -5
  35. package/dist/commands/server-telemetry-contract.d.ts +66 -0
  36. package/dist/commands/server-telemetry-contract.js +127 -0
  37. package/dist/commands/telemetry-token.js +238 -0
  38. package/dist/commands/telemetry.d.ts +161 -8
  39. package/dist/commands/telemetry.js +940 -158
  40. package/dist/commands/verify-onboarding.js +21 -1
  41. package/dist/commands/verify.js +56 -9
  42. package/dist/index.js +85 -6
  43. package/dist/schema.js +2 -2
  44. package/dist/telemetry/next-loader.cjs +66 -9
  45. package/dist/telemetry/next.d.ts +11 -3
  46. package/dist/telemetry/next.js +95 -15
  47. package/dist/telemetry/typed-source.d.ts +47 -0
  48. package/dist/telemetry/typed-source.js +379 -0
  49. package/package.json +4 -2
  50. package/schemas/init.v1.json +63 -4
  51. package/schemas/pre-verify.v1.json +60 -3
@@ -0,0 +1,119 @@
1
+ /** FIXED CONTRACT — mirror of infra/lambda/haystack-design-verifier-shared/capture_route_pattern.ts for the published
2
+ * CLI, whose build cannot import repository files outside src. Everything below this comment is that file as it is;
3
+ * the source file wins. */
4
+ const NAME = /^[A-Za-z0-9_]+$/;
5
+ /** A parameter name as the pattern syntax spells it: the framework's own name where it is a plain word. */
6
+ export function paramName(raw) {
7
+ const cleaned = raw.replace(/[^A-Za-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
8
+ return cleaned === '' ? 'param' : cleaned;
9
+ }
10
+ export function formatPattern(pattern) {
11
+ const body = pattern.segments.map(segment => {
12
+ if (segment.kind === 'literal')
13
+ return segment.value;
14
+ return `${segment.kind === 'param' ? ':' : '*'}${segment.name}${segment.optional ? '?' : ''}`;
15
+ }).join('/');
16
+ return `${pattern.hash ? '#' : ''}/${body}`;
17
+ }
18
+ /** The match rule for these segments, with why it is not exact when it is not (route-pattern's syntax above). */
19
+ export function ruleFor(segments, hash = false) {
20
+ const widened = segments.find(segment => segment.kind !== 'literal' && segment.inexact !== undefined);
21
+ const splat = segments.findIndex(segment => segment.kind === 'splat');
22
+ const inexact = widened && widened.kind !== 'literal' ? widened.inexact
23
+ : splat !== -1 && splat !== segments.length - 1 ? 'a catch-all segment before its end' : null;
24
+ return { match: formatPattern({ hash, segments }), inexact };
25
+ }
26
+ /** Parses a match rule; throws on anything outside the syntax above (an adapter bug, never the customer's input). */
27
+ export function parsePattern(match) {
28
+ const hash = match.startsWith('#');
29
+ const path = hash ? match.slice(1) : match;
30
+ if (!path.startsWith('/'))
31
+ throw new Error(`Route pattern ${match} does not start with / or #/.`);
32
+ const parts = path === '/' ? [] : path.slice(1).split('/');
33
+ const segments = parts.map((part, index) => {
34
+ if (part === '')
35
+ throw new Error(`Route pattern ${match} has an empty segment.`);
36
+ const sigil = part[0];
37
+ if (sigil !== ':' && sigil !== '*')
38
+ return { kind: 'literal', value: part };
39
+ const optional = part.endsWith('?');
40
+ const name = part.slice(1, optional ? -1 : undefined);
41
+ if (!NAME.test(name))
42
+ throw new Error(`Route pattern ${match} has a parameter without a plain name.`);
43
+ if (sigil === '*' && index !== parts.length - 1)
44
+ throw new Error(`Route pattern ${match} has a splat before its end.`);
45
+ return sigil === ':' ? { kind: 'param', name, optional } : { kind: 'splat', name, optional };
46
+ });
47
+ return { hash, segments };
48
+ }
49
+ const END = 5;
50
+ function rank(segment) {
51
+ if (segment === undefined)
52
+ return END;
53
+ if (segment.kind === 'literal')
54
+ return 4;
55
+ if (segment.kind === 'param')
56
+ return segment.optional ? 2 : 3;
57
+ return segment.optional ? 0 : 1;
58
+ }
59
+ /** Most specific first: pathname patterns before hash patterns, then segment by segment literal > end of pattern is
60
+ * not a factor between disjoint lengths > param > optional param > splat > optional splat; ties by rule text. */
61
+ export function compareRoutePatterns(a, b) {
62
+ const pa = parsePattern(a);
63
+ const pb = parsePattern(b);
64
+ if (pa.hash !== pb.hash)
65
+ return pa.hash ? 1 : -1;
66
+ const length = Math.max(pa.segments.length, pb.segments.length);
67
+ for (let index = 0; index < length; index += 1) {
68
+ const difference = rank(pb.segments[index]) - rank(pa.segments[index]);
69
+ if (difference !== 0)
70
+ return difference;
71
+ }
72
+ return a < b ? -1 : a > b ? 1 : 0;
73
+ }
74
+ /** parsePattern, answering null where it throws: a rule outside the syntax. */
75
+ function patternOrNull(match) {
76
+ try {
77
+ return parsePattern(match);
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ function decodedSegments(path) {
84
+ const trimmed = path.replace(/\/+$/, '');
85
+ if (trimmed === '')
86
+ return [];
87
+ try {
88
+ return trimmed.slice(1).split('/').map(part => decodeURIComponent(part));
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ function matchFrom(segments, parts, si, pi) {
95
+ if (si === segments.length)
96
+ return pi === parts.length;
97
+ const segment = segments[si];
98
+ if (segment.kind === 'splat')
99
+ return parts.length - pi >= (segment.optional ? 0 : 1);
100
+ if (segment.kind === 'param' && segment.optional && matchFrom(segments, parts, si + 1, pi))
101
+ return true;
102
+ if (pi === parts.length)
103
+ return false;
104
+ if (segment.kind === 'literal' ? parts[pi] !== segment.value : parts[pi] === '')
105
+ return false;
106
+ return matchFrom(segments, parts, si + 1, pi + 1);
107
+ }
108
+ /** The reference matcher the browser tag implements: does `match` match this location? A rule outside the syntax matches
109
+ * nothing. */
110
+ export function matchRoutePattern(match, location) {
111
+ const pattern = patternOrNull(match);
112
+ if (pattern === null)
113
+ return false;
114
+ const raw = pattern.hash ? location.hash.replace(/^#/, '').split(/[?]/)[0] : location.pathname;
115
+ if (!raw.startsWith('/'))
116
+ return false;
117
+ const parts = decodedSegments(raw);
118
+ return parts !== null && matchFrom(pattern.segments, parts, 0, 0);
119
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The capture script this CLI version installs (CAPTURE-V1 rule 1).
3
+ *
4
+ * Release step, one command: `pnpm run release:cli` in packages/haystack-capture writes the built tag into
5
+ * src/assets/capture/ as `capture.<hash>.js` and `release.json` ({ file, integrity }); the CLI build copies src/assets to
6
+ * dist/assets. Init copies the file byte for byte into the app's static output and pins it with that integrity, so a
7
+ * release whose file does not have that digest is refused rather than installed (a mismatched integrity would make every
8
+ * browser refuse the script).
9
+ *
10
+ * No release.json: this CLI version carries no capture script, and init reports capture as unsupported.
11
+ */
12
+ import { createHash } from 'node:crypto';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { gzipSync } from 'node:zlib';
17
+ import { CAPTURE_SCRIPT_MAX_BYTES } from '../commands/capture-contract.js';
18
+ const FILE_NAME = /^capture\.[0-9a-z]{6,64}\.js$/;
19
+ const INTEGRITY = /^sha384-[A-Za-z0-9+/]{64}$/;
20
+ export function sha384Integrity(bytes) {
21
+ return `sha384-${createHash('sha384').update(bytes).digest('base64')}`;
22
+ }
23
+ /** The released script, verified against its integrity and the contract's size cap; null when this CLI version carries
24
+ * none. Throws when the release is inconsistent (a packaging bug, never the customer's problem). */
25
+ export function loadCaptureScript() {
26
+ // dist/capture/script-release.js → dist/assets/capture/
27
+ const assets = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'capture');
28
+ const releaseFile = join(assets, 'release.json');
29
+ if (!existsSync(releaseFile))
30
+ return null;
31
+ const release = JSON.parse(readFileSync(releaseFile, 'utf8'));
32
+ if (typeof release.file !== 'string' || !FILE_NAME.test(release.file) || typeof release.integrity !== 'string' || !INTEGRITY.test(release.integrity)) {
33
+ throw new Error(`This CLI's capture release.json does not name a capture.<hash>.js and its sha384 integrity; reinstall @haystackeditor/cli.`);
34
+ }
35
+ const bytes = readFileSync(join(assets, release.file));
36
+ if (sha384Integrity(bytes) !== release.integrity) {
37
+ throw new Error(`This CLI's capture script ${release.file} does not match its integrity; reinstall @haystackeditor/cli.`);
38
+ }
39
+ // Measured as the tag's build measures it (packages/haystack-capture/scripts/build.ts: gzip level 9).
40
+ if (gzipSync(bytes, { level: 9 }).length > CAPTURE_SCRIPT_MAX_BYTES) {
41
+ throw new Error(`This CLI's capture script ${release.file} is over ${CAPTURE_SCRIPT_MAX_BYTES} bytes gzipped (CAPTURE-V1 rule 1).`);
42
+ }
43
+ const text = bytes.toString('utf8');
44
+ if (!Buffer.from(text, 'utf8').equals(bytes))
45
+ throw new Error(`This CLI's capture script ${release.file} is not UTF-8 text.`);
46
+ return { file: release.file, integrity: release.integrity, text, bytes: bytes.length };
47
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The tags init writes (CAPTURE-V1 rule 1), in each form a framework takes them, and how init recognizes its own tags on
3
+ * a rerun (by their /_haystack/ path, Haystack's own namespace in the app's static output), so a rerun or a newer script
4
+ * version replaces them instead of adding more.
5
+ */
6
+ /** Where the files live in the app's static output; every adapter publishes at the origin root (rule 4 Publishing). */
7
+ export const STATIC_SUBDIR = '_haystack';
8
+ export function staticPath(file) {
9
+ return `/${STATIC_SUBDIR}/${file}`;
10
+ }
11
+ /** The tag's attributes in order (src first), for every renderer. */
12
+ export function captureAttributes(tags) {
13
+ return [
14
+ ['src', staticPath(tags.script.file)],
15
+ ['integrity', tags.script.integrity],
16
+ ['data-key', tags.key],
17
+ ...(tags.consentNotRequired ? [['data-consent', 'not-required']] : []),
18
+ ];
19
+ }
20
+ export function consentAttributes(consent) {
21
+ return [['src', staticPath(consent.file)], ['integrity', consent.integrity]];
22
+ }
23
+ function escapeAttribute(value) {
24
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
25
+ }
26
+ function htmlScript(attributes, src) {
27
+ const rendered = attributes.map(([name, value]) => name === 'src' && src !== undefined ? `src="${src}"` : `${name}="${escapeAttribute(value)}"`);
28
+ return `<script ${rendered.join(' ')} async></script>`;
29
+ }
30
+ /** Plain HTML (index.html, app.html, Rails layouts). */
31
+ export function htmlTags(tags) {
32
+ return [htmlScript(captureAttributes(tags)), ...(tags.consent ? [htmlScript(consentAttributes(tags.consent))] : [])];
33
+ }
34
+ /** Django templates: the file is served through the staticfiles app, so its URL comes from {% static %}. */
35
+ export function djangoTags(tags) {
36
+ const src = (file) => `{% static '${STATIC_SUBDIR}/${file}' %}`;
37
+ return [htmlScript(captureAttributes(tags), src(tags.script.file)),
38
+ ...(tags.consent ? [htmlScript(consentAttributes(tags.consent), src(tags.consent.file))] : [])];
39
+ }
40
+ function jsxAttributes(attributes) {
41
+ return attributes.map(([name, value]) => `${name}=${JSON.stringify(value)}`).join(' ');
42
+ }
43
+ /** Next.js: its Script component with strategy="lazyOnload" runs the script after the page's load event (rule 1). */
44
+ export function nextScriptTags(tags, component) {
45
+ return [`<${component} ${jsxAttributes(captureAttributes(tags))} strategy="lazyOnload" />`,
46
+ ...(tags.consent ? [`<${component} ${jsxAttributes(consentAttributes(tags.consent))} strategy="lazyOnload" />`] : [])];
47
+ }
48
+ /** JSX documents rendered by React (React Router / Remix root): a plain async script element. */
49
+ export function jsxScriptTags(tags) {
50
+ return [`<script ${jsxAttributes(captureAttributes(tags))} async />`,
51
+ ...(tags.consent ? [`<script ${jsxAttributes(consentAttributes(tags.consent))} async />`] : [])];
52
+ }
53
+ /** Nuxt `app.head.script` entries. */
54
+ export function nuxtScriptEntries(tags) {
55
+ const entry = (attributes) => `{ ${attributes.map(([name, value]) => `${/^[a-z]+$/.test(name) ? name : `'${name}'`}: '${value}'`).join(', ')}, async: true }`;
56
+ return [entry(captureAttributes(tags)), ...(tags.consent ? [entry(consentAttributes(tags.consent))] : [])];
57
+ }
58
+ /** Is this script source one of init's own files? 'capture' for the tag (self-hosted, or the hosted copy on
59
+ * c.haystack.sh), 'consent' for a consent integration, else null. */
60
+ export function ourScriptKind(src) {
61
+ const staticTemplate = /^\{%\s*static\s+['"]([^'"]+)['"]\s*%\}$/.exec(src.trim());
62
+ const path = staticTemplate ? `/${staticTemplate[1]}` : src;
63
+ if (path.startsWith('https://c.haystack.sh/') && path.endsWith('.js'))
64
+ return 'capture';
65
+ const index = path.lastIndexOf(`/${STATIC_SUBDIR}/`);
66
+ if (index === -1)
67
+ return null;
68
+ const file = path.slice(index + STATIC_SUBDIR.length + 2);
69
+ if (/^capture\.[0-9a-z]+\.js$/.test(file))
70
+ return 'capture';
71
+ if (/^consent-[a-z]+\.[0-9a-f]+\.js$/.test(file))
72
+ return 'consent';
73
+ return null;
74
+ }
@@ -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
+ }
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `haystack-capture-step`: the build step `haystack init` joins to an app's build script (CAPTURE-V1 rules 4, 13). It runs
4
+ * `haystack capture manifest --app . --json` (plus any arguments given) in a child process and always exits 0: whatever
5
+ * happens to the manifest, the CLI failing to load or start included, the customer's build goes on.
6
+ *
7
+ * Unless the command reports a manifest written, the step removes current.json from the directories
8
+ * .haystack/capture.json records as the app's publication locations, so the build never ships the manifest of an earlier
9
+ * release (rule 4 Publishing; the tag then records nothing on this one). It imports nothing but Node.js built-ins, so
10
+ * nothing it loads can fail.
11
+ */
12
+ import { spawnSync } from 'node:child_process';
13
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
14
+ import { dirname, join, resolve } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ /** current.json at every recorded publication location, removed; how many were. */
17
+ function withdrawCurrent() {
18
+ let removed = 0;
19
+ try {
20
+ const config = JSON.parse(readFileSync(join('.haystack', 'capture.json'), 'utf8'));
21
+ const dirs = Array.isArray(config.publish) ? config.publish.filter((dir) => typeof dir === 'string') : [];
22
+ for (const dir of dirs) {
23
+ const file = join(resolve(dir), '.well-known', 'haystack-capture', 'current.json');
24
+ try {
25
+ if (existsSync(file)) {
26
+ unlinkSync(file);
27
+ removed += 1;
28
+ }
29
+ }
30
+ catch { /* the next location still gets its chance */ }
31
+ }
32
+ }
33
+ catch { /* no readable record: nothing init published here to withdraw */ }
34
+ return removed;
35
+ }
36
+ let written = false;
37
+ try {
38
+ const cli = join(dirname(fileURLToPath(import.meta.url)), 'index.js');
39
+ const run = spawnSync(process.execPath, [cli, 'capture', 'manifest', '--app', '.', '--json', ...process.argv.slice(2)], { stdio: ['ignore', 'pipe', 'inherit'], encoding: 'utf8' });
40
+ const result = JSON.parse(run.stdout);
41
+ written = result.written === true;
42
+ if (written) {
43
+ const files = Array.isArray(result.files) ? result.files.join(', ') : '';
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)}.`);
48
+ }
49
+ }
50
+ catch { /* the command did not answer: no manifest was written */ }
51
+ if (!written) {
52
+ const removed = withdrawCurrent();
53
+ console.warn(`haystack capture manifest did not write a route manifest${removed > 0 ? ', so the previous current.json was removed' : ''};`
54
+ + ' the build goes on.');
55
+ }
56
+ process.exitCode = 0;
@@ -0,0 +1,92 @@
1
+ /** CAPTURE-V1 rule 7b in `haystack pre-verify`: the routes a change touches (the brief's blast-radius files and the changed
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';
8
+ import chalk from 'chalk';
9
+ import { classifyHttpError } from '../utils/haystack-api.js';
10
+ import { gatewayFetch } from './case-batch.js';
11
+ 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;
20
+ try {
21
+ value = JSON.parse(readFileSync(path, 'utf8'));
22
+ }
23
+ 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` };
29
+ }
30
+ return { applicationId };
31
+ }
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;
38
+ if ('unreadable' in application)
39
+ return { applicationId: null, state: 'unavailable', reason: application.unreadable };
40
+ const { applicationId } = application;
41
+ try {
42
+ const response = await gatewayFetch(`${CAPTURE_WINDOW_PATH}?repository=${encodeURIComponent(repository)}&app=${encodeURIComponent(applicationId)}`, token);
43
+ if (!response.ok)
44
+ throw await classifyHttpError(response, `Haystack API ${CAPTURE_WINDOW_PATH}`);
45
+ const answer = await response.json();
46
+ if (answer.version !== 'capture-v1' || answer.applicationId !== applicationId)
47
+ throw new Error('the capture window answer names another application');
48
+ if (answer.window === null)
49
+ return { applicationId, state: 'no-data' };
50
+ return { applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
51
+ }
52
+ catch (error) {
53
+ return { applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
54
+ }
55
+ }
56
+ const percent = (share) => share === null ? 'n/a' : `${(share * 100).toFixed(1)}%`;
57
+ 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. */
59
+ export function formatCapture(capture) {
60
+ const lines = [chalk.bold('What real users do where your change is (captured sessions)')];
61
+ if (capture.state === 'unavailable') {
62
+ lines.push(` Captured sessions could not be read${capture.applicationId ? ` for ${plain(capture.applicationId)}` : ''}: ${plain(capture.reason)}`);
63
+ return lines.join('\n');
64
+ }
65
+ if (capture.state === 'no-data') {
66
+ lines.push(` No captured sessions for ${plain(capture.applicationId)} yet: none arrived from its registered origins, or none consented.`);
67
+ return lines.join('\n');
68
+ }
69
+ const window = capture.window;
70
+ const days = window.days.length ? `${window.days[0]} to ${window.days.at(-1)}` : 'the window';
71
+ lines.push(` ${window.sessions} captured session(s), ${days}; newest data ${window.newestHour === null ? 'none' : `${window.newestHour}:00 UTC`}`
72
+ + `${window.stale ? ` (stale: ${window.ageHours === null ? 'no data' : `${Math.round(window.ageHours)} hours old`})` : ''}.`);
73
+ if (window.routes.length === 0)
74
+ lines.push(' None of the files this change touches defines a route captured sessions reached.');
75
+ for (const route of window.routes) {
76
+ lines.push(` ${plain(route.match)} (${plain(route.source)}): ${percent(route.share)} of captured sessions (${route.sessions})`);
77
+ }
78
+ if (window.routes.length > 1)
79
+ lines.push(` Together: ${percent(window.union.share)} of captured sessions (${window.union.sessions}) visited at least one of them.`);
80
+ if (window.unmapped !== null) {
81
+ lines.push(` Unmapped: ${percent(window.unmapped.share)} of sessions (${window.unmapped.sessions}) were on pages no route names; whether they`
82
+ + ' reach this change is unknown, not zero.');
83
+ }
84
+ if (window.releasesWithoutManifest.length) {
85
+ lines.push(` Releases whose route list Haystack does not hold (their pages are unmapped): ${window.releasesWithoutManifest.map(plain).join(', ')}`);
86
+ }
87
+ const dropped = Object.entries(window.dropped).filter(([, count]) => count > 0);
88
+ if (dropped.length)
89
+ lines.push(` Events dropped at ingest: ${dropped.map(([reason, count]) => `${plain(reason)} ${count}`).join(', ')}.`);
90
+ lines.push(' Shares are of captured (consenting) sessions only; how many visitors did not consent is unknowable and not claimed.');
91
+ return lines.join('\n');
92
+ }