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