@haystackeditor/cli 0.24.1 → 0.25.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 (49) 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 +127 -0
  6. package/dist/capture/adapters/files.js +77 -0
  7. package/dist/capture/adapters/index.js +64 -0
  8. package/dist/capture/adapters/jsx-edit.js +81 -0
  9. package/dist/capture/adapters/next.js +327 -0
  10. package/dist/capture/adapters/nuxt.js +192 -0
  11. package/dist/capture/adapters/rails.js +171 -0
  12. package/dist/capture/adapters/react-router.js +432 -0
  13. package/dist/capture/adapters/sveltekit.js +102 -0
  14. package/dist/capture/adapters/types.js +4 -0
  15. package/dist/capture/adapters/vite.js +121 -0
  16. package/dist/capture/app-config.js +106 -0
  17. package/dist/capture/consent.js +127 -0
  18. package/dist/capture/csp.js +331 -0
  19. package/dist/capture/html.js +74 -0
  20. package/dist/capture/js-ast.js +400 -0
  21. package/dist/capture/manifest.js +95 -0
  22. package/dist/capture/project.js +177 -0
  23. package/dist/capture/route-pattern.js +119 -0
  24. package/dist/capture/script-release.js +47 -0
  25. package/dist/capture/tag.js +74 -0
  26. package/dist/capture-step.js +53 -0
  27. package/dist/commands/capture-brief.js +92 -0
  28. package/dist/commands/capture-contract.js +46 -0
  29. package/dist/commands/capture-manifest.js +78 -0
  30. package/dist/commands/init-capture.js +409 -0
  31. package/dist/commands/init-telemetry.js +1011 -0
  32. package/dist/commands/init.js +68 -5
  33. package/dist/commands/server-telemetry-contract.d.ts +66 -0
  34. package/dist/commands/server-telemetry-contract.js +127 -0
  35. package/dist/commands/telemetry-token.js +238 -0
  36. package/dist/commands/telemetry.d.ts +161 -8
  37. package/dist/commands/telemetry.js +940 -158
  38. package/dist/commands/verify-onboarding.js +5 -1
  39. package/dist/commands/verify.js +54 -7
  40. package/dist/index.js +83 -6
  41. package/dist/schema.js +2 -2
  42. package/dist/telemetry/next-loader.cjs +66 -9
  43. package/dist/telemetry/next.d.ts +11 -3
  44. package/dist/telemetry/next.js +95 -15
  45. package/dist/telemetry/typed-source.d.ts +47 -0
  46. package/dist/telemetry/typed-source.js +379 -0
  47. package/package.json +4 -2
  48. package/schemas/init.v1.json +63 -4
  49. 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,53 @@
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
+ }
46
+ }
47
+ catch { /* the command did not answer: no manifest was written */ }
48
+ if (!written) {
49
+ const removed = withdrawCurrent();
50
+ console.warn(`haystack capture manifest did not write a route manifest${removed > 0 ? ', so the previous current.json was removed' : ''};`
51
+ + ' the build goes on.');
52
+ }
53
+ 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
+ }
@@ -0,0 +1,46 @@
1
+ /** FIXED CONTRACT — mirror of CAPTURE-V1 (infra/lambda/haystack-design-verifier-shared/capture_contracts.ts, rule 7's
2
+ * capture_rollup_contracts.ts, and the server-telemetry routes of telemetry_token_contracts.ts) for the published CLI, whose
3
+ * build cannot import repository files outside src. Only the shapes the CLI reads or sends are mirrored, and
4
+ * captureChangedRoutes is the source's own join; the source files win. */
5
+ export const CAPTURE_VERSION = 'capture-v1';
6
+ export const CAPTURE_KEY_PATTERN = /^hc_[a-z2-7]{26}$/;
7
+ export const CAPTURE_ORIGIN = 'https://c.haystack.sh';
8
+ export const CAPTURE_SCRIPT_MAX_BYTES = 8 * 1024;
9
+ /** The longest a route id may be, in characters. */
10
+ export const CAPTURE_ROUTE_ID_MAX = 200;
11
+ /** Rule 4 Publishing: the largest route manifest (current.json and <release>.json), in bytes. */
12
+ export const CAPTURE_MANIFEST_MAX_BYTES = 256 * 1024;
13
+ export const CAPTURE_WINDOW_PATH = '/api/agent/cloud-verifier/capture/window';
14
+ /** Rule 8(e): telemetry older than this is reported stale; rule 7b: a capture window older than this is stale. */
15
+ export const CAPTURE_EVIDENCE_FRESH_HOURS = 24;
16
+ export const CAPTURE_ROUTE_UNKNOWN = 'unknown';
17
+ export const CAPTURE_APPLICATION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u;
18
+ const share = (part, whole) => whole > 0 ? part / whole : null;
19
+ /** Rule 7b: the window's routes whose source entry is one of `files` (repository paths, compared exactly). */
20
+ export function captureChangedRoutes(window, files, now) {
21
+ const wanted = new Set(files);
22
+ const index = new Map(window.routes.map((row, at) => [row.route, at]));
23
+ const mapped = window.manifest.routes.filter(route => wanted.has(route.source));
24
+ const routes = mapped.map(route => {
25
+ const row = window.routes[index.get(route.id) ?? -1];
26
+ const sessions = row?.sessions ?? 0;
27
+ return { route: route.id, match: route.match, source: route.source, sessions, share: share(sessions, window.sessions) };
28
+ }).sort((left, right) => right.sessions - left.sessions || left.route.localeCompare(right.route));
29
+ const changed = new Set(mapped.flatMap(route => index.has(route.id) ? [index.get(route.id)] : []));
30
+ const union = window.routeSets.reduce((sum, set) => set.routes.some(at => changed.has(at)) ? sum + set.sessions : sum, 0);
31
+ const unknown = window.routes[index.get(CAPTURE_ROUTE_UNKNOWN) ?? -1];
32
+ const ageHours = window.newestHour === null ? null
33
+ : Math.max(0, (now.getTime() - (Date.parse(`${window.newestHour}:00:00Z`) + 3_600_000)) / 3_600_000);
34
+ return { applicationId: window.applicationId, days: window.days, newestHour: window.newestHour, ageHours,
35
+ stale: ageHours === null || ageHours > CAPTURE_EVIDENCE_FRESH_HOURS, sessions: window.sessions, routes,
36
+ union: { sessions: union, share: share(union, window.sessions) },
37
+ unmapped: unknown ? { sessions: unknown.sessions, share: share(unknown.sessions, window.sessions) } : null,
38
+ releasesWithoutManifest: window.manifest.missing, dropped: window.dropped };
39
+ }
40
+ export const TELEMETRY_TOKEN_VERSION = 'telemetry-token-v1';
41
+ export const TELEMETRY_ENVIRONMENT = 'production';
42
+ export const TELEMETRY_TOKEN_PATH = '/api/agent/cloud-verifier/telemetry/token';
43
+ export const TELEMETRY_SERVER_STATUS_PATH = '/api/agent/cloud-verifier/telemetry/server';
44
+ export const TELEMETRY_TOKEN_SHA256 = /^[0-9a-f]{64}$/;
45
+ /** Lane A's capture status: GET /api/agent/cloud-verifier/capture/status?repository=&app= (rule 8b's key registration). */
46
+ export const CAPTURE_STATUS_PATH = '/api/agent/cloud-verifier/capture/status';
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `haystack capture manifest` — the build step init adds (CAPTURE-V1 rule 4 Publishing): reads the app's identity from
3
+ * .haystack/capture.json, lists its routes and its build's chunks through the same framework adapter init chose, and
4
+ * writes the route manifest into the app's own static output as /.well-known/haystack-capture/<release>.json and
5
+ * current.json. No credential is involved: whoever deploys the app publishes its manifest.
6
+ *
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
9
+ * 0, because a missing manifest only stops the tag on this release (rule 13a: no manifest, no work). It needs no git
10
+ * checkout: repository paths come from the app path init recorded.
11
+ */
12
+ import chalk from 'chalk';
13
+ import { relative, resolve, sep } from 'node:path';
14
+ import { detectApp } from '../capture/adapters/index.js';
15
+ import { isManual } from '../capture/adapters/types.js';
16
+ import { CAPTURE_CONFIG_PATH, readCaptureConfig } from '../capture/app-config.js';
17
+ import { loadBabel } from '../capture/js-ast.js';
18
+ import { buildManifest, exactRoutes, withdrawCurrent, writeManifest } from '../capture/manifest.js';
19
+ import { appContext } from '../capture/project.js';
20
+ export async function captureManifestCommand(options) {
21
+ // Where this app publishes, once known: a failure withdraws the current.json there, so a build never ships the manifest
22
+ // of another release (pre-build publication into public/ or static/ would otherwise keep the last one).
23
+ const locations = [];
24
+ try {
25
+ await publish(options, locations);
26
+ }
27
+ catch (error) {
28
+ let withdrawn = [];
29
+ try {
30
+ withdrawn = withdrawCurrent(locations);
31
+ }
32
+ catch { /* the warning below still says no manifest was written */ }
33
+ const reason = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').trim().replace(/\.$/, '');
34
+ if (options.json)
35
+ process.stdout.write(`${JSON.stringify({ written: false, reason, withdrawn }, null, 2)}\n`);
36
+ console.warn(`haystack capture manifest: no route manifest written (${reason})${withdrawn.length > 0 ? ' and the previous current.json removed' : ''};`
37
+ + ' the build goes on, and the capture tag records nothing on this release.');
38
+ process.exitCode = 0;
39
+ }
40
+ }
41
+ async function publish(options, locations) {
42
+ const appDir = resolve(options.app ?? '.');
43
+ const config = readCaptureConfig(appDir);
44
+ if (!config)
45
+ throw new Error(`${appDir} has no ${CAPTURE_CONFIG_PATH}: run \`haystack init\` in the repository to set capture up.`);
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.`);
49
+ // The checkout's root when the app sits at its recorded path; else (a build that sees only the app) the app itself.
50
+ const suffix = config.app === '.' ? '' : config.app.split('/').join(sep);
51
+ const repoRoot = suffix !== '' && appDir.endsWith(`${sep}${suffix}`) ? appDir.slice(0, -suffix.length - 1) : appDir;
52
+ await loadBabel();
53
+ 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);
65
+ if (isManual(detected.publishDirs))
66
+ throw new Error(detected.publishDirs.manual);
67
+ const manifest = buildManifest(config, detected.manifestAdapter, routes, detected.chunks());
68
+ const written = writeManifest(detected.publishDirs.map(dir => resolve(appDir, dir)), manifest);
69
+ if (options.json) {
70
+ 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`);
72
+ return;
73
+ }
74
+ const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
75
+ console.log(chalk.green(`Haystack route manifest ${manifest.release}: ${count(manifest.routes.length, 'route')}, ${count(manifest.chunks.length, 'chunk')} (${detected.label})`));
76
+ for (const file of written)
77
+ console.log(chalk.dim(` ${relative(appDir, file)}`));
78
+ }