@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,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,86 @@
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 production dependency. 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 { ADAPTER_IDS, detectApp, detectWith } 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 (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('|')}>`);
53
+ // The checkout's root when the app sits at its recorded path; else (a build that sees only the app) the app itself.
54
+ const suffix = config.app === '.' ? '' : config.app.split('/').join(sep);
55
+ const repoRoot = suffix !== '' && appDir.endsWith(`${sep}${suffix}`) ? appDir.slice(0, -suffix.length - 1) : appDir;
56
+ await loadBabel();
57
+ const ctx = appContext(repoRoot, appDir, config.app);
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}`);
64
+ if (isManual(detected.publishDirs))
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);
72
+ const manifest = buildManifest(config, detected.manifestAdapter, routes, detected.chunks());
73
+ const written = writeManifest(publishDirs, manifest);
74
+ const notes = detected.buildNotes();
75
+ if (options.json) {
76
+ process.stdout.write(`${JSON.stringify({ written: true, release: manifest.release, routes: manifest.routes.length, chunks: manifest.chunks.length,
77
+ files: written.map(file => relative(appDir, file)), notes }, null, 2)}\n`);
78
+ return;
79
+ }
80
+ const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
81
+ console.log(chalk.green(`Haystack route manifest ${manifest.release}: ${count(manifest.routes.length, 'route')}, ${count(manifest.chunks.length, 'chunk')} (${detected.label})`));
82
+ for (const file of written)
83
+ console.log(chalk.dim(` ${relative(appDir, file)}`));
84
+ for (const note of notes)
85
+ console.warn(chalk.yellow(`haystack capture manifest: ${note}.`));
86
+ }
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Browser capture's part of `haystack init` (CAPTURE-V1 rules 8a, 8b with 1, 2, 4 and 13a): the tag, its consent wiring,
3
+ * the build step that publishes the route manifest, the app's registration, and the CSP report.
4
+ *
5
+ * It is planned inside the telemetry plan (init-telemetry.ts), which chooses the app (rule 8a), asks for answers that are
6
+ * missing, chains capture's package.json build step with the server part's edits, and pins the CLI once, as a production
7
+ * dependency, for every part that runs it. Every change here is an InitChange, shown first and made with the rest only
8
+ * with --yes, all or nothing.
9
+ *
10
+ * Init never guesses (rule 8): the production origins and the consent answer are the user's (--origin, --consent), kept in
11
+ * .haystack/capture.json so a rerun (a newer script version) needs no flags; a framework, version, layout or config init
12
+ * cannot read is a manual step with the exact tag, manifest command and CSP lines and where init looked. Registration
13
+ * (POST /api/agent/cloud-verifier/capture/key) is an action the preview announces: until it runs (after --yes, before any
14
+ * write) the diffs show the key as a placeholder, and init plans again with the key it issues.
15
+ *
16
+ * Do no harm (rule 13a) in what init writes: the script is self-hosted, async (Next.js: lazyOnload) and pinned by
17
+ * integrity; consent integrations are static files that never throw; the build step is the pinned CLI's
18
+ * `haystack-capture-step`, joined only to a build script of plain `&&`-chained commands, which exits 0 whatever happens to
19
+ * the manifest or the CLI; init never edits a Content-Security-Policy. A newer script version
20
+ * replaces the old file (deleted) and tag.
21
+ */
22
+ import chalk from 'chalk';
23
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
24
+ import { isAbsolute, join, posix, relative, sep } from 'node:path';
25
+ import { detectApp } from '../capture/adapters/index.js';
26
+ import { RAKE_FILE, RAKE_FILE_CONTENT } from '../capture/adapters/rails.js';
27
+ import { isManual } from '../capture/adapters/types.js';
28
+ import { captureConfigFile, formatCaptureConfig, parseOrigins, readCaptureConfig, readCaptureRegistration, registerCaptureApp } from '../capture/app-config.js';
29
+ import { consentLabel, consentScript, formatConsentChoice, manualConsentStep, parseConsentChoice } from '../capture/consent.js';
30
+ import { findCsp } from '../capture/csp.js';
31
+ import { applyEdits, keyName, loadBabel, parseJsonLike } from '../capture/js-ast.js';
32
+ import { buildManifest, exactRoutes, manifestDocument, MANIFEST_DIR } from '../capture/manifest.js';
33
+ import { appContext } from '../capture/project.js';
34
+ import { parseUrlRewrites, URL_REWRITES_QUESTION } from '../capture/url-rewrites.js';
35
+ import { loadCaptureScript } from '../capture/script-release.js';
36
+ import { STATIC_SUBDIR } from '../capture/tag.js';
37
+ import { CAPTURE_ORIGIN, CAPTURE_VERSION } from './capture-contract.js';
38
+ import { HaystackApiError } from '../utils/haystack-api.js';
39
+ import { lineDiff } from './init.js';
40
+ /** Shown in place of the key until registration issues it (with --yes). */
41
+ const KEY_PLACEHOLDER = 'hc_(issued-when-init-runs-with---yes)';
42
+ const ID_PLACEHOLDER = '(issued-when-init-runs-with---yes)';
43
+ /** The build step: the pinned CLI's dependency-free step bin (src/capture-step.ts), which runs the manifest command and
44
+ * always exits 0, so neither the manifest nor the CLI failing to start can fail the build (rule 13). */
45
+ const BUILD_COMMAND = 'haystack-capture-step';
46
+ const EARLIER_BUILD_COMMAND = 'haystack capture manifest --app .';
47
+ /** A build script init composes with: commands joined by `&&` and nothing else, of plain words. Any other shell syntax (`;`,
48
+ * `||`, `|`, `&`, quotes, redirects, variables, subshells, comments) could change what running a command after it means,
49
+ * so it is a manual step. */
50
+ const COMPOSABLE_SCRIPT = /^[A-Za-z0-9_./=:@\s&-]+$/;
51
+ const STAGED_DIR = `.haystack/${STATIC_SUBDIR}`;
52
+ /** The answers in effect: the flags, else the ones init recorded in .haystack/capture.json (rule 8b: the user's answers,
53
+ * never inferred, so a rerun that only updates the script needs none). */
54
+ export function captureAnswers(appDir, origins, consent, urlRewrites) {
55
+ const recorded = readCaptureConfig(appDir);
56
+ return { origins: origins.length > 0 ? origins : recorded?.origins ?? [], consent: consent ?? recorded?.consent,
57
+ urlRewrites: urlRewrites ?? recorded?.urlRewrites ?? undefined };
58
+ }
59
+ function readText(path) {
60
+ try {
61
+ return readFileSync(path, 'utf8');
62
+ }
63
+ catch (error) {
64
+ if (error.code === 'ENOENT')
65
+ return null;
66
+ throw error;
67
+ }
68
+ }
69
+ class Planner {
70
+ gitRoot;
71
+ changes = [];
72
+ constructor(gitRoot) {
73
+ this.gitRoot = gitRoot;
74
+ }
75
+ text(target, content, reason) {
76
+ const before = readText(target);
77
+ if (before === content)
78
+ return;
79
+ this.changes.push({ path: this.display(target), action: before === null ? 'create' : 'update', reason,
80
+ diff: lineDiff(before ?? '', content), target, content });
81
+ }
82
+ /** A file whose content is not worth reading in a diff (the minified tag): its size and integrity instead. */
83
+ summarized(target, content, reason, summary) {
84
+ const before = readText(target);
85
+ if (before === content)
86
+ return;
87
+ this.changes.push({ path: this.display(target), action: before === null ? 'create' : 'update', reason, diff: [`+${summary}`], target, content });
88
+ }
89
+ remove(target, reason) {
90
+ this.changes.push({ path: this.display(target), action: 'delete', reason, diff: ['-(the whole file)'], target, content: '' });
91
+ }
92
+ display(target) {
93
+ const inside = relative(this.gitRoot, target);
94
+ return inside.startsWith('..') || isAbsolute(inside) ? target : inside.split(sep).join('/');
95
+ }
96
+ }
97
+ /** package.json with the manifest command joined to `script`: after it or before it, once. Edited at the script's string
98
+ * node, so the rest of the file keeps its formatting. */
99
+ function withBuildStep(text, file, script, phase) {
100
+ const ast = parseJsonLike(text, file);
101
+ const scripts = ast?.type === 'ObjectExpression'
102
+ ? ast.properties.find((property) => property.type === 'ObjectProperty' && keyName(property) === 'scripts')?.value : null;
103
+ const entry = scripts?.type === 'ObjectExpression'
104
+ ? scripts.properties.find((property) => property.type === 'ObjectProperty' && keyName(property) === script) : null;
105
+ if (!entry || entry.value.type !== 'StringLiteral')
106
+ return { manual: `package.json has no ${script} script init can read` };
107
+ const command = entry.value.value;
108
+ if (command.split('&&').some(segment => segment.trim() === BUILD_COMMAND))
109
+ return text;
110
+ // An earlier build step (the manifest command itself, which a CLI that cannot start would let fail the build) is
111
+ // replaced by the step in place.
112
+ if (command.split('&&').some(segment => segment.trim() === EARLIER_BUILD_COMMAND)) {
113
+ const replaced = command.split('&&').map(segment => (segment.trim() === EARLIER_BUILD_COMMAND ? segment.replace(EARLIER_BUILD_COMMAND, BUILD_COMMAND) : segment)).join('&&');
114
+ return applyEdits(text, [{ start: entry.value.start, end: entry.value.end, text: JSON.stringify(replaced) }]);
115
+ }
116
+ const segments = command.split('&&').map(segment => segment.trim());
117
+ if (!COMPOSABLE_SCRIPT.test(command) || segments.some(segment => segment === '' || segment.includes('&'))) {
118
+ return { manual: `its ${script} script (\`${command}\`) uses shell syntax init does not compose with; add \`${BUILD_COMMAND}\` to run ${phase} it` };
119
+ }
120
+ const next = phase === 'after' ? `${command} && ${BUILD_COMMAND}` : `${BUILD_COMMAND} && ${command}`;
121
+ return applyEdits(text, [{ start: entry.value.start, end: entry.value.end, text: JSON.stringify(next) }]);
122
+ }
123
+ function scriptFiles(dir) {
124
+ try {
125
+ return readdirSync(dir).filter(file => /^(capture\.[0-9a-z]+|consent-[a-z]+\.[0-9a-f]+)\.js$/.test(file));
126
+ }
127
+ catch {
128
+ return [];
129
+ }
130
+ }
131
+ function formatUrlRewrites(rewrites) {
132
+ return rewrites.kind === 'none' ? 'none' : `hidden-segment:${rewrites.name}`;
133
+ }
134
+ function manifestCommand(appPath) {
135
+ return `haystack capture manifest --app ${appPath}`;
136
+ }
137
+ function cspFallbackLine() {
138
+ return `connect-src 'self' ${CAPTURE_ORIGIN}; script-src 'self'`;
139
+ }
140
+ function part(steps) {
141
+ return steps.length > 0 ? { status: 'manual', steps, received: null } : { status: 'installed', received: null };
142
+ }
143
+ /** Everything capture would change for this app with these answers; reads only. Throws on answers that are not ones
144
+ * (an origin that is not https, an unknown consent form), so init stops before anything changes. `registration` is the
145
+ * registration's outcome when init plans again after it. */
146
+ export async function planCapture(input) {
147
+ const { gitRoot, appDir, repository } = input;
148
+ const origins = parseOrigins(input.answers.origins);
149
+ if (input.answers.consent === undefined)
150
+ throw new Error('planCapture needs the consent answer.');
151
+ const consent = parseConsentChoice(input.answers.consent);
152
+ const urlRewrites = input.answers.urlRewrites === undefined ? null : formatUrlRewrites(parseUrlRewrites(input.answers.urlRewrites));
153
+ const plan = { changes: [], packageText: null, needsCli: false, registration: null, notices: [], part: part([]) };
154
+ if (input.registration && 'failed' in input.registration) {
155
+ plan.part = part([`Run \`haystack init\` again: registering the app for capture failed (${input.registration.failed}).`]);
156
+ return plan;
157
+ }
158
+ const script = loadCaptureScript();
159
+ if (!script) {
160
+ plan.part = { status: 'unsupported', why: 'This version of the Haystack CLI carries no capture script yet; update @haystackeditor/cli and run `haystack init` again.' };
161
+ return plan;
162
+ }
163
+ await loadBabel();
164
+ const ctx = appContext(gitRoot, appDir);
165
+ const existing = readCaptureConfig(appDir);
166
+ const detected = await detectApp(ctx);
167
+ const registered = input.registration && 'registered' in input.registration ? input.registration.registered : null;
168
+ const recorded = existing !== null && existing.app === ctx.appPath
169
+ ? { key: existing.key, repositoryId: existing.repositoryId, applicationId: existing.applicationId } : null;
170
+ plan.registration = { repository, app: ctx.appPath, origins, recorded };
171
+ // The key the files carry: the one registration just issued, else the recorded one (prepareCapture checks it against
172
+ // Haystack's before anything is written, and plans again when it differs), else a placeholder until registration.
173
+ const identity = registered ?? recorded;
174
+ if (!registered && (existing === null || existing.origins.join(',') !== origins.join(','))) {
175
+ plan.notices.push(registrationNotice(plan.registration));
176
+ }
177
+ const consentFile = consentScript(consent);
178
+ const tags = {
179
+ script: { file: script.file, integrity: script.integrity },
180
+ key: identity?.key ?? KEY_PLACEHOLDER,
181
+ consentNotRequired: consent.kind === 'not-required',
182
+ consent: consentFile ? { file: consentFile.file, integrity: consentFile.integrity } : null,
183
+ };
184
+ const steps = [];
185
+ const planner = new Planner(gitRoot);
186
+ const config = {
187
+ version: CAPTURE_VERSION,
188
+ key: tags.key,
189
+ repositoryId: identity?.repositoryId ?? ID_PLACEHOLDER,
190
+ applicationId: identity?.applicationId ?? ID_PLACEHOLDER,
191
+ // The adapter init recognized, whatever it left manual: the manifest command uses it once the cause is fixed.
192
+ adapter: detected ? detected.id : null,
193
+ app: ctx.appPath,
194
+ origins,
195
+ consent: formatConsentChoice(consent),
196
+ // Where a build step publishes (none for a static site, whose manifest init writes and the repository keeps).
197
+ publish: detected && !isManual(detected.publishDirs) && !isManual(detected.build) && detected.build.kind !== 'init-writes'
198
+ ? detected.publishDirs : [],
199
+ urlRewrites,
200
+ };
201
+ planner.text(captureConfigFile(appDir), formatCaptureConfig(config), 'The app\'s capture identity, committed for the build step that publishes its route manifest (CAPTURE-V1 rule 8b).');
202
+ const consentStep = manualConsentStep(consent);
203
+ if (!detected) {
204
+ plan.part = { status: 'unsupported', why: `init recognized no web framework at ${ctx.appPath} (it looked for package.json, Gemfile,`
205
+ + ' manage.py and index.html there), and the tag records nothing without the route manifest only a recognized framework\'s routes'
206
+ + ' give; run `haystack init --app <dir>` with the app\'s directory' };
207
+ plan.changes = planner.changes;
208
+ return plan;
209
+ }
210
+ // A manifest that cannot be made exactly (a base path, a config init cannot read) leaves nothing worth placing: a tag
211
+ // without a manifest records nothing. Two steps that work: remove the cause, run init again (the recorded answers carry
212
+ // over, and the manifest command checks the cause on every build).
213
+ if (detected.manifestBlocked) {
214
+ steps.push(`Remove what stops ${detected.label}'s route manifest: ${detected.manifestBlocked} (init looked at ${detected.looked.join(', ')}).`);
215
+ steps.push('Then run `haystack init` again: it places the tag, the files and the build step that publishes the manifest itself.');
216
+ plan.changes = planner.changes;
217
+ plan.part = part(steps);
218
+ return plan;
219
+ }
220
+ const staticDir = typeof detected.staticDir === 'string' ? detected.staticDir : null;
221
+ // The files: in the app's static output, or staged for a manual step.
222
+ const filesDir = staticDir === null ? join(appDir, STAGED_DIR) : join(appDir, staticDir, STATIC_SUBDIR);
223
+ const wanted = new Set([script.file, ...(consentFile ? [consentFile.file] : [])]);
224
+ planner.summarized(join(filesDir, script.file), script.text, 'The capture script, self-hosted so Haystack\'s availability never affects how the app loads (CAPTURE-V1 rules 1, 13).', `(the capture script, ${script.bytes} bytes, ${script.integrity})`);
225
+ if (consentFile) {
226
+ planner.text(join(filesDir, consentFile.file), consentFile.bytes.toString('utf8'), `Applies the visitor's consent as ${consentLabel(consent)} records it to the tag (rule 2).`);
227
+ }
228
+ for (const old of scriptFiles(filesDir))
229
+ if (!wanted.has(old))
230
+ planner.remove(join(filesDir, old), 'An earlier capture file, replaced by this version.');
231
+ const servedAt = `/${STATIC_SUBDIR}/`;
232
+ // The tag.
233
+ const integration = detected.blocked ? { manual: detected.blocked } : await detected.integrate(tags);
234
+ if (isManual(integration)) {
235
+ steps.push(`Add the tag yourself (${integration.manual}; init looked at ${detected.looked.join(', ')}):\n${detected.manualTag(tags).map(line => ` ${line}`).join('\n')}`);
236
+ }
237
+ else {
238
+ for (const edit of integration)
239
+ planner.text(edit.target, edit.content, 'Adds the capture tag, async and pinned by its integrity, exactly once (CAPTURE-V1 rules 1, 13a).');
240
+ }
241
+ if (typeof detected.staticDir !== 'string')
242
+ steps.push(`Serve ${posix.join(ctx.appPath, STAGED_DIR)} at ${servedAt}: ${detected.staticDir.manual}.`);
243
+ // The build step and where the manifest goes.
244
+ await planBuild(plan, planner, steps, ctx, detected, config);
245
+ if (detected.publishNote)
246
+ plan.notices.push(`Capture: ${detected.publishNote}`);
247
+ // A middleware or proxy that may rewrite page URLs: the routes are exact only with the user's answer.
248
+ if (detected.rewriting && urlRewrites === null)
249
+ steps.push(`Answer one question about URL rewrites: ${detected.rewriting}. ${URL_REWRITES_QUESTION}`);
250
+ // Routes now, so a router init cannot read is a step today rather than a manifest missing after the next build; an
251
+ // adapter whose routes come from the build reads them there.
252
+ if (detected.fromBuild) {
253
+ plan.notices.push(`Capture: the routes come from each production build (${detected.label}), read after it by the build step.`);
254
+ }
255
+ else if (!isManual(detected.build) && detected.build.kind !== 'rake') {
256
+ const routes = exactRoutes(await detected.routes({}));
257
+ if (isManual(routes))
258
+ steps.push(`Make the routes readable or publish the manifest yourself: ${routes.manual}.`);
259
+ else {
260
+ const count = new Set(routes.map(route => route.match)).size;
261
+ plan.notices.push(`Capture: ${count} ${count === 1 ? 'route' : 'routes'} for the route manifest (${detected.label}).`);
262
+ }
263
+ }
264
+ // CSP: report, never edit.
265
+ const csp = findCsp(gitRoot, appDir, detected.id === 'next', origins);
266
+ if (csp.findings.length === 0) {
267
+ plan.notices.push(`Capture: no Content-Security-Policy found in ${csp.looked}. If production sets one elsewhere (a CDN or proxy), it needs: ${cspFallbackLine()}.`);
268
+ }
269
+ for (const finding of csp.findings) {
270
+ for (const note of finding.notes)
271
+ plan.notices.push(`Capture: ${finding.where}: ${note}`);
272
+ if (finding.additions.length > 0) {
273
+ steps.push(`Update the Content-Security-Policy at ${finding.where} (init never edits a policy):\n${finding.additions.map(line => ` ${line}`).join('\n')}`);
274
+ }
275
+ }
276
+ if (consentStep)
277
+ steps.push(consentStep);
278
+ plan.changes = planner.changes;
279
+ plan.part = part(steps);
280
+ return plan;
281
+ }
282
+ async function planBuild(plan, planner, steps, ctx, detected, config) {
283
+ const build = detected.build;
284
+ const publishDirs = detected.publishDirs;
285
+ if (isManual(publishDirs)) {
286
+ steps.push(publishDirs.manual);
287
+ return;
288
+ }
289
+ if (isManual(build)) {
290
+ steps.push(`Run \`${manifestCommand(ctx.appPath)}\` after each build (${build.manual}).`);
291
+ return;
292
+ }
293
+ const manifestFile = join(ctx.appDir, 'package.json');
294
+ if (build.kind === 'package-script') {
295
+ const text = readFileSync(manifestFile, 'utf8');
296
+ const edited = withBuildStep(text, manifestFile, build.script, build.phase);
297
+ if (typeof edited !== 'string') {
298
+ steps.push(`Publish the route manifest with each build: ${edited.manual}, with @haystackeditor/cli as an exact-version production dependency.`);
299
+ return;
300
+ }
301
+ plan.packageText = edited === text ? null : edited;
302
+ plan.needsCli = true;
303
+ }
304
+ else if (build.kind === 'rake') {
305
+ if (!existsSync(manifestFile)) {
306
+ steps.push('Add @haystackeditor/cli to the app as an exact-version dependency (the app has no package.json, so its build has no pinned'
307
+ + ' CLI to publish the route manifest with), then run `haystack init` again.');
308
+ return;
309
+ }
310
+ plan.needsCli = true;
311
+ planner.text(join(ctx.appDir, RAKE_FILE), RAKE_FILE_CONTENT, 'Publishes the route manifest after assets:precompile from the routes Rails lists, with the CLI the app pins, never failing the build (rules 4, 13).');
312
+ }
313
+ else {
314
+ // A static site: init writes the manifest itself (no build).
315
+ plan.notices.push('Capture: this site has no build, so init writes its route manifest, and `haystack init` writes it again after its pages change.');
316
+ await planStaticManifest(planner, ctx, detected, config, publishDirs);
317
+ return;
318
+ }
319
+ // A manifest written into a source directory is a build product: keep it out of commits (once: init's own line).
320
+ if (typeof detected.staticDir === 'string' && publishDirs.includes(detected.staticDir)) {
321
+ const line = `/${posix.join(detected.staticDir === '.' ? '' : detected.staticDir, MANIFEST_DIR)}/`;
322
+ const file = join(ctx.appDir, '.gitignore');
323
+ const before = readText(file) ?? '';
324
+ if (!before.split('\n').includes(line)) {
325
+ planner.text(file, `${before}${before === '' || before.endsWith('\n') ? '' : '\n'}# Haystack's route manifest, written by each build\n${line}\n`, 'Keeps the route manifest each build writes out of commits.');
326
+ }
327
+ }
328
+ }
329
+ async function planStaticManifest(planner, ctx, detected, config, publishDirs) {
330
+ const routes = exactRoutes(await detected.routes({}));
331
+ if (isManual(routes))
332
+ return;
333
+ const manifest = buildManifest(config, detected.manifestAdapter, routes, detected.chunks());
334
+ const body = manifestDocument(manifest);
335
+ for (const dir of publishDirs) {
336
+ for (const name of [`${manifest.release}.json`, 'current.json']) {
337
+ planner.text(join(ctx.appDir, dir, MANIFEST_DIR, name), body, 'The route manifest (CAPTURE-V1 rule 4), published with the site.');
338
+ }
339
+ }
340
+ }
341
+ function appName(app) {
342
+ return app === '.' ? 'the app at the repository root' : app;
343
+ }
344
+ /** The preview's line for the registration init checks with --yes. */
345
+ function registrationNotice(request) {
346
+ return `Capture: with --yes, init checks the registration of ${appName(request.app)} of ${request.repository} with Haystack (production`
347
+ + ` origins ${request.origins.join(', ')}): ${request.recorded ? 'the origins are updated after every change below is written'
348
+ : 'the app is registered before anything is written, and the key it issues replaces the placeholder in the diffs'}.`;
349
+ }
350
+ const errorText = (error) => (error instanceof Error ? error.message : String(error));
351
+ const nothing = async () => null;
352
+ /**
353
+ * The registration side of capture with --yes (rule 8b), so Haystack never accepts origins the checkout does not carry:
354
+ * - the recorded key is Haystack's (stable for the app): nothing is sent before the writes; the origins are updated
355
+ * after every write succeeded (a failed update is a step, and the next run compares with Haystack again);
356
+ * - no recorded key, or another one: the app is registered before anything is written (the files need its key), and a
357
+ * write failing afterwards puts the previous origins back (a new app's registration stays: it accepts nothing the
358
+ * checkout has not deployed).
359
+ * `approved`: the user said yes to changes; without it nothing is sent, and a needed update is a step.
360
+ */
361
+ export async function prepareCapture(request, token, say, approved) {
362
+ const app = appName(request.app);
363
+ let current;
364
+ try {
365
+ current = await readCaptureRegistration(token, request.repository, request.app);
366
+ }
367
+ catch (error) {
368
+ // Unreachable (no answer, a server error, a rate limit): the recorded key still names the app, so the local updates go
369
+ // ahead with it and only the origins check waits. Refused (any other answer), or no key to go on: capture waits.
370
+ const unreachable = !(error instanceof HaystackApiError) || error.status >= 500 || error.status === 429;
371
+ if (unreachable && request.recorded) {
372
+ say(chalk.yellow(`Haystack could not be reached to check ${app}'s capture registration (${errorText(error)}); the recorded key stays.`));
373
+ return { replan: null, rollback: async () => { }, finish: async () => `Run \`haystack init --yes\` again: Haystack could not be reached to check ${app}'s production origins for capture (${errorText(error)}).` };
374
+ }
375
+ say(chalk.yellow(`Browser capture is not set up this time: reading ${app}'s registration failed (${errorText(error)}).`));
376
+ return { replan: { failed: `reading the app's registration failed (${errorText(error)})` }, finish: nothing, rollback: async () => { } };
377
+ }
378
+ const sameOrigins = current !== null && current.origins.join(',') === request.origins.join(',');
379
+ const register = async (origins) => registerCaptureApp(token, request.repository, request.app, origins);
380
+ if (request.recorded && current && current.key === request.recorded.key) {
381
+ return { replan: null, rollback: async () => { }, finish: async (finished) => {
382
+ if (sameOrigins)
383
+ return null;
384
+ if (!approved)
385
+ return `Run \`haystack init --yes\`: Haystack accepts capture from ${current.origins.join(', ') || 'no origin'}, not ${request.origins.join(', ')}.`;
386
+ try {
387
+ await register(request.origins);
388
+ finished(chalk.green(`✓ Updated ${app}'s production origins for browser capture`));
389
+ return null;
390
+ }
391
+ catch (error) {
392
+ return `Run \`haystack init --yes\` again: updating ${app}'s production origins for capture failed (${errorText(error)}).`;
393
+ }
394
+ } };
395
+ }
396
+ if (!approved) {
397
+ return { replan: { failed: 'registering the app needs your yes (`haystack init --yes`)' }, finish: nothing, rollback: async () => { } };
398
+ }
399
+ let registered;
400
+ try {
401
+ registered = await register(request.origins);
402
+ }
403
+ catch (error) {
404
+ say(chalk.yellow(`Browser capture is not set up this time: registering ${app} failed (${errorText(error)}).`));
405
+ return { replan: { failed: errorText(error) }, finish: nothing, rollback: async () => { } };
406
+ }
407
+ say(chalk.green(`✓ Registered ${app} for browser capture`));
408
+ const previous = current !== null && !sameOrigins ? current.origins : null;
409
+ return { replan: { registered }, finish: nothing, rollback: async (undo) => {
410
+ if (previous === null)
411
+ return;
412
+ try {
413
+ await register(previous);
414
+ undo(chalk.yellow(`Restored ${app}'s previous production origins for browser capture.`));
415
+ }
416
+ catch (error) {
417
+ undo(chalk.red(`Could not restore ${app}'s previous production origins for browser capture (${errorText(error)}); run \`haystack init --yes\` again.`));
418
+ }
419
+ } };
420
+ }
421
+ /** The capture part with one more step (an update that could not be made after the writes). */
422
+ export function withCaptureStep(part, step) {
423
+ if (part.status === 'unsupported')
424
+ return part;
425
+ return { status: 'manual', steps: [...(part.status === 'manual' ? part.steps : []), step], received: part.received };
426
+ }