@haystackeditor/cli 0.24.0 → 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.
- package/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
- package/dist/assets/capture/release.json +4 -0
- package/dist/assets/telemetry/runtime.cjs +829 -1254
- package/dist/capture/adapters/client-routes.js +383 -0
- package/dist/capture/adapters/django.js +127 -0
- package/dist/capture/adapters/files.js +77 -0
- package/dist/capture/adapters/index.js +64 -0
- package/dist/capture/adapters/jsx-edit.js +81 -0
- package/dist/capture/adapters/next.js +327 -0
- package/dist/capture/adapters/nuxt.js +192 -0
- package/dist/capture/adapters/rails.js +171 -0
- package/dist/capture/adapters/react-router.js +432 -0
- package/dist/capture/adapters/sveltekit.js +102 -0
- package/dist/capture/adapters/types.js +4 -0
- package/dist/capture/adapters/vite.js +121 -0
- package/dist/capture/app-config.js +106 -0
- package/dist/capture/consent.js +127 -0
- package/dist/capture/csp.js +331 -0
- package/dist/capture/html.js +74 -0
- package/dist/capture/js-ast.js +400 -0
- package/dist/capture/manifest.js +95 -0
- package/dist/capture/project.js +177 -0
- package/dist/capture/route-pattern.js +119 -0
- package/dist/capture/script-release.js +47 -0
- package/dist/capture/tag.js +74 -0
- package/dist/capture-step.js +53 -0
- package/dist/commands/capture-brief.js +92 -0
- package/dist/commands/capture-contract.js +46 -0
- package/dist/commands/capture-manifest.js +78 -0
- package/dist/commands/init-capture.js +409 -0
- package/dist/commands/init-telemetry.js +1011 -0
- package/dist/commands/init.js +75 -9
- package/dist/commands/server-telemetry-contract.d.ts +66 -0
- package/dist/commands/server-telemetry-contract.js +127 -0
- package/dist/commands/telemetry-token.js +238 -0
- package/dist/commands/telemetry.d.ts +161 -8
- package/dist/commands/telemetry.js +940 -158
- package/dist/commands/verify-onboarding.js +5 -1
- package/dist/commands/verify.js +54 -7
- package/dist/index.js +83 -6
- package/dist/schema.js +2 -2
- package/dist/telemetry/next-loader.cjs +66 -9
- package/dist/telemetry/next.d.ts +11 -3
- package/dist/telemetry/next.js +95 -15
- package/dist/telemetry/typed-source.d.ts +47 -0
- package/dist/telemetry/typed-source.js +379 -0
- package/package.json +4 -2
- package/schemas/init.v1.json +63 -4
- package/schemas/pre-verify.v1.json +60 -3
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `haystack init --app <dir>`: telemetry (CAPTURE-V1 rule 8, capture_contracts.ts; this file is rules 8c-8e, the capture
|
|
3
|
+
* part is lane D1's and plugs in at planCapture).
|
|
4
|
+
*
|
|
5
|
+
* Every edit is an InitChange: shown first as a diff, made only with --yes (or a yes), and planned again from the files
|
|
6
|
+
* on a rerun, so a rerun after applying plans nothing. init edits only what it understands and says exactly what to do
|
|
7
|
+
* with the rest (rule 8: init never guesses):
|
|
8
|
+
* - server telemetry (8c). A Next.js app's next.config gains withHaystackTelemetry as its outermost wrapper, in the
|
|
9
|
+
* export form it already has (object, function, async function, other plugins; ESM or CommonJS). The edit is made on
|
|
10
|
+
* the parsed file: the call and its import are spliced in at the positions Babel reports, so every other byte stays.
|
|
11
|
+
* A Node server whose build script is a `tsc` compile with a declared outDir gains `haystack telemetry instrument
|
|
12
|
+
* <outDir> --entry <entry>` after that compile, once. The CLI becomes a dependency through the app's own package
|
|
13
|
+
* manager (its lockfile stays in step, so a frozen-lockfile CI install keeps working), pinned to this exact version:
|
|
14
|
+
* Haystack can never push code into a customer's build (rule 13c);
|
|
15
|
+
* - settings (9c). Lane C's `haystack telemetry settings --propose` writes `.haystack/telemetry-settings.proposed.json`,
|
|
16
|
+
* left uncommitted for the user to review and approve with `haystack telemetry settings --approve` (which writes
|
|
17
|
+
* `.haystack/telemetry-settings.json`); an existing allowlist is the user's reviewed approval and stays as it is;
|
|
18
|
+
* - the data profile (8d). The `haystack db profile` command when the onboarding plan's database (or, before a plan
|
|
19
|
+
* exists, the database the notes name) is Postgres, the engine the profiler reads;
|
|
20
|
+
* - the report (8e). InitTelemetryReport, plus the person's steps beside it: the ingest token (init never mints, prints,
|
|
21
|
+
* stores or reads one), production's environment, the start command, the deploy and the off switches (13c).
|
|
22
|
+
*/
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import chalk from 'chalk';
|
|
29
|
+
import { classifyHttpError } from '../utils/haystack-api.js';
|
|
30
|
+
import { gatewayFetch } from './case-batch.js';
|
|
31
|
+
import { CAPTURE_EVIDENCE_FRESH_HOURS, CAPTURE_STATUS_PATH, } from './capture-contract.js';
|
|
32
|
+
import { captureAnswers, planCapture } from './init-capture.js';
|
|
33
|
+
import { discoverApps } from '../capture/adapters/index.js';
|
|
34
|
+
import { loadBabel } from '../capture/js-ast.js';
|
|
35
|
+
import { lineDiff } from './init.js';
|
|
36
|
+
import { readTelemetryServerStatus } from './telemetry-token.js';
|
|
37
|
+
import { readFacts } from './verify-onboarding.js';
|
|
38
|
+
/** Lane D1: with the user's yes, init checks browser capture's registration with Haystack around the writes. */
|
|
39
|
+
export { prepareCapture, withCaptureStep } from './init-capture.js';
|
|
40
|
+
const CLI_PACKAGE = '@haystackeditor/cli';
|
|
41
|
+
const NEXT_ENTRY = '@haystackeditor/cli/next';
|
|
42
|
+
const WRAPPER = 'withHaystackTelemetry';
|
|
43
|
+
/** withHaystackTelemetry relies on compiler.runAfterProductionCompile (packages/haystack-cli/src/telemetry/next.ts). */
|
|
44
|
+
const NEXT_MINIMUM = [15, 4];
|
|
45
|
+
const NEXT_CONFIGS = ['next.config.js', 'next.config.mjs', 'next.config.cjs', 'next.config.ts', 'next.config.mts', 'next.config.cts'];
|
|
46
|
+
const INSTRUMENT_COMMAND = 'haystack telemetry instrument';
|
|
47
|
+
const SETTINGS_FILE = '.haystack/telemetry-settings.json';
|
|
48
|
+
const SETTINGS_PROPOSAL = '.haystack/telemetry-settings.proposed.json';
|
|
49
|
+
/** The variable the db profile command reads; the person sets it to a read-only connection string where they run it. */
|
|
50
|
+
const PROFILE_URL_ENV = 'HAYSTACK_PROFILE_DATABASE_URL';
|
|
51
|
+
const PROFILE_FILE = 'haystack-db-profile.json';
|
|
52
|
+
/* ------------------------------------------------------------------ files */
|
|
53
|
+
function cliVersion() {
|
|
54
|
+
const path = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
|
55
|
+
const version = JSON.parse(readFileSync(path, 'utf8')).version;
|
|
56
|
+
if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/.test(version))
|
|
57
|
+
throw new Error(`The CLI's own package.json at ${path} has no release version.`);
|
|
58
|
+
return version;
|
|
59
|
+
}
|
|
60
|
+
function isFile(path) {
|
|
61
|
+
try {
|
|
62
|
+
return lstatSync(path).isFile();
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === 'ENOENT')
|
|
66
|
+
return false;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function inside(gitRoot, target) {
|
|
71
|
+
const path = relative(gitRoot, target);
|
|
72
|
+
return path === '' ? '.' : path.split(sep).join('/');
|
|
73
|
+
}
|
|
74
|
+
function change(gitRoot, target, reason, before, content, run) {
|
|
75
|
+
return { path: inside(gitRoot, target), action: before === null ? 'create' : 'update', reason, diff: lineDiff(before ?? '', content),
|
|
76
|
+
target, content, ...(run ? { run } : {}) };
|
|
77
|
+
}
|
|
78
|
+
function readPackage(path) {
|
|
79
|
+
const text = readFileSync(path, 'utf8');
|
|
80
|
+
let json;
|
|
81
|
+
try {
|
|
82
|
+
json = JSON.parse(text);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
throw new Error(`${path} is not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
|
|
86
|
+
}
|
|
87
|
+
if (!json || typeof json !== 'object' || Array.isArray(json))
|
|
88
|
+
throw new Error(`${path} is not a JSON object.`);
|
|
89
|
+
return { path, text, json: json, indent: /\n([ \t]+)"/.exec(text)?.[1] ?? ' ' };
|
|
90
|
+
}
|
|
91
|
+
/** A package.json as the planned edits before it leave it. */
|
|
92
|
+
function packageFrom(path, text) {
|
|
93
|
+
return { path, text, json: JSON.parse(text), indent: /\n([ \t]+)"/.exec(text)?.[1] ?? ' ' };
|
|
94
|
+
}
|
|
95
|
+
function packageText(file, json) {
|
|
96
|
+
return `${JSON.stringify(json, null, file.indent)}${file.text.endsWith('\n') ? '\n' : ''}`;
|
|
97
|
+
}
|
|
98
|
+
function section(json, name) {
|
|
99
|
+
const value = json[name];
|
|
100
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
101
|
+
}
|
|
102
|
+
const LOCKFILES = [['pnpm-lock.yaml', 'pnpm'], ['package-lock.json', 'npm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun']];
|
|
103
|
+
/** The package manager that owns the app's lockfile: the nearest `packageManager` field, else the nearest lockfile, from
|
|
104
|
+
* the app up to the repository root. null: no lockfile, so the dependency is written into package.json itself. */
|
|
105
|
+
function packageManager(gitRoot, appDir) {
|
|
106
|
+
for (let directory = appDir;; directory = dirname(directory)) {
|
|
107
|
+
const manifest = join(directory, 'package.json');
|
|
108
|
+
if (isFile(manifest)) {
|
|
109
|
+
const declared = readPackage(manifest).json.packageManager;
|
|
110
|
+
const name = typeof declared === 'string' ? /^(pnpm|npm|yarn|bun)@/.exec(declared)?.[1] : undefined;
|
|
111
|
+
if (name)
|
|
112
|
+
return { name: name, root: directory };
|
|
113
|
+
}
|
|
114
|
+
for (const [lockfile, name] of LOCKFILES) {
|
|
115
|
+
if (isFile(join(directory, lockfile)))
|
|
116
|
+
return { name, root: directory };
|
|
117
|
+
}
|
|
118
|
+
if (directory === gitRoot || dirname(directory) === directory)
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** The first release whose sites record repository-relative paths (what a registered tenant's src_prefix '' relies on)
|
|
123
|
+
* and that ships the `./next` entry: earlier releases are not compatible with either integration. */
|
|
124
|
+
const CLI_COMPATIBLE_SINCE = [0, 24, 0];
|
|
125
|
+
function parseVersion(text) {
|
|
126
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(text);
|
|
127
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
128
|
+
}
|
|
129
|
+
function versionAtLeast(version, minimum) {
|
|
130
|
+
for (let i = 0; i < 3; i += 1)
|
|
131
|
+
if (version[i] !== minimum[i])
|
|
132
|
+
return version[i] > minimum[i];
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
/** The CLI the app resolves (its node_modules), and whether it has what the integration uses; null when none is installed. */
|
|
136
|
+
function installedCli(appDir) {
|
|
137
|
+
let manifestPath;
|
|
138
|
+
try {
|
|
139
|
+
manifestPath = createRequire(join(appDir, 'package.json')).resolve(`${CLI_PACKAGE}/package.json`);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error.code === 'MODULE_NOT_FOUND')
|
|
143
|
+
return null;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
147
|
+
const exportsMap = manifest.exports && typeof manifest.exports === 'object' ? manifest.exports : {};
|
|
148
|
+
const bin = manifest.bin && typeof manifest.bin === 'object' ? manifest.bin : {};
|
|
149
|
+
return { version: typeof manifest.version === 'string' ? parseVersion(manifest.version) : null, next: './next' in exportsMap,
|
|
150
|
+
bin: typeof bin.haystack === 'string' };
|
|
151
|
+
}
|
|
152
|
+
/** What the app's declared range guarantees at least: `x.y.z`, `^x.y.z` or `~x.y.z`; null for anything else (a tag, a
|
|
153
|
+
* workspace or file link, an open range), which init cannot check. */
|
|
154
|
+
function declaredMinimum(spec) {
|
|
155
|
+
const match = /^[\^~]?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(spec.trim());
|
|
156
|
+
return match ? parseVersion(match[1]) : null;
|
|
157
|
+
}
|
|
158
|
+
function mergeNeeds(own, extra) {
|
|
159
|
+
if (extra === null)
|
|
160
|
+
return own;
|
|
161
|
+
return {
|
|
162
|
+
field: own.field === 'dependencies' || extra.field === 'dependencies' ? 'dependencies' : 'devDependencies',
|
|
163
|
+
next: own.next || extra.next,
|
|
164
|
+
bin: own.bin || extra.bin,
|
|
165
|
+
minimum: versionAtLeast(own.minimum, extra.minimum) ? own.minimum : extra.minimum,
|
|
166
|
+
why: `${own.why} ${extra.why}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Browser capture's need (lane D1): the build runs `haystack capture manifest`, which this CLI version has, and a production
|
|
170
|
+
* install must have it too, so the build step never runs a CLI that is not there (rule 13). */
|
|
171
|
+
function captureNeed() {
|
|
172
|
+
return { field: 'dependencies', next: false, bin: true, minimum: parseVersion(cliVersion()),
|
|
173
|
+
why: 'The build runs `haystack capture manifest` to publish the route manifest, so the CLI is a production dependency (a production-only install keeps it).' };
|
|
174
|
+
}
|
|
175
|
+
/** The CLI as a compatible dependency in `field`, checked rather than assumed (rule 13: an edit that imports the CLI must
|
|
176
|
+
* never ship without it): an installed copy is checked for the entry the integration uses; without one, the declared
|
|
177
|
+
* range must guarantee a compatible release. Otherwise this exact version is installed (or, for Next.js, moved from
|
|
178
|
+
* devDependencies to dependencies) through the app's package manager, and the result is checked after it runs. A
|
|
179
|
+
* declaration init cannot check is left to the person. */
|
|
180
|
+
function planDependency(gitRoot, appDir, pkg, after, need) {
|
|
181
|
+
const { field, why } = need;
|
|
182
|
+
const inProduction = section(pkg.json, 'dependencies')[CLI_PACKAGE];
|
|
183
|
+
const inDevelopment = section(pkg.json, 'devDependencies')[CLI_PACKAGE];
|
|
184
|
+
// Next.js loads next.config, and with it the CLI, at `next start`: only `dependencies` survive a production install.
|
|
185
|
+
const declared = field === 'dependencies' ? inProduction : inDevelopment ?? inProduction;
|
|
186
|
+
if (declared !== undefined) {
|
|
187
|
+
const installed = installedCli(appDir);
|
|
188
|
+
const compatible = installed
|
|
189
|
+
? installed.version !== null && versionAtLeast(installed.version, need.minimum)
|
|
190
|
+
&& (!need.next || installed.next) && (!need.bin || installed.bin)
|
|
191
|
+
: (() => {
|
|
192
|
+
const minimum = declaredMinimum(declared);
|
|
193
|
+
return minimum === null ? null : versionAtLeast(minimum, need.minimum);
|
|
194
|
+
})();
|
|
195
|
+
if (compatible === true)
|
|
196
|
+
return { kind: 'ok' };
|
|
197
|
+
if (compatible === null) {
|
|
198
|
+
return { kind: 'manual', why: `${inside(gitRoot, pkg.path)} declares ${CLI_PACKAGE} "${declared}", which init cannot check is `
|
|
199
|
+
+ `${need.minimum.join('.')} or newer. Set it to ${cliVersion()} in ${field}, then run \`haystack init\` again` };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const version = cliVersion();
|
|
203
|
+
const json = JSON.parse(after);
|
|
204
|
+
const entries = { ...section(json, field), [CLI_PACKAGE]: version };
|
|
205
|
+
json[field] = Object.fromEntries(Object.entries(entries).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
206
|
+
const promoting = field === 'dependencies' && inProduction === undefined && inDevelopment !== undefined;
|
|
207
|
+
if (promoting) {
|
|
208
|
+
const development = { ...section(json, 'devDependencies') };
|
|
209
|
+
delete development[CLI_PACKAGE];
|
|
210
|
+
json.devDependencies = development;
|
|
211
|
+
}
|
|
212
|
+
const content = packageText(pkg, json);
|
|
213
|
+
const manager = packageManager(gitRoot, appDir);
|
|
214
|
+
const spec = `${CLI_PACKAGE}@${version}`;
|
|
215
|
+
const dev = field === 'devDependencies';
|
|
216
|
+
const workspaceRoot = isFile(join(appDir, 'pnpm-workspace.yaml')) ? ['--workspace-root'] : [];
|
|
217
|
+
const argv = manager === null ? null : {
|
|
218
|
+
pnpm: ['pnpm', 'add', '--save-exact', dev ? '--save-dev' : '--save-prod', ...workspaceRoot, spec],
|
|
219
|
+
npm: ['npm', 'install', '--save-exact', dev ? '--save-dev' : '--save-prod', spec],
|
|
220
|
+
yarn: ['yarn', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
|
|
221
|
+
bun: ['bun', 'add', '--exact', ...(dev ? ['--dev'] : []), spec],
|
|
222
|
+
}[manager.name];
|
|
223
|
+
const action = promoting ? `Moved from devDependencies to dependencies at ${version}` : `Pinned to ${version}`;
|
|
224
|
+
const reason = argv
|
|
225
|
+
? `${why} ${action} with \`${argv.join(' ')}\` in ${inside(gitRoot, appDir)}, which updates ${manager?.name}'s lockfile too.`
|
|
226
|
+
: `${why} ${action}: a newer release never reaches your build unless you change it.`;
|
|
227
|
+
// The package manager's own word is not taken for it: the manifest must say what the integration needs.
|
|
228
|
+
const check = () => {
|
|
229
|
+
const written = readPackage(pkg.path).json;
|
|
230
|
+
if (section(written, field)[CLI_PACKAGE] !== version)
|
|
231
|
+
return `${inside(gitRoot, pkg.path)} does not list ${spec} in ${field}`;
|
|
232
|
+
if (field === 'dependencies' && section(written, 'devDependencies')[CLI_PACKAGE] !== undefined) {
|
|
233
|
+
return `${inside(gitRoot, pkg.path)} still lists ${CLI_PACKAGE} in devDependencies`;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
};
|
|
237
|
+
const touches = manager === null ? [] : LOCKFILES.map(([lockfile]) => join(manager.root, lockfile));
|
|
238
|
+
return { kind: 'change', change: change(gitRoot, pkg.path, reason, after, content, argv ? { argv, cwd: appDir, touches, check } : undefined) };
|
|
239
|
+
}
|
|
240
|
+
function isNode(value) {
|
|
241
|
+
return !!value && typeof value === 'object' && typeof value.type === 'string' && typeof value.start === 'number';
|
|
242
|
+
}
|
|
243
|
+
function walk(node, visit) {
|
|
244
|
+
visit(node);
|
|
245
|
+
for (const [key, value] of Object.entries(node)) {
|
|
246
|
+
if (key === 'leadingComments' || key === 'trailingComments' || key === 'innerComments' || key === 'loc' || key === 'extra')
|
|
247
|
+
continue;
|
|
248
|
+
for (const child of Array.isArray(value) ? value : [value])
|
|
249
|
+
if (isNode(child))
|
|
250
|
+
walk(child, visit);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const named = (node, name) => isNode(node) && node.type === 'Identifier' && node.name === name;
|
|
254
|
+
const stringValue = (node) => isNode(node) && node.type === 'StringLiteral' && typeof node.value === 'string' ? node.value : null;
|
|
255
|
+
function isModuleExports(node) {
|
|
256
|
+
return isNode(node) && node.type === 'MemberExpression' && node.computed === false && named(node.object, 'module') && named(node.property, 'exports');
|
|
257
|
+
}
|
|
258
|
+
/** `require('<source>')`. */
|
|
259
|
+
function requireOf(node) {
|
|
260
|
+
if (!isNode(node) || node.type !== 'CallExpression' || !named(node.callee, 'require'))
|
|
261
|
+
return null;
|
|
262
|
+
const args = node.arguments;
|
|
263
|
+
return args.length === 1 ? stringValue(args[0]) : null;
|
|
264
|
+
}
|
|
265
|
+
const STATEMENTS = new Set(['ImportDeclaration', 'ExpressionStatement', 'VariableDeclaration', 'ReturnStatement']);
|
|
266
|
+
/** The exported expression without the type assertions around it (`x satisfies NextConfig`). */
|
|
267
|
+
function unwrapTypes(node) {
|
|
268
|
+
let current = node;
|
|
269
|
+
while (current.type === 'TSSatisfiesExpression' || current.type === 'TSAsExpression' || current.type === 'ParenthesizedExpression') {
|
|
270
|
+
current = current.expression;
|
|
271
|
+
}
|
|
272
|
+
return current;
|
|
273
|
+
}
|
|
274
|
+
async function parse(code, file) {
|
|
275
|
+
const babel = await import('@babel/core');
|
|
276
|
+
const typescript = /\.[cm]?ts$/.test(file);
|
|
277
|
+
const ast = babel.parseSync(code, {
|
|
278
|
+
babelrc: false, configFile: false, filename: file, sourceType: 'unambiguous',
|
|
279
|
+
parserOpts: { plugins: typescript ? ['typescript'] : ['jsx'], createParenthesizedExpressions: false },
|
|
280
|
+
});
|
|
281
|
+
if (!ast?.program)
|
|
282
|
+
throw new Error('the parser returned no program');
|
|
283
|
+
return { program: ast.program, comments: ast.comments ?? [] };
|
|
284
|
+
}
|
|
285
|
+
/** Where code init adds at the top of a file goes: after the hashbang, the directives ('use strict') and the comments that
|
|
286
|
+
* open the file (`// @ts-check`, `/* eslint-disable *\/`, a licence: file-level, so they stay first), but before a JSDoc
|
|
287
|
+
* block that sits right on the first statement (`/** @type {import('next').NextConfig} *\/`), which documents that
|
|
288
|
+
* statement and stays with it. Always a line start; null when code shares a line with what must stay above it. */
|
|
289
|
+
function topInsertion(code, parsed) {
|
|
290
|
+
const firstCode = parsed.program.body[0]?.start ?? code.length;
|
|
291
|
+
const after = (end) => {
|
|
292
|
+
const lineEnd = code.indexOf('\n', end);
|
|
293
|
+
if (lineEnd === -1)
|
|
294
|
+
return code.length;
|
|
295
|
+
return lineEnd + 1 > firstCode && firstCode !== code.length ? null : lineEnd + 1;
|
|
296
|
+
};
|
|
297
|
+
let position = parsed.program.interpreter ? after(parsed.program.interpreter.end) : 0;
|
|
298
|
+
const directives = (parsed.program.directives ?? []);
|
|
299
|
+
if (position !== null && directives.length > 0)
|
|
300
|
+
position = after(directives[directives.length - 1].end);
|
|
301
|
+
if (position === null)
|
|
302
|
+
return null;
|
|
303
|
+
for (const comment of parsed.comments) {
|
|
304
|
+
if (comment.end <= position)
|
|
305
|
+
continue;
|
|
306
|
+
if (comment.start >= firstCode || code.slice(position, comment.start).trim() !== '')
|
|
307
|
+
break;
|
|
308
|
+
if (comment.type === 'CommentBlock' && comment.value.startsWith('*') && code.slice(comment.end, firstCode).trim() === '')
|
|
309
|
+
break;
|
|
310
|
+
const next = after(comment.end);
|
|
311
|
+
if (next === null)
|
|
312
|
+
return null;
|
|
313
|
+
position = next;
|
|
314
|
+
}
|
|
315
|
+
return position;
|
|
316
|
+
}
|
|
317
|
+
/** The wrapper in one next.config's own export form, or why init leaves the file to the person. */
|
|
318
|
+
function planNextConfigEdit(code, parsed) {
|
|
319
|
+
const body = parsed.program.body;
|
|
320
|
+
let cached = false;
|
|
321
|
+
walk(parsed.program, node => {
|
|
322
|
+
if (named(node, 'turbopackFileSystemCacheForBuild') || stringValue(node) === 'turbopackFileSystemCacheForBuild')
|
|
323
|
+
cached = true;
|
|
324
|
+
});
|
|
325
|
+
if (cached) {
|
|
326
|
+
return { kind: 'manual', why: 'it sets experimental.turbopackFileSystemCacheForBuild: a build that reuses cached Turbopack modules skips the '
|
|
327
|
+
+ 'instrumenting loader, so withHaystackTelemetry refuses it. Turn that option off, then run `haystack init` again' };
|
|
328
|
+
}
|
|
329
|
+
// The local name the wrapper is imported as, if it is.
|
|
330
|
+
let local = null;
|
|
331
|
+
let importNode = null;
|
|
332
|
+
for (const statement of body) {
|
|
333
|
+
if (statement.type === 'ImportDeclaration' && stringValue(statement.source) === NEXT_ENTRY) {
|
|
334
|
+
for (const specifier of statement.specifiers) {
|
|
335
|
+
if (specifier.type === 'ImportSpecifier' && (named(specifier.imported, WRAPPER) || stringValue(specifier.imported) === WRAPPER)) {
|
|
336
|
+
local = specifier.local.name;
|
|
337
|
+
importNode = statement;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (statement.type === 'VariableDeclaration') {
|
|
342
|
+
for (const declarator of statement.declarations) {
|
|
343
|
+
if (requireOf(declarator.init) !== NEXT_ENTRY || declarator.id.type !== 'ObjectPattern')
|
|
344
|
+
continue;
|
|
345
|
+
for (const property of declarator.id.properties) {
|
|
346
|
+
if (property.type === 'ObjectProperty' && named(property.key, WRAPPER) && isNode(property.value) && property.value.type === 'Identifier') {
|
|
347
|
+
local = property.value.name;
|
|
348
|
+
importNode = statement;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const esmDefaults = body.filter(statement => statement.type === 'ExportDefaultDeclaration');
|
|
355
|
+
const reexportsDefault = body.some(statement => statement.type === 'ExportNamedDeclaration'
|
|
356
|
+
&& statement.specifiers?.some(specifier => named(specifier.exported, 'default') || stringValue(specifier.exported) === 'default'));
|
|
357
|
+
const cjsExports = body.filter(statement => statement.type === 'ExpressionStatement' && isNode(statement.expression)
|
|
358
|
+
&& statement.expression.type === 'AssignmentExpression' && statement.expression.operator === '=' && isModuleExports(statement.expression.left));
|
|
359
|
+
let otherCjs = false;
|
|
360
|
+
walk(parsed.program, node => {
|
|
361
|
+
// module.exports.x = / exports.x = : the config is assembled in pieces init cannot wrap whole.
|
|
362
|
+
if (node.type === 'AssignmentExpression' && isNode(node.left) && node.left.type === 'MemberExpression'
|
|
363
|
+
&& (isModuleExports(node.left.object) || named(node.left.object, 'exports')))
|
|
364
|
+
otherCjs = true;
|
|
365
|
+
});
|
|
366
|
+
const forms = esmDefaults.length + cjsExports.length;
|
|
367
|
+
if (reexportsDefault || otherCjs || forms !== 1) {
|
|
368
|
+
return { kind: 'manual', why: forms === 0 && !reexportsDefault && !otherCjs
|
|
369
|
+
? 'it exports no config init can find (no `export default` and no `module.exports =`)'
|
|
370
|
+
: 'it exports its config in a form init does not edit (several exports, `export { x as default }` or `module.exports.x =`)' };
|
|
371
|
+
}
|
|
372
|
+
const esm = esmDefaults.length === 1;
|
|
373
|
+
const statement = esm ? esmDefaults[0] : cjsExports[0];
|
|
374
|
+
const exported = (esm ? statement.declaration : statement.expression.right);
|
|
375
|
+
if (local !== null) {
|
|
376
|
+
const core = exported.type === 'FunctionDeclaration' ? exported : unwrapTypes(exported);
|
|
377
|
+
if (core.type === 'CallExpression' && named(core.callee, local))
|
|
378
|
+
return { kind: 'installed' };
|
|
379
|
+
let used = false;
|
|
380
|
+
walk(parsed.program, node => { if (named(node, local) && !(importNode && node.start >= importNode.start && node.end <= importNode.end))
|
|
381
|
+
used = true; });
|
|
382
|
+
if (used) {
|
|
383
|
+
return { kind: 'manual', why: `it already calls ${WRAPPER}, but not as the outermost wrapper of the exported config. Make it the outermost `
|
|
384
|
+
+ `(\`export default ${WRAPPER}(<everything else>)\`), then run \`haystack init\` again` };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (exported.type === 'ClassDeclaration'
|
|
388
|
+
|| (exported.type.startsWith('TS') && exported.type !== 'TSSatisfiesExpression' && exported.type !== 'TSAsExpression')) {
|
|
389
|
+
return { kind: 'manual', why: `its default export is a ${exported.type}, not a config object or function` };
|
|
390
|
+
}
|
|
391
|
+
// House style for the lines init adds: the quotes of the file's first string, and whether its first simple statement
|
|
392
|
+
// ends with a semicolon.
|
|
393
|
+
const strings = [];
|
|
394
|
+
walk(parsed.program, node => { if (node.type === 'StringLiteral')
|
|
395
|
+
strings.push(node); });
|
|
396
|
+
const firstRaw = strings.sort((a, b) => a.start - b.start)[0]?.extra;
|
|
397
|
+
const quote = typeof firstRaw?.raw === 'string' && firstRaw.raw.startsWith('"') ? '"' : '\'';
|
|
398
|
+
const simple = [];
|
|
399
|
+
walk(parsed.program, node => { if (STATEMENTS.has(node.type))
|
|
400
|
+
simple.push(node); });
|
|
401
|
+
const first = simple.sort((a, b) => a.start - b.start)[0];
|
|
402
|
+
const semicolon = first && code[first.end - 1] === ';' ? ';' : '';
|
|
403
|
+
// A binding no name in the file already uses (any identifier counts, so neither a declaration nor a global it
|
|
404
|
+
// reads is shadowed).
|
|
405
|
+
const names = new Set();
|
|
406
|
+
walk(parsed.program, node => { if (node.type === 'Identifier' && typeof node.name === 'string')
|
|
407
|
+
names.add(node.name); });
|
|
408
|
+
let name = local ?? WRAPPER;
|
|
409
|
+
for (let n = 1; local === null && names.has(name); n += 1)
|
|
410
|
+
name = `${WRAPPER}_${n}`;
|
|
411
|
+
const splices = [];
|
|
412
|
+
if (local === null && esm) {
|
|
413
|
+
// Imports are hoisted, so after the file's last import (or at its top) the binding is ready wherever it is used.
|
|
414
|
+
const line = `import { ${name === WRAPPER ? WRAPPER : `${WRAPPER} as ${name}`} } from ${quote}${NEXT_ENTRY}${quote}${semicolon}`;
|
|
415
|
+
const imports = body.filter(node => node.type === 'ImportDeclaration');
|
|
416
|
+
const last = imports.length > 0 ? imports[imports.length - 1] : null;
|
|
417
|
+
if (last)
|
|
418
|
+
splices.push({ at: last.end, remove: 0, text: `\n${line}` });
|
|
419
|
+
else {
|
|
420
|
+
const at = topInsertion(code, parsed);
|
|
421
|
+
if (at === null)
|
|
422
|
+
return { kind: 'manual', why: 'code shares a line with its directives or opening comments, so init cannot add a line above it' };
|
|
423
|
+
const next = code.slice(at);
|
|
424
|
+
splices.push({ at, remove: 0, text: `${line}\n${/^\s*\n/.test(next) || at === code.length ? '' : '\n'}` });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
else if (local === null) {
|
|
428
|
+
// A `const` is in its temporal dead zone until it runs, so the require goes directly before the statement that
|
|
429
|
+
// uses it (above the comments that open that statement, which may type it), wherever the file's other requires are.
|
|
430
|
+
const line = `const { ${name === WRAPPER ? WRAPPER : `${WRAPPER}: ${name}`} } = require(${quote}${NEXT_ENTRY}${quote});`;
|
|
431
|
+
const previousEnd = body.filter(node => node.end <= statement.start).reduce((end, node) => Math.max(end, node.end), 0);
|
|
432
|
+
let at = statement.start;
|
|
433
|
+
for (const comment of [...parsed.comments].reverse()) {
|
|
434
|
+
if (comment.end > at || comment.start < previousEnd || code.slice(comment.end, at).trim() !== '')
|
|
435
|
+
continue;
|
|
436
|
+
if (code.slice(code.lastIndexOf('\n', comment.start - 1) + 1, comment.start).trim() !== '')
|
|
437
|
+
break;
|
|
438
|
+
at = comment.start;
|
|
439
|
+
}
|
|
440
|
+
// Never above what opens the file: the hashbang, the directives and file-level comments stay first.
|
|
441
|
+
const top = topInsertion(code, parsed);
|
|
442
|
+
if (top === null)
|
|
443
|
+
return { kind: 'manual', why: 'code shares a line with its directives or opening comments, so init cannot add a line above it' };
|
|
444
|
+
at = Math.max(at, top);
|
|
445
|
+
splices.push({ at, remove: 0, text: `${line}\n` });
|
|
446
|
+
}
|
|
447
|
+
if (exported.type === 'FunctionDeclaration' && exported.id) {
|
|
448
|
+
// `export default function config()` stays a declaration (its name is a module binding); the export moves below it.
|
|
449
|
+
splices.push({ at: statement.start, remove: exported.start - statement.start, text: '' });
|
|
450
|
+
splices.push({ at: statement.end, remove: 0, text: `\n\nexport default ${name}(${exported.id.name})${semicolon}` });
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
splices.push({ at: exported.start, remove: 0, text: `${name}(` });
|
|
454
|
+
splices.push({ at: exported.end, remove: 0, text: ')' });
|
|
455
|
+
}
|
|
456
|
+
let content = code;
|
|
457
|
+
for (const splice of splices.sort((a, b) => b.at - a.at || b.remove - a.remove)) {
|
|
458
|
+
content = content.slice(0, splice.at) + splice.text + content.slice(splice.at + splice.remove);
|
|
459
|
+
}
|
|
460
|
+
return { kind: 'edit', content };
|
|
461
|
+
}
|
|
462
|
+
/** [major, minor] of the Next.js this app builds with: the installed package when there is one, else the minimum of the
|
|
463
|
+
* declared range when it has a plain lower bound; null when neither says. */
|
|
464
|
+
function nextVersion(appDir, declared) {
|
|
465
|
+
try {
|
|
466
|
+
const installed = createRequire(join(appDir, 'package.json'))('next/package.json').version;
|
|
467
|
+
const match = typeof installed === 'string' ? /^(\d+)\.(\d+)\./.exec(installed) : null;
|
|
468
|
+
if (match)
|
|
469
|
+
return [Number(match[1]), Number(match[2])];
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
if (error.code !== 'MODULE_NOT_FOUND')
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
const match = /^\s*(?:\^|~|>=|=)?\s*v?(\d+)\.(\d+)(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?\s*$/.exec(declared);
|
|
476
|
+
return match ? [Number(match[1]), Number(match[2])] : null;
|
|
477
|
+
}
|
|
478
|
+
const atLeast = (version, minimum) => version[0] > minimum[0] || (version[0] === minimum[0] && version[1] >= minimum[1]);
|
|
479
|
+
/** The server part for a Next.js app. `extra`: another part's CLI need, merged into this part's dependency; `covers` says
|
|
480
|
+
* the returned changes settle it. */
|
|
481
|
+
async function planNext(gitRoot, appDir, pkg, extra) {
|
|
482
|
+
const declared = section(pkg.json, 'dependencies').next ?? section(pkg.json, 'devDependencies').next;
|
|
483
|
+
const version = nextVersion(appDir, declared);
|
|
484
|
+
const wrapperLines = [`import { ${WRAPPER} } from '${NEXT_ENTRY}';`, `export default ${WRAPPER}(nextConfig); // the outermost wrapper`];
|
|
485
|
+
// The dependency is settled before the config is touched: an import of a CLI production cannot load breaks `next start`.
|
|
486
|
+
const dependency = planDependency(gitRoot, appDir, pkg, pkg.text, mergeNeeds({ field: 'dependencies', next: true, bin: false,
|
|
487
|
+
minimum: CLI_COMPATIBLE_SINCE, why: `next.config imports ${NEXT_ENTRY}, and \`next start\` loads next.config in production, so the CLI is a production dependency.` }, extra));
|
|
488
|
+
const manualSteps = (why, file) => [
|
|
489
|
+
`init left ${file} alone: ${why}.`,
|
|
490
|
+
dependency.kind === 'ok' ? `Wrap the exported config in ${file}, outermost:`
|
|
491
|
+
: `Add ${CLI_PACKAGE}@${cliVersion()} to the app's dependencies (exact version, not devDependencies), then wrap the exported `
|
|
492
|
+
+ `config in ${file}, outermost:`,
|
|
493
|
+
...wrapperLines.map(line => ` ${line}`),
|
|
494
|
+
];
|
|
495
|
+
if (dependency.kind === 'manual') {
|
|
496
|
+
return { changes: [], server: { status: 'manual', kind: 'next', steps: manualSteps(dependency.why, 'next.config') } };
|
|
497
|
+
}
|
|
498
|
+
if (version === null) {
|
|
499
|
+
return { changes: [], server: { status: 'manual', kind: 'next', steps: manualSteps(`it cannot tell which Next.js the app builds with (package.json declares next "${declared}" and none is installed here); `
|
|
500
|
+
+ `${WRAPPER} needs Next.js ${NEXT_MINIMUM.join('.')} or newer`, 'next.config') } };
|
|
501
|
+
}
|
|
502
|
+
if (!atLeast(version, NEXT_MINIMUM)) {
|
|
503
|
+
return { changes: [], server: { status: 'unsupported',
|
|
504
|
+
why: `${WRAPPER} needs Next.js ${NEXT_MINIMUM.join('.')} or newer; this app builds with ${version.join('.')}.` } };
|
|
505
|
+
}
|
|
506
|
+
const present = NEXT_CONFIGS.filter(name => isFile(join(appDir, name)));
|
|
507
|
+
if (present.length > 1) {
|
|
508
|
+
return { changes: [], server: { status: 'manual', kind: 'next', steps: manualSteps(`the app has several next.config files (${present.join(', ')}) and init does not pick one`, 'the one Next.js loads') } };
|
|
509
|
+
}
|
|
510
|
+
// The install first: a config edit is never made ahead of the dependency it imports.
|
|
511
|
+
const changes = dependency.kind === 'change' ? [dependency.change] : [];
|
|
512
|
+
let distDir = null;
|
|
513
|
+
if (present.length === 0) {
|
|
514
|
+
const target = join(appDir, 'next.config.mjs');
|
|
515
|
+
changes.push(change(gitRoot, target, `Wraps the app's Next.js config so \`next build\` instruments its server code for telemetry (the app had no next.config).`, null, `import { ${WRAPPER} } from '${NEXT_ENTRY}';\n\nexport default ${WRAPPER}({});\n`));
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
const target = join(appDir, present[0]);
|
|
519
|
+
const code = readFileSync(target, 'utf8');
|
|
520
|
+
let parsed;
|
|
521
|
+
try {
|
|
522
|
+
parsed = await parse(code, target);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
return { changes: [], server: { status: 'manual', kind: 'next', steps: manualSteps(`it could not parse it (${error instanceof Error ? error.message.split('\n')[0] : String(error)})`, present[0]) } };
|
|
526
|
+
}
|
|
527
|
+
const edit = planNextConfigEdit(code, parsed);
|
|
528
|
+
if (edit.kind === 'manual')
|
|
529
|
+
return { changes: [], server: { status: 'manual', kind: 'next', steps: manualSteps(edit.why, present[0]) } };
|
|
530
|
+
if (edit.kind === 'edit') {
|
|
531
|
+
changes.push(change(gitRoot, target, `Wraps the exported Next.js config so \`next build\` instruments the app's server code for telemetry `
|
|
532
|
+
+ `(its own export form kept; ${WRAPPER} is the outermost wrapper). Development and \`next start\` get the config unchanged.`, code, edit.content));
|
|
533
|
+
}
|
|
534
|
+
// The runtime lands in <distDir>/.haystack-telemetry; a literal distDir is read, anything else is Next's default.
|
|
535
|
+
walk(parsed.program, node => {
|
|
536
|
+
if (node.type === 'ObjectProperty' && named(node.key, 'distDir') && stringValue(node.value) !== null)
|
|
537
|
+
distDir = stringValue(node.value);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return { changes, server: { status: 'installed', kind: 'next', distDir: distDir ?? '.next' }, covers: true };
|
|
541
|
+
}
|
|
542
|
+
/* ---------------------------------------------------------------- node */
|
|
543
|
+
/** A tsconfig's JSON with comments and trailing commas removed (strings are copied as they are). */
|
|
544
|
+
function jsonc(text) {
|
|
545
|
+
let out = '';
|
|
546
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
547
|
+
const char = text[i];
|
|
548
|
+
if (char === '"') {
|
|
549
|
+
let j = i + 1;
|
|
550
|
+
while (j < text.length && text[j] !== '"')
|
|
551
|
+
j += text[j] === '\\' ? 2 : 1;
|
|
552
|
+
out += text.slice(i, j + 1);
|
|
553
|
+
i = j;
|
|
554
|
+
}
|
|
555
|
+
else if (char === '/' && text[i + 1] === '/') {
|
|
556
|
+
while (i < text.length && text[i] !== '\n')
|
|
557
|
+
i += 1;
|
|
558
|
+
out += '\n';
|
|
559
|
+
}
|
|
560
|
+
else if (char === '/' && text[i + 1] === '*') {
|
|
561
|
+
i = text.indexOf('*/', i + 2);
|
|
562
|
+
if (i === -1)
|
|
563
|
+
throw new Error('an unterminated comment');
|
|
564
|
+
i += 1;
|
|
565
|
+
}
|
|
566
|
+
else
|
|
567
|
+
out += char;
|
|
568
|
+
}
|
|
569
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
|
|
570
|
+
}
|
|
571
|
+
/** A config `extends` names, found as tsc finds it: a path (with or without `.json`), or a package's config. */
|
|
572
|
+
function extendedConfig(from, specifier) {
|
|
573
|
+
const directory = dirname(from);
|
|
574
|
+
if (specifier.startsWith('.') || isAbsolute(specifier)) {
|
|
575
|
+
const path = resolve(directory, specifier);
|
|
576
|
+
if (isFile(path))
|
|
577
|
+
return path;
|
|
578
|
+
if (isFile(`${path}.json`))
|
|
579
|
+
return `${path}.json`;
|
|
580
|
+
if (isFile(join(path, 'tsconfig.json')))
|
|
581
|
+
return join(path, 'tsconfig.json');
|
|
582
|
+
throw new Error(`${inside(dirname(from), path)} (extended by ${from}) does not exist`);
|
|
583
|
+
}
|
|
584
|
+
const resolveFrom = createRequire(from);
|
|
585
|
+
for (const candidate of [specifier, `${specifier}.json`, `${specifier}/tsconfig.json`]) {
|
|
586
|
+
try {
|
|
587
|
+
return resolveFrom.resolve(candidate);
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
if (error.code !== 'MODULE_NOT_FOUND' && error.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED')
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
throw new Error(`${specifier} (extended by ${from}) is not installed, so its options are unknown`);
|
|
595
|
+
}
|
|
596
|
+
/** The options a tsconfig sets, its `extends` chain first (in order, a later one winning), then its own; only what some
|
|
597
|
+
* level sets. Throws with the reason when a level cannot be read or found: unknown options are never guessed. */
|
|
598
|
+
function declaredCompile(path, chain) {
|
|
599
|
+
if (chain.includes(path))
|
|
600
|
+
throw new Error(`${path} extends itself`);
|
|
601
|
+
const config = jsonc(readFileSync(path, 'utf8'));
|
|
602
|
+
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
603
|
+
throw new Error(`${path} is not a JSON object`);
|
|
604
|
+
const object = config;
|
|
605
|
+
const bases = object.extends === undefined ? [] : Array.isArray(object.extends) ? object.extends : [object.extends];
|
|
606
|
+
let declared = {};
|
|
607
|
+
for (const base of bases) {
|
|
608
|
+
if (typeof base !== 'string')
|
|
609
|
+
throw new Error(`${path} has an extends entry that is not a string`);
|
|
610
|
+
declared = { ...declared, ...declaredCompile(extendedConfig(path, base), [...chain, path]) };
|
|
611
|
+
}
|
|
612
|
+
const options = section(object, 'compilerOptions');
|
|
613
|
+
const directory = dirname(path);
|
|
614
|
+
const own = {};
|
|
615
|
+
if (typeof options.outDir === 'string')
|
|
616
|
+
own.outDir = resolve(directory, options.outDir);
|
|
617
|
+
if (typeof options.rootDir === 'string')
|
|
618
|
+
own.rootDir = resolve(directory, options.rootDir);
|
|
619
|
+
for (const name of ['outFile', 'out'])
|
|
620
|
+
if (typeof options[name] === 'string')
|
|
621
|
+
own.outFile = resolve(directory, options[name]);
|
|
622
|
+
if (typeof options.noEmit === 'boolean')
|
|
623
|
+
own.noEmit = options.noEmit;
|
|
624
|
+
if (typeof options.emitDeclarationOnly === 'boolean')
|
|
625
|
+
own.emitDeclarationOnly = options.emitDeclarationOnly;
|
|
626
|
+
if (object.include !== undefined)
|
|
627
|
+
own.include = { patterns: object.include, base: directory };
|
|
628
|
+
return { ...declared, ...own };
|
|
629
|
+
}
|
|
630
|
+
function effectiveCompile(path) {
|
|
631
|
+
return { outDir: null, rootDir: null, outFile: null, noEmit: false, emitDeclarationOnly: false, include: null, ...declaredCompile(path, []) };
|
|
632
|
+
}
|
|
633
|
+
const SAFE_SCRIPT = /^[A-Za-z0-9_./=:@\s&-]+$/;
|
|
634
|
+
const SAFE_PATH = /^[A-Za-z0-9_./@-]+$/;
|
|
635
|
+
/** How tsc names the output of each source extension. */
|
|
636
|
+
const EMITTED_FROM = { '.js': ['.ts', '.tsx', '.js', '.jsx'], '.mjs': ['.mts', '.mjs'], '.cjs': ['.cts', '.cjs'] };
|
|
637
|
+
function option(argv, ...names) {
|
|
638
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
639
|
+
for (const name of names) {
|
|
640
|
+
if (argv[i] === name)
|
|
641
|
+
return argv[i + 1] ?? null;
|
|
642
|
+
if (argv[i].startsWith(`${name}=`))
|
|
643
|
+
return argv[i].slice(name.length + 1);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return undefined;
|
|
647
|
+
}
|
|
648
|
+
/** The server part for a Node server built with `tsc`; `extra` and `covers` as for planNext. */
|
|
649
|
+
function planNode(gitRoot, appDir, pkg, extra) {
|
|
650
|
+
const scripts = section(pkg.json, 'scripts');
|
|
651
|
+
const build = scripts.build;
|
|
652
|
+
const appPath = inside(gitRoot, appDir);
|
|
653
|
+
// The CLI goes where the app already keeps it; devDependencies otherwise (the build runs it; production needs only the
|
|
654
|
+
// runtime it copies into the output).
|
|
655
|
+
const field = section(pkg.json, 'dependencies')[CLI_PACKAGE] !== undefined && section(pkg.json, 'devDependencies')[CLI_PACKAGE] === undefined
|
|
656
|
+
? 'dependencies' : 'devDependencies';
|
|
657
|
+
const need = mergeNeeds({ field, next: false, bin: true, minimum: CLI_COMPATIBLE_SINCE,
|
|
658
|
+
why: 'The build runs `haystack`; the runtime it copies into the output is all production needs.' }, extra);
|
|
659
|
+
const manual = (reason) => ({ changes: [], server: { status: 'manual', kind: 'node', steps: [
|
|
660
|
+
`init left the build alone: ${reason}.`,
|
|
661
|
+
`If the build's final output is \`tsc\`'s one JavaScript file per source file in a directory, add \`&& ${INSTRUMENT_COMMAND} <that directory> `
|
|
662
|
+
+ `--entry <the server's entry file, relative to it>\` (and \`--source-root <the sources' directory>\` unless that is the directory's sibling `
|
|
663
|
+
+ `src/) to the end of the build script in ${appPath}/package.json, and add ${CLI_PACKAGE}@${cliVersion()} to devDependencies. Bundled `
|
|
664
|
+
+ 'output (esbuild, tsup, webpack, tsc --outFile) cannot be instrumented this way.',
|
|
665
|
+
] } });
|
|
666
|
+
if (typeof build !== 'string' || build.trim() === '') {
|
|
667
|
+
return { changes: [], server: { status: 'unsupported', why: `${appPath}/package.json has no build script and the app is not Next.js: server `
|
|
668
|
+
+ 'telemetry instruments a Next.js build or the compiled output of a Node server\'s build.' } };
|
|
669
|
+
}
|
|
670
|
+
if (build.includes(INSTRUMENT_COMMAND)) {
|
|
671
|
+
// Already instrumented: what the build runs must still be installed (a rerun repairs it).
|
|
672
|
+
const dependency = planDependency(gitRoot, appDir, pkg, pkg.text, need);
|
|
673
|
+
if (dependency.kind === 'manual')
|
|
674
|
+
return manual(dependency.why);
|
|
675
|
+
return { changes: dependency.kind === 'change' ? [dependency.change] : [], server: { status: 'installed', kind: 'node', distDir: null }, covers: true };
|
|
676
|
+
}
|
|
677
|
+
// Commands joined by `&&` and nothing else: any other shell syntax could change what "after the compile" means.
|
|
678
|
+
const segments = build.split('&&').map(segment => segment.trim());
|
|
679
|
+
if (!SAFE_SCRIPT.test(build) || segments.some(segment => segment === '' || segment.includes('&'))) {
|
|
680
|
+
return manual('its build script uses shell syntax init does not edit (quotes, pipes, `;`, `||`, redirects or variables)');
|
|
681
|
+
}
|
|
682
|
+
const commands = segments.map(segment => segment.split(/\s+/));
|
|
683
|
+
const compiles = commands.filter(argv => argv[0] === 'tsc');
|
|
684
|
+
if (compiles.length !== 1)
|
|
685
|
+
return manual(compiles.length === 0 ? 'its build script runs no `tsc` compile' : 'its build script runs `tsc` more than once');
|
|
686
|
+
// The output instrumented must be tsc's: nothing may run after the compile (a bundler would replace or repack it).
|
|
687
|
+
if (commands[commands.length - 1] !== compiles[0])
|
|
688
|
+
return manual('commands run after its `tsc` compile, so the final output may not be tsc\'s');
|
|
689
|
+
const tsc = compiles[0];
|
|
690
|
+
for (const flag of ['-b', '--build', '-w', '--watch', '--noEmit', '--emitDeclarationOnly', '--outFile', '--out']) {
|
|
691
|
+
if (tsc.some(arg => arg === flag || arg.startsWith(`${flag}=`)))
|
|
692
|
+
return manual(`its \`tsc\` runs with ${flag}, which emits no per-file JavaScript output`);
|
|
693
|
+
}
|
|
694
|
+
const projectOption = option(tsc, '-p', '--project');
|
|
695
|
+
if (projectOption === null)
|
|
696
|
+
return manual('its `tsc` names a project without a path');
|
|
697
|
+
let project = resolve(appDir, projectOption ?? 'tsconfig.json');
|
|
698
|
+
if (existsSync(project) && statSync(project).isDirectory())
|
|
699
|
+
project = join(project, 'tsconfig.json');
|
|
700
|
+
if (!isFile(project))
|
|
701
|
+
return manual(`its \`tsc\` project ${inside(gitRoot, project)} does not exist`);
|
|
702
|
+
let compile;
|
|
703
|
+
try {
|
|
704
|
+
compile = effectiveCompile(project);
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
return manual(`the options its compile takes effect with are unknown: ${error instanceof Error ? error.message : String(error)}`);
|
|
708
|
+
}
|
|
709
|
+
if (compile.noEmit || compile.emitDeclarationOnly)
|
|
710
|
+
return manual(`${inside(gitRoot, project)} (with what it extends) emits no JavaScript`);
|
|
711
|
+
if (compile.outFile)
|
|
712
|
+
return manual(`${inside(gitRoot, project)} (with what it extends) bundles the output into one file (outFile)`);
|
|
713
|
+
const outOption = option(tsc, '--outDir');
|
|
714
|
+
const outDir = outOption ? resolve(appDir, outOption) : compile.outDir;
|
|
715
|
+
if (outDir === null)
|
|
716
|
+
return manual('its compile declares no outDir (in its config or anything it extends), so its output sits beside the sources');
|
|
717
|
+
// The sources the output mirrors: rootDir when declared, or a single included directory (tsc's inferred root then).
|
|
718
|
+
const rootOption = option(tsc, '--rootDir');
|
|
719
|
+
const patterns = compile.include?.patterns;
|
|
720
|
+
const rootDir = rootOption ? resolve(appDir, rootOption) : compile.rootDir
|
|
721
|
+
?? (Array.isArray(patterns) && patterns.length === 1 && typeof patterns[0] === 'string' && !/[*?{]/.test(patterns[0])
|
|
722
|
+
? resolve(compile.include.base, patterns[0]) : null);
|
|
723
|
+
if (rootDir === null)
|
|
724
|
+
return manual('its compile declares no rootDir (and includes more than one directory), so which sources the output mirrors is not declared');
|
|
725
|
+
// The server's entry: package.json main, or the start script's `node <file>`; it must be tsc's output of one source file.
|
|
726
|
+
const start = typeof scripts.start === 'string' ? scripts.start.trim().split(/\s+/) : [];
|
|
727
|
+
const entryPath = typeof pkg.json.main === 'string' ? resolve(appDir, pkg.json.main)
|
|
728
|
+
: start.length === 2 && start[0] === 'node' ? resolve(appDir, start[1]) : null;
|
|
729
|
+
if (entryPath === null || relative(outDir, entryPath).startsWith('..')) {
|
|
730
|
+
return manual(`neither package.json main nor a \`node <file>\` start script names the server's entry inside ${inside(gitRoot, outDir)}`);
|
|
731
|
+
}
|
|
732
|
+
const emitted = relative(outDir, entryPath);
|
|
733
|
+
const extension = extname(emitted);
|
|
734
|
+
const sources = (EMITTED_FROM[extension] ?? []).map(source => join(rootDir, emitted.slice(0, -extension.length) + source));
|
|
735
|
+
if (!sources.some(isFile)) {
|
|
736
|
+
return manual(`the entry ${inside(gitRoot, entryPath)} is not tsc's output of a source file under ${inside(gitRoot, rootDir)}`);
|
|
737
|
+
}
|
|
738
|
+
const outArg = relative(appDir, outDir).split(sep).join('/');
|
|
739
|
+
const entryArg = emitted.split(sep).join('/');
|
|
740
|
+
const sourceArg = rootDir === join(dirname(outDir), 'src') ? null : relative(appDir, rootDir).split(sep).join('/') || '.';
|
|
741
|
+
if (![outArg, entryArg, sourceArg ?? 'src'].every(path => SAFE_PATH.test(path)))
|
|
742
|
+
return manual('its paths need quoting in a script');
|
|
743
|
+
const instrument = `${INSTRUMENT_COMMAND} ${outArg} --entry ${entryArg}${sourceArg ? ` --source-root ${sourceArg}` : ''}`;
|
|
744
|
+
const json = structuredClone(pkg.json);
|
|
745
|
+
json.scripts = { ...scripts, build: `${build} && ${instrument}` };
|
|
746
|
+
const edited = packageText(pkg, json);
|
|
747
|
+
const dependency = planDependency(gitRoot, appDir, pkg, edited, need);
|
|
748
|
+
if (dependency.kind === 'manual')
|
|
749
|
+
return manual(dependency.why);
|
|
750
|
+
const changes = [change(gitRoot, pkg.path, `Instruments the server's compiled output for telemetry after each build (\`${instrument}\`); `
|
|
751
|
+
+ 'application source is not changed.', pkg.text, edited)];
|
|
752
|
+
// The install runs after this edit (its result is checked, and every file init touched is restored if it fails).
|
|
753
|
+
if (dependency.kind === 'change')
|
|
754
|
+
changes.push(dependency.change);
|
|
755
|
+
return { changes, server: { status: 'installed', kind: 'node', distDir: null }, covers: true };
|
|
756
|
+
}
|
|
757
|
+
/* -------------------------------------------------------------- settings */
|
|
758
|
+
/** The running CLI's own command line (`haystack ...`, whichever way it was invoked). */
|
|
759
|
+
function ownCli() {
|
|
760
|
+
return [process.execPath, process.argv[1]];
|
|
761
|
+
}
|
|
762
|
+
/* CONNECT(lane C): a proposal is not an approval (rule 9c). `haystack telemetry settings --propose`, run at the repository
|
|
763
|
+
* root, writes <git root>/.haystack/telemetry-settings.proposed.json; a PERSON promotes it with `haystack telemetry
|
|
764
|
+
* settings --approve` (which shows the diff) into .haystack/telemetry-settings.json, the only file instrumentation
|
|
765
|
+
* honours. init runs the proposal and never approves. Until this CLI has both, the settings are a notice. */
|
|
766
|
+
function settingsCommandAvailable() {
|
|
767
|
+
const [node, cli] = ownCli();
|
|
768
|
+
const help = spawnSync(node, [cli, 'telemetry', 'settings', '--help'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
769
|
+
return help.status === 0 && help.stdout.includes('--propose') && help.stdout.includes('--approve');
|
|
770
|
+
}
|
|
771
|
+
function planSettings(gitRoot, notices) {
|
|
772
|
+
if (existsSync(join(gitRoot, SETTINGS_FILE)))
|
|
773
|
+
return { changes: [], settings: 'approved' };
|
|
774
|
+
const target = join(gitRoot, SETTINGS_PROPOSAL);
|
|
775
|
+
if (existsSync(target))
|
|
776
|
+
return { changes: [], settings: 'proposed' };
|
|
777
|
+
if (!settingsCommandAvailable()) {
|
|
778
|
+
notices.push(`This CLI cannot propose telemetry settings yet (\`haystack telemetry settings --propose\`), so server telemetry records only `
|
|
779
|
+
+ 'shapes and sizes, never a setting\'s value. Run init again after updating the CLI.');
|
|
780
|
+
return { changes: [], settings: null };
|
|
781
|
+
}
|
|
782
|
+
const [node, cli] = ownCli();
|
|
783
|
+
return { settings: 'proposed', changes: [{
|
|
784
|
+
path: SETTINGS_PROPOSAL, action: 'create', target, content: '',
|
|
785
|
+
reason: 'Proposes which settings server telemetry may record by value (each a literal union, enum or `as const` list in the typed source); '
|
|
786
|
+
+ `everything else is recorded only as its shape and size. A proposal records nothing: your user approves it with \`haystack telemetry `
|
|
787
|
+
+ `settings --approve\`, which writes ${SETTINGS_FILE}.`,
|
|
788
|
+
diff: ['+ (the proposal `haystack telemetry settings --propose` writes; shown after it runs)'],
|
|
789
|
+
run: { argv: [node, cli, 'telemetry', 'settings', '--propose'], cwd: gitRoot, touches: [] },
|
|
790
|
+
}] };
|
|
791
|
+
}
|
|
792
|
+
/* --------------------------------------------------------------- capture */
|
|
793
|
+
/** What the agent must ask its user before the browser tag can be set up (rules 2 and 8b: origins and consent are the
|
|
794
|
+
* user's answers, never inferred). */
|
|
795
|
+
function captureQuestions(origins, consent) {
|
|
796
|
+
return [
|
|
797
|
+
...(origins.length === 0 ? ['Ask your user for the app\'s production origins (for example https://app.example.com), then run '
|
|
798
|
+
+ '`haystack init` again with `--origin <origin>` for each.'] : []),
|
|
799
|
+
...(consent === undefined ? ['Ask your user whether the site asks visitors for consent, and with which consent platform, or whether '
|
|
800
|
+
+ 'they have decided consent is not required; then run `haystack init` again with `--consent <platform>` or `--consent not-required`.'] : []),
|
|
801
|
+
];
|
|
802
|
+
}
|
|
803
|
+
/* ------------------------------------------------------------------ plan */
|
|
804
|
+
/** Everything init would change for telemetry in this checkout; reads only. Planned on every run (telemetry is part of
|
|
805
|
+
* onboarding): when an answer is missing (which app, in a repository with several; its origins; the consent choice),
|
|
806
|
+
* only the telemetry parts stop, with steps saying what the agent must ask its user. */
|
|
807
|
+
export async function planTelemetry(gitRoot, repository, options) {
|
|
808
|
+
const notices = [];
|
|
809
|
+
let appDir;
|
|
810
|
+
if (options.app !== undefined) {
|
|
811
|
+
const requested = resolve(gitRoot, options.app);
|
|
812
|
+
if (!existsSync(requested) || !statSync(requested).isDirectory())
|
|
813
|
+
throw new Error(`--app ${options.app} is not a directory.`);
|
|
814
|
+
appDir = realpathSync(requested);
|
|
815
|
+
if (inside(gitRoot, appDir).startsWith('..') || isAbsolute(inside(gitRoot, appDir)))
|
|
816
|
+
throw new Error(`--app ${options.app} is outside this repository.`);
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
// One rule for which directories are apps (capture/adapters/index.ts): a web framework an adapter claims, or a package
|
|
820
|
+
// with a start script.
|
|
821
|
+
await loadBabel();
|
|
822
|
+
const apps = await discoverApps(gitRoot);
|
|
823
|
+
if (apps.length !== 1) {
|
|
824
|
+
// Rule 8(a): init never picks among several apps, and never guesses one where it found none.
|
|
825
|
+
const ask = apps.length === 0
|
|
826
|
+
? 'init found no app here (no web framework it recognizes and no package with a start script). If the repository has one that serves production, run '
|
|
827
|
+
+ '`haystack init` again with `--app <its directory>`.'
|
|
828
|
+
: `Ask your user which of these apps serves production: ${apps.join(', ')}; then run \`haystack init\` again with \`--app <that directory>\`.`;
|
|
829
|
+
const questions = [ask, ...captureQuestions(options.origins, options.consent)];
|
|
830
|
+
// A notice too, so a preview (which reports no telemetry yet) already says what to ask.
|
|
831
|
+
notices.push(`Telemetry waits for your user's answers: ${questions.join(' ')}`);
|
|
832
|
+
const waiting = { status: 'manual', steps: questions, received: null };
|
|
833
|
+
return { app: null, changes: [], notices, server: { status: 'manual', kind: null, steps: [ask] }, settings: null, capture: waiting,
|
|
834
|
+
captureRegistration: null };
|
|
835
|
+
}
|
|
836
|
+
appDir = realpathSync(join(gitRoot, apps[0]));
|
|
837
|
+
}
|
|
838
|
+
const app = inside(gitRoot, appDir);
|
|
839
|
+
const changes = [];
|
|
840
|
+
let server;
|
|
841
|
+
const manifest = join(appDir, 'package.json');
|
|
842
|
+
// CONNECT(lane D1): browser capture (init-capture.ts) plans with the answers in effect (flags, else the ones init recorded
|
|
843
|
+
// for this app); a missing answer is a question, never a guess.
|
|
844
|
+
const answers = captureAnswers(appDir, options.origins, options.consent);
|
|
845
|
+
const questions = captureQuestions(answers.origins, answers.consent);
|
|
846
|
+
const capture = questions.length > 0 ? null
|
|
847
|
+
: await planCapture({ gitRoot, appDir, repository, answers, registration: options.registration });
|
|
848
|
+
let capturePart = capture?.part ?? { status: 'manual', steps: questions, received: null };
|
|
849
|
+
if (capture) {
|
|
850
|
+
changes.push(...capture.changes);
|
|
851
|
+
notices.push(...capture.notices);
|
|
852
|
+
}
|
|
853
|
+
if (!isFile(manifest)) {
|
|
854
|
+
server = { status: 'unsupported', why: `${app} has no package.json: server telemetry supports Node.js apps (Next.js, or a Node server built with \`tsc\`).` };
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
let pkg = readPackage(manifest);
|
|
858
|
+
// Capture's build step joins package.json first, the server part's edits build on it, and the CLI is pinned once for
|
|
859
|
+
// every part that runs it (a failed install restores every file, so no build step outlives its CLI).
|
|
860
|
+
let extra = null;
|
|
861
|
+
if (capture?.needsCli) {
|
|
862
|
+
const text = capture.packageText ?? pkg.text;
|
|
863
|
+
const probe = planDependency(gitRoot, appDir, packageFrom(manifest, text), text, captureNeed());
|
|
864
|
+
if (probe.kind === 'manual') {
|
|
865
|
+
capturePart = capturePart.status === 'unsupported' ? capturePart
|
|
866
|
+
: { status: 'manual', steps: [...(capturePart.status === 'manual' ? capturePart.steps : []), `Publish the route manifest: ${probe.why}.`], received: null };
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
if (capture.packageText !== null) {
|
|
870
|
+
changes.push(change(gitRoot, manifest, 'Publishes the route manifest after (or, for frameworks that assemble their output inside the build, before) '
|
|
871
|
+
+ 'each build with the CLI the app pins (CAPTURE-V1 rule 4).', pkg.text, capture.packageText));
|
|
872
|
+
pkg = packageFrom(manifest, capture.packageText);
|
|
873
|
+
}
|
|
874
|
+
extra = captureNeed();
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
const isNext = section(pkg.json, 'dependencies').next !== undefined || section(pkg.json, 'devDependencies').next !== undefined;
|
|
878
|
+
const planned = isNext ? await planNext(gitRoot, appDir, pkg, extra) : planNode(gitRoot, appDir, pkg, extra);
|
|
879
|
+
server = planned.server;
|
|
880
|
+
changes.push(...planned.changes);
|
|
881
|
+
if (extra !== null && !planned.covers) {
|
|
882
|
+
const dependency = planDependency(gitRoot, appDir, pkg, pkg.text, extra);
|
|
883
|
+
if (dependency.kind === 'change')
|
|
884
|
+
changes.push(dependency.change);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
let settings = null;
|
|
888
|
+
if (server.status !== 'unsupported') {
|
|
889
|
+
const proposed = planSettings(gitRoot, notices);
|
|
890
|
+
changes.push(...proposed.changes);
|
|
891
|
+
settings = proposed.settings;
|
|
892
|
+
}
|
|
893
|
+
if (questions.length > 0)
|
|
894
|
+
notices.push(`Telemetry waits for your user's answers: ${questions.join(' ')}`);
|
|
895
|
+
return { app, changes, notices, server, settings, capture: capturePart, captureRegistration: capture?.registration ?? null };
|
|
896
|
+
}
|
|
897
|
+
/* ---------------------------------------------------------------- report */
|
|
898
|
+
function received(lastSeenAt, release) {
|
|
899
|
+
if (lastSeenAt === null)
|
|
900
|
+
return null;
|
|
901
|
+
const at = Date.parse(lastSeenAt);
|
|
902
|
+
if (!Number.isFinite(at))
|
|
903
|
+
throw new Error(`Telemetry evidence has an unreadable time ${lastSeenAt}.`);
|
|
904
|
+
return { lastSeenAt, release, stale: Date.now() - at > CAPTURE_EVIDENCE_FRESH_HOURS * 3_600_000 };
|
|
905
|
+
}
|
|
906
|
+
async function readJson(path, token) {
|
|
907
|
+
const response = await gatewayFetch(path, token);
|
|
908
|
+
if (response.status === 404) {
|
|
909
|
+
await response.body?.cancel();
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
if (!response.ok)
|
|
913
|
+
throw await classifyHttpError(response, `Haystack API ${path.split('?')[0]}`);
|
|
914
|
+
return response.json();
|
|
915
|
+
}
|
|
916
|
+
/** Lane A's registration of the application: its newest capture event, or null when it is not registered (404) or has none. */
|
|
917
|
+
async function captureReceived(repository, app, token) {
|
|
918
|
+
const status = await readJson(`${CAPTURE_STATUS_PATH}?repository=${encodeURIComponent(repository)}&app=${encodeURIComponent(app)}`, token);
|
|
919
|
+
return status === null ? null : received(status.lastSeenAt, status.release);
|
|
920
|
+
}
|
|
921
|
+
function withReceived(part, evidence) {
|
|
922
|
+
return part.status === 'unsupported' ? part : { ...part, received: evidence };
|
|
923
|
+
}
|
|
924
|
+
const POSTGRES_WORD = /\bpostgres(?:ql)?\b/i;
|
|
925
|
+
/** Rule 8(d): the profiler reads Postgres. The plan is the proof once onboarding has one; before that, the database the
|
|
926
|
+
* coding agent's own notes name (a word match on its run note against the one engine name, nothing looser). */
|
|
927
|
+
function dbProfile(repository, onboarding, notes) {
|
|
928
|
+
const planned = onboarding?.state === 'ready' ? onboarding.services.filter(service => service.engine === 'postgres') : null;
|
|
929
|
+
if (planned ? planned.length === 0 : !(notes && POSTGRES_WORD.test(notes.run)))
|
|
930
|
+
return null;
|
|
931
|
+
const command = `haystack db profile --url-env ${PROFILE_URL_ENV} --out ${PROFILE_FILE}`;
|
|
932
|
+
return { command, steps: [
|
|
933
|
+
`Ask your user to profile production's data (a person's action: it needs read access to production's Postgres, which you never hold). `
|
|
934
|
+
+ `With ${PROFILE_URL_ENV} set to a read-only connection string for it (a read replica is best), they run:`,
|
|
935
|
+
` ${command}`,
|
|
936
|
+
` haystack db profile show ${PROFILE_FILE}`,
|
|
937
|
+
` haystack db profile upload ${PROFILE_FILE} --repo ${repository}`,
|
|
938
|
+
...(planned && planned.length > 1
|
|
939
|
+
? [`The onboarding plan has ${planned.length} Postgres databases (${planned.map(service => service.id).join(', ')}); `
|
|
940
|
+
+ '`haystack db profile upload --help` says how a profile names the one it describes.'] : []),
|
|
941
|
+
] };
|
|
942
|
+
}
|
|
943
|
+
/** Rule 8(e): the report, and what the person does for telemetry to arrive (the token, production's environment, the start,
|
|
944
|
+
* the deploy) with the off switches (rule 13c). `onboarding` and `notes` feed the data profile. */
|
|
945
|
+
export async function telemetryReport(plan, context) {
|
|
946
|
+
const { repository, token } = context;
|
|
947
|
+
const steps = [];
|
|
948
|
+
const serverPart = plan.server.status === 'unsupported' ? { status: 'unsupported', why: plan.server.why }
|
|
949
|
+
: plan.server.status === 'manual' ? { status: 'manual', steps: plan.server.steps, received: null } : { status: 'installed', received: null };
|
|
950
|
+
let server = serverPart;
|
|
951
|
+
// Answers only the user can give come first: until then the rest of telemetry waits (rule 8: init never guesses).
|
|
952
|
+
if (plan.app === null || plan.capture.status === 'manual') {
|
|
953
|
+
steps.push('Telemetry waits for answers only your user can give: ask what the `steps` of each `manual` part say, then run `haystack init` '
|
|
954
|
+
+ 'again with them.');
|
|
955
|
+
}
|
|
956
|
+
if (plan.app !== null && plan.server.status !== 'unsupported') {
|
|
957
|
+
const status = await readTelemetryServerStatus(repository, token);
|
|
958
|
+
server = withReceived(serverPart, status.newestFlush === null ? null : received(status.newestFlush.storedAt, status.newestFlush.buildId));
|
|
959
|
+
if (plan.settings === 'proposed') {
|
|
960
|
+
// A proposal is not an approval (rule 9c): only the user approves, with the command that shows them the diff.
|
|
961
|
+
steps.push(`Show your user ${SETTINGS_PROPOSAL}: the settings whose values server telemetry would record (every other value is recorded only `
|
|
962
|
+
+ 'as its shape and size). Nothing is recorded until THEY approve it by running `haystack telemetry settings --approve` themselves '
|
|
963
|
+
+ `(it shows the diff and writes ${SETTINGS_FILE}); never approve it yourself. Then commit ${SETTINGS_FILE}.`);
|
|
964
|
+
}
|
|
965
|
+
steps.push(status.token === null
|
|
966
|
+
? `Ask your user to run \`haystack telemetry token --repo ${status.repository}\` in their own terminal (it needs admin or maintain `
|
|
967
|
+
+ 'permission on the repository). Never run it yourself: it creates a production credential, writes it to a file only they can read, and never prints it.'
|
|
968
|
+
: `A telemetry token is already active for ${status.repository} (minted ${status.token.createdAt}). If production does not have it, ask your `
|
|
969
|
+
+ `user to run \`haystack telemetry token --repo ${status.repository}\` in their own terminal: it says which file holds it, and \`--rotate\` replaces a lost one.`);
|
|
970
|
+
steps.push('Have your user set these in production\'s server environment, never as public or client-side variables: HAYSTACK_TELEMETRY=1, '
|
|
971
|
+
+ `HAYSTACK_TELEMETRY_ENDPOINT=${status.endpoint}, and HAYSTACK_TELEMETRY_TOKEN set to the contents of the token file.`);
|
|
972
|
+
if (plan.server.status === 'installed' && plan.server.kind === 'next') {
|
|
973
|
+
const runtime = posix.join(plan.app, plan.server.distDir ?? '.next', '.haystack-telemetry', 'register.cjs');
|
|
974
|
+
steps.push(`Start the production server (\`next start\` or a standalone server.js) with NODE_OPTIONS=--require=<absolute path of ${runtime} `
|
|
975
|
+
+ 'where the app runs>; `next build` prints the exact line. A standalone image must also copy that .haystack-telemetry directory. Set it only '
|
|
976
|
+
+ 'where a build with the wrapper produced that file: Node refuses to start when a --require file is missing.');
|
|
977
|
+
}
|
|
978
|
+
steps.push(`Deploy. Then run \`haystack init --json\` again with the same --app, --origin and --consent: \`telemetry.server.received\` `
|
|
979
|
+
+ 'shows the newest flush Haystack holds.');
|
|
980
|
+
}
|
|
981
|
+
// Rule 13(c): the switches that need nothing of Haystack's, for each part set up.
|
|
982
|
+
const switches = [
|
|
983
|
+
...(plan.app !== null && plan.server.status !== 'unsupported'
|
|
984
|
+
? ['HAYSTACK_TELEMETRY=0 in production\'s environment turns server telemetry off at the next restart'] : []),
|
|
985
|
+
...(plan.app !== null && plan.capture.status !== 'unsupported' ? ['removing the tag turns browser capture off'] : []),
|
|
986
|
+
];
|
|
987
|
+
if (switches.length > 0)
|
|
988
|
+
steps.push(`Off switches your user controls without Haystack: ${switches.join(', and ')}.`);
|
|
989
|
+
const capture = plan.capture.status === 'unsupported' || plan.app === null ? plan.capture
|
|
990
|
+
: withReceived(plan.capture, await captureReceived(repository, plan.app, token));
|
|
991
|
+
// Before a plan, the notes this run gave, else the ones the repository already has.
|
|
992
|
+
const notes = context.onboarding?.state === 'ready' ? null
|
|
993
|
+
: context.notes ?? (await readFacts(repository, token)).facts.notes ?? null;
|
|
994
|
+
const profile = dbProfile(repository, context.onboarding, notes);
|
|
995
|
+
if (profile)
|
|
996
|
+
steps.push(...profile.steps);
|
|
997
|
+
return { telemetry: { capture, server, dbProfileCommand: profile?.command ?? null }, steps };
|
|
998
|
+
}
|
|
999
|
+
/** The report as lines for a person. */
|
|
1000
|
+
export function formatTelemetry(telemetry, steps) {
|
|
1001
|
+
const part = (name, value) => {
|
|
1002
|
+
if (value.status === 'unsupported')
|
|
1003
|
+
return [`${name}: unsupported. ${value.why}`];
|
|
1004
|
+
const evidence = value.received === null ? 'nothing received yet'
|
|
1005
|
+
: `last received ${value.received.lastSeenAt}${value.received.release ? ` (release ${value.received.release})` : ''}${value.received.stale ? ', stale' : ''}`;
|
|
1006
|
+
return [`${name}: ${value.status === 'installed' ? 'set up' : 'needs a manual step'}; ${evidence}.`,
|
|
1007
|
+
...(value.status === 'manual' ? value.steps.map(step => ` ${step}`) : [])];
|
|
1008
|
+
};
|
|
1009
|
+
return [chalk.bold('Telemetry'), ...part('Browser capture', telemetry.capture), ...part('Server telemetry', telemetry.server),
|
|
1010
|
+
...(steps.length > 0 ? ['', chalk.bold('Next, for telemetry to arrive:'), ...steps.map(step => (step.startsWith(' ') ? step : `• ${step}`))] : [])];
|
|
1011
|
+
}
|