@haystackeditor/cli 0.24.1 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
  2. package/dist/assets/capture/release.json +4 -0
  3. package/dist/assets/telemetry/runtime.cjs +829 -1254
  4. package/dist/capture/adapters/client-routes.js +383 -0
  5. package/dist/capture/adapters/django.js +134 -0
  6. package/dist/capture/adapters/files.js +77 -0
  7. package/dist/capture/adapters/index.js +74 -0
  8. package/dist/capture/adapters/jsx-edit.js +81 -0
  9. package/dist/capture/adapters/next-build.js +113 -0
  10. package/dist/capture/adapters/next.js +494 -0
  11. package/dist/capture/adapters/nuxt.js +199 -0
  12. package/dist/capture/adapters/rails.js +178 -0
  13. package/dist/capture/adapters/react-router.js +439 -0
  14. package/dist/capture/adapters/sveltekit.js +109 -0
  15. package/dist/capture/adapters/types.js +4 -0
  16. package/dist/capture/adapters/vite.js +135 -0
  17. package/dist/capture/app-config.js +107 -0
  18. package/dist/capture/consent.js +127 -0
  19. package/dist/capture/csp.js +332 -0
  20. package/dist/capture/html.js +74 -0
  21. package/dist/capture/js-ast.js +400 -0
  22. package/dist/capture/manifest.js +95 -0
  23. package/dist/capture/project.js +177 -0
  24. package/dist/capture/route-pattern.js +119 -0
  25. package/dist/capture/script-release.js +47 -0
  26. package/dist/capture/tag.js +74 -0
  27. package/dist/capture/url-rewrites.js +232 -0
  28. package/dist/capture-step.js +56 -0
  29. package/dist/commands/capture-brief.js +92 -0
  30. package/dist/commands/capture-contract.js +46 -0
  31. package/dist/commands/capture-manifest.js +86 -0
  32. package/dist/commands/init-capture.js +426 -0
  33. package/dist/commands/init-telemetry.js +1028 -0
  34. package/dist/commands/init.js +78 -5
  35. package/dist/commands/server-telemetry-contract.d.ts +66 -0
  36. package/dist/commands/server-telemetry-contract.js +127 -0
  37. package/dist/commands/telemetry-token.js +238 -0
  38. package/dist/commands/telemetry.d.ts +161 -8
  39. package/dist/commands/telemetry.js +940 -158
  40. package/dist/commands/verify-onboarding.js +21 -1
  41. package/dist/commands/verify.js +56 -9
  42. package/dist/index.js +85 -6
  43. package/dist/schema.js +2 -2
  44. package/dist/telemetry/next-loader.cjs +66 -9
  45. package/dist/telemetry/next.d.ts +11 -3
  46. package/dist/telemetry/next.js +95 -15
  47. package/dist/telemetry/typed-source.d.ts +47 -0
  48. package/dist/telemetry/typed-source.js +379 -0
  49. package/package.json +4 -2
  50. package/schemas/init.v1.json +63 -4
  51. package/schemas/pre-verify.v1.json +60 -3
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Babel-based reading and editing of the app's JS/TS files for capture setup (CAPTURE-V1 rule 8: init edits only what it
3
+ * understands). Every edit is located on the parsed AST and spliced into the original text at the node's offsets, so
4
+ * the customer's formatting, comments and quotes stay as they were; the result is parsed again before it is offered,
5
+ * and a file that does not parse after the edit is never written.
6
+ */
7
+ import { existsSync, readFileSync, statSync } from 'node:fs';
8
+ import { dirname, extname, join, resolve } from 'node:path';
9
+ let babel;
10
+ /** @babel/core 8 is ESM-only; load it once before any parse (the same boundary as telemetry instrument). */
11
+ export async function loadBabel() {
12
+ babel ??= await import('@babel/core');
13
+ }
14
+ function pluginsFor(filename) {
15
+ const extension = extname(filename);
16
+ if (extension === '.ts' || extension === '.mts' || extension === '.cts')
17
+ return ['typescript'];
18
+ if (extension === '.tsx')
19
+ return ['jsx', 'typescript'];
20
+ return ['jsx'];
21
+ }
22
+ /** The file's AST, or null when it does not parse (the caller reports it and leaves the file alone). */
23
+ export function parseFile(code, filename) {
24
+ if (!babel)
25
+ throw new Error('loadBabel() must run before parseFile().');
26
+ try {
27
+ return babel.parseSync(code, {
28
+ filename,
29
+ babelrc: false,
30
+ configFile: false,
31
+ sourceType: 'unambiguous',
32
+ parserOpts: { plugins: pluginsFor(filename), errorRecovery: false },
33
+ });
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /** A JSON (or JSON-with-comments, like tsconfig) document as an AST: an object expression whose node offsets are offsets
40
+ * into `text` itself. */
41
+ export function parseJsonLike(text, filename) {
42
+ const ast = parseFile(`(${text}\n)`, `${filename}.js`);
43
+ if (!ast)
44
+ return null;
45
+ const statement = ast.program.body[0];
46
+ if (ast.program.body.length !== 1 || statement?.type !== 'ExpressionStatement')
47
+ return null;
48
+ return shift(statement.expression, -1);
49
+ }
50
+ function shift(node, by) {
51
+ walk(node, current => {
52
+ if (typeof current.start === 'number')
53
+ current.start += by;
54
+ if (typeof current.end === 'number')
55
+ current.end += by;
56
+ });
57
+ return node;
58
+ }
59
+ /** Babel's path of `target` in `ast`, with its scopes resolved (which name a binding at that place refers to), or null. */
60
+ export function pathOf(ast, target) {
61
+ if (!babel)
62
+ throw new Error('loadBabel() must run before pathOf().');
63
+ let found = null;
64
+ babel.traverse(ast, {
65
+ enter(path) {
66
+ if (path.node === target) {
67
+ found = path;
68
+ path.stop();
69
+ }
70
+ },
71
+ });
72
+ return found;
73
+ }
74
+ /** Every local name `import X from '<source>'` binds (a module may import the same default twice). */
75
+ export function defaultImportNames(ast, source) {
76
+ return ast.program.body.flatMap((statement) => (statement.type === 'ImportDeclaration' && statement.source.value === source
77
+ && statement.importKind !== 'type'
78
+ ? statement.specifiers.filter((specifier) => specifier.type === 'ImportDefaultSpecifier').map((specifier) => specifier.local.name)
79
+ : []));
80
+ }
81
+ /** Depth-first over every node; `visit` returning false skips that node's children. */
82
+ export function walk(node, visit, parent = null) {
83
+ if (!node || typeof node.type !== 'string')
84
+ return;
85
+ if (visit(node, parent) === false)
86
+ return;
87
+ for (const key of Object.keys(node)) {
88
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra' || key === 'leadingComments'
89
+ || key === 'trailingComments' || key === 'innerComments')
90
+ continue;
91
+ const value = node[key];
92
+ if (Array.isArray(value))
93
+ for (const child of value)
94
+ walk(child, visit, node);
95
+ else if (value && typeof value.type === 'string')
96
+ walk(value, visit, node);
97
+ }
98
+ }
99
+ export function unwrapTs(node) {
100
+ let current = node;
101
+ while (current && (current.type === 'TSAsExpression' || current.type === 'TSSatisfiesExpression'
102
+ || current.type === 'TSNonNullExpression' || current.type === 'ParenthesizedExpression' || current.type === 'TSTypeAssertion'
103
+ || current.type === 'AwaitExpression')) {
104
+ current = current.expression ?? current.argument;
105
+ }
106
+ return current;
107
+ }
108
+ export function keyName(property) {
109
+ if (property.computed)
110
+ return property.key.type === 'StringLiteral' ? property.key.value : null;
111
+ if (property.key.type === 'Identifier')
112
+ return property.key.name;
113
+ if (property.key.type === 'StringLiteral')
114
+ return property.key.value;
115
+ return null;
116
+ }
117
+ /** The top-level `const name = <init>` of the module, or null. */
118
+ export function topLevelConst(ast, name) {
119
+ for (const statement of ast.program.body) {
120
+ const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement;
121
+ if (declaration?.type !== 'VariableDeclaration')
122
+ continue;
123
+ for (const declarator of declaration.declarations) {
124
+ if (declarator.id.type === 'Identifier' && declarator.id.name === name && declarator.init)
125
+ return declarator.init;
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+ export const UNREADABLE = Symbol('unreadable');
131
+ /** The static value of an expression built only from literals, arrays, objects and top-level consts; UNREADABLE
132
+ * otherwise (init never evaluates the customer's code). */
133
+ export function literalValue(node, ast, depth = 0) {
134
+ const current = unwrapTs(node);
135
+ if (!current || depth > 20)
136
+ return UNREADABLE;
137
+ switch (current.type) {
138
+ case 'StringLiteral':
139
+ case 'NumericLiteral':
140
+ case 'BooleanLiteral': return current.value;
141
+ case 'NullLiteral': return null;
142
+ case 'TemplateLiteral':
143
+ return current.expressions.length === 0 ? current.quasis.map((quasi) => quasi.value.cooked).join('') : UNREADABLE;
144
+ case 'Identifier': {
145
+ if (current.name === 'undefined')
146
+ return UNREADABLE;
147
+ const init = topLevelConst(ast, current.name);
148
+ return init ? literalValue(init, ast, depth + 1) : UNREADABLE;
149
+ }
150
+ case 'ArrayExpression': {
151
+ const values = [];
152
+ for (const element of current.elements) {
153
+ if (!element || element.type === 'SpreadElement')
154
+ return UNREADABLE;
155
+ const value = literalValue(element, ast, depth + 1);
156
+ if (value === UNREADABLE)
157
+ return UNREADABLE;
158
+ values.push(value);
159
+ }
160
+ return values;
161
+ }
162
+ case 'ObjectExpression': {
163
+ const value = {};
164
+ for (const property of current.properties) {
165
+ if (property.type !== 'ObjectProperty')
166
+ return UNREADABLE;
167
+ const name = keyName(property);
168
+ if (name === null)
169
+ return UNREADABLE;
170
+ const inner = literalValue(property.value, ast, depth + 1);
171
+ if (inner === UNREADABLE)
172
+ return UNREADABLE;
173
+ value[name] = inner;
174
+ }
175
+ return value;
176
+ }
177
+ default: return UNREADABLE;
178
+ }
179
+ }
180
+ /** The object a config module exports: `export default <x>` / `module.exports = <x>`, through `defineConfig(...)`-style
181
+ * wrappers (the first argument of each call), identifiers bound to top-level consts, and functions with one returned
182
+ * object. null when the module's config object is not literally there. */
183
+ export function configObject(ast) {
184
+ let exported = null;
185
+ for (const statement of ast.program.body) {
186
+ if (statement.type === 'ExportDefaultDeclaration')
187
+ exported = statement.declaration;
188
+ if (statement.type === 'ExpressionStatement' && statement.expression.type === 'AssignmentExpression') {
189
+ const left = statement.expression.left;
190
+ if (left.type === 'MemberExpression' && left.object.type === 'Identifier' && left.object.name === 'module'
191
+ && left.property.type === 'Identifier' && left.property.name === 'exports')
192
+ exported = statement.expression.right;
193
+ }
194
+ }
195
+ return exported ? unwrapConfig(exported, ast, 0) : null;
196
+ }
197
+ function unwrapConfig(node, ast, depth) {
198
+ const current = unwrapTs(node);
199
+ if (!current || depth > 10)
200
+ return null;
201
+ if (current.type === 'ObjectExpression')
202
+ return current;
203
+ if (current.type === 'Identifier') {
204
+ const init = topLevelConst(ast, current.name);
205
+ if (init)
206
+ return unwrapConfig(init, ast, depth + 1);
207
+ for (const statement of ast.program.body) {
208
+ if (statement.type === 'FunctionDeclaration' && statement.id?.name === current.name)
209
+ return returnedObject(statement, ast, depth);
210
+ }
211
+ return null;
212
+ }
213
+ if (current.type === 'CallExpression' && current.arguments.length > 0)
214
+ return unwrapConfig(current.arguments[0], ast, depth + 1);
215
+ if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression' || current.type === 'FunctionDeclaration') {
216
+ return returnedObject(current, ast, depth);
217
+ }
218
+ return null;
219
+ }
220
+ function returnedObject(fn, ast, depth) {
221
+ if (fn.body.type !== 'BlockStatement')
222
+ return unwrapConfig(fn.body, ast, depth + 1);
223
+ const returns = [];
224
+ walk(fn.body, (node) => {
225
+ if (node !== fn.body && (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression'))
226
+ return false;
227
+ if (node.type === 'ReturnStatement')
228
+ returns.push(node);
229
+ });
230
+ return returns.length === 1 && returns[0].argument ? unwrapConfig(returns[0].argument, ast, depth + 1) : null;
231
+ }
232
+ /** A key path in a config module (`['kit', 'paths', 'base']`). Absent only when the path is provably absent; unreadable
233
+ * (a manual step for the adapter) whenever the module's config is opaque: not a literal object init can find (imported,
234
+ * re-exported, computed, a function with several returns), or an object on the path with a spread or a computed key that
235
+ * could set the key (a property counts only when it comes after every such entry; the last of duplicates wins). */
236
+ export function readConfig(ast, path) {
237
+ const object = configObject(ast);
238
+ if (!object)
239
+ return { state: 'unreadable' };
240
+ let current = object;
241
+ for (const name of path) {
242
+ const resolved = unwrapTs(current);
243
+ if (resolved.type !== 'ObjectExpression')
244
+ return { state: 'unreadable' };
245
+ const members = resolved.properties;
246
+ let opaque = -1;
247
+ let found = -1;
248
+ members.forEach((member, index) => {
249
+ if (member.type === 'SpreadElement' || keyName(member) === null)
250
+ opaque = index;
251
+ else if (keyName(member) === name)
252
+ found = index;
253
+ });
254
+ if (found === -1)
255
+ return opaque === -1 ? { state: 'absent' } : { state: 'unreadable' };
256
+ if (found < opaque)
257
+ return { state: 'unreadable' };
258
+ const property = members[found];
259
+ if (property.type !== 'ObjectProperty')
260
+ return { state: 'unreadable' };
261
+ current = property.value;
262
+ }
263
+ const value = literalValue(current, ast);
264
+ return value === UNREADABLE ? { state: 'unreadable' } : { state: 'value', value };
265
+ }
266
+ export function applyEdits(code, edits) {
267
+ const ordered = [...edits].sort((a, b) => b.start - a.start);
268
+ for (let index = 1; index < ordered.length; index += 1) {
269
+ if (ordered[index].end > ordered[index - 1].start)
270
+ throw new Error('Overlapping edits.');
271
+ }
272
+ return ordered.reduce((text, edit) => `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`, code);
273
+ }
274
+ /** The whitespace that starts the line containing `offset`. */
275
+ export function lineIndent(code, offset) {
276
+ const lineStart = code.lastIndexOf('\n', offset - 1) + 1;
277
+ let end = lineStart;
278
+ while (end < code.length && (code[end] === ' ' || code[end] === '\t'))
279
+ end += 1;
280
+ return code.slice(lineStart, end);
281
+ }
282
+ /** True when only whitespace precedes `offset` on its line. */
283
+ export function startsLine(code, offset) {
284
+ const lineStart = code.lastIndexOf('\n', offset - 1) + 1;
285
+ return code.slice(lineStart, offset).trim() === '';
286
+ }
287
+ /** The indentation unit the file uses (two spaces when it cannot tell). */
288
+ export function indentUnit(code) {
289
+ for (const line of code.split('\n')) {
290
+ if (line.startsWith('\t'))
291
+ return '\t';
292
+ const spaces = line.length - line.trimStart().length;
293
+ if (spaces > 0 && line.trim() !== '' && !line.trimStart().startsWith('*'))
294
+ return ' '.repeat(Math.min(spaces, 4));
295
+ }
296
+ return ' ';
297
+ }
298
+ /** Where a new import statement goes: after the last import, else after the directive prologue ('use client'), else 0;
299
+ * with the text that must precede it so it starts its own line. */
300
+ export function importInsertion(ast, code) {
301
+ const imports = ast.program.body.filter((statement) => statement.type === 'ImportDeclaration');
302
+ if (imports.length > 0)
303
+ return { at: imports[imports.length - 1].end, before: '\n', after: '' };
304
+ const directives = ast.program.directives ?? [];
305
+ if (directives.length > 0)
306
+ return { at: directives[directives.length - 1].end, before: '\n\n', after: '' };
307
+ return { at: 0, before: '', after: code.startsWith('\n') ? '\n' : '\n\n' };
308
+ }
309
+ export function jsxName(element) {
310
+ const name = element.openingElement?.name;
311
+ if (!name)
312
+ return null;
313
+ if (name.type === 'JSXIdentifier')
314
+ return name.name;
315
+ return null;
316
+ }
317
+ export function jsxStringAttribute(element, name) {
318
+ for (const attribute of element.openingElement.attributes) {
319
+ if (attribute.type !== 'JSXAttribute' || attribute.name.type !== 'JSXIdentifier' || attribute.name.name !== name)
320
+ continue;
321
+ const value = attribute.value;
322
+ if (value?.type === 'StringLiteral')
323
+ return value.value;
324
+ if (value?.type === 'JSXExpressionContainer' && value.expression.type === 'StringLiteral')
325
+ return value.expression.value;
326
+ if (value?.type === 'JSXExpressionContainer' && value.expression.type === 'TemplateLiteral' && value.expression.expressions.length === 0) {
327
+ return value.expression.quasis[0].value.cooked;
328
+ }
329
+ }
330
+ return null;
331
+ }
332
+ /* ------------------------------------------------------------ imports */
333
+ const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.mts', '.vue', '.svelte'];
334
+ /** tsconfig/jsconfig `compilerOptions.paths` entries of the form `prefix/*` → `dir/*` (the aliases Vite and Nuxt apps
335
+ * import pages through), read without following `extends`. */
336
+ export function readPathAliases(appDir) {
337
+ for (const name of ['tsconfig.json', 'jsconfig.json', 'tsconfig.app.json']) {
338
+ const file = join(appDir, name);
339
+ if (!existsSync(file))
340
+ continue;
341
+ const parsed = parseJsonLike(readFileSync(file, 'utf8'), file);
342
+ if (!parsed)
343
+ continue;
344
+ const fake = { program: { body: [] } };
345
+ const value = literalValue(parsed, fake);
346
+ if (value === UNREADABLE || typeof value !== 'object' || value === null || Array.isArray(value))
347
+ continue;
348
+ const options = value.compilerOptions;
349
+ const paths = options?.paths;
350
+ if (!paths || typeof paths !== 'object')
351
+ continue;
352
+ const baseDir = resolve(appDir, typeof options?.baseUrl === 'string' ? options.baseUrl : '.');
353
+ const entries = Object.entries(paths).flatMap(([alias, targets]) => {
354
+ if (!alias.endsWith('/*') || !Array.isArray(targets))
355
+ return [];
356
+ const dirs = targets.filter((target) => typeof target === 'string' && target.endsWith('/*')).map(target => target.slice(0, -1));
357
+ return dirs.length > 0 ? [{ prefix: alias.slice(0, -1), targets: dirs }] : [];
358
+ });
359
+ if (entries.length > 0)
360
+ return { baseDir, entries };
361
+ }
362
+ return { baseDir: appDir, entries: [] };
363
+ }
364
+ function existingFile(base) {
365
+ const candidates = [base, ...RESOLVE_EXTENSIONS.map(extension => `${base}${extension}`),
366
+ ...RESOLVE_EXTENSIONS.map(extension => join(base, `index${extension}`))];
367
+ for (const candidate of candidates) {
368
+ try {
369
+ if (statSync(candidate).isFile())
370
+ return candidate;
371
+ }
372
+ catch { /* not this candidate */ }
373
+ }
374
+ return null;
375
+ }
376
+ /** The file a relative or aliased import names, or null (a package import, or nothing there). */
377
+ export function resolveImport(fromFile, specifier, aliases) {
378
+ if (specifier.startsWith('.'))
379
+ return existingFile(resolve(dirname(fromFile), specifier));
380
+ for (const entry of aliases.entries) {
381
+ if (!specifier.startsWith(entry.prefix))
382
+ continue;
383
+ for (const target of entry.targets) {
384
+ const found = existingFile(resolve(aliases.baseDir, target + specifier.slice(entry.prefix.length)));
385
+ if (found)
386
+ return found;
387
+ }
388
+ }
389
+ return null;
390
+ }
391
+ /** The source of the import that binds `local` in this module, or null. */
392
+ export function importSourceOf(ast, local) {
393
+ for (const statement of ast.program.body) {
394
+ if (statement.type !== 'ImportDeclaration')
395
+ continue;
396
+ if (statement.specifiers.some((specifier) => specifier.local.name === local))
397
+ return statement.source.value;
398
+ }
399
+ return null;
400
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The route manifest (CAPTURE-V1 rule 4) and its publication in the app's own static output.
3
+ *
4
+ * Release id: the first 16 hex of sha256(JSON.stringify({ routes, chunks })), routes in manifest order with keys in the
5
+ * order id, match, source, and chunks sorted: the same app rebuilt gives the same release, and ingest can recompute it.
6
+ * Chunk ids: the URL paths (from the origin root) of the JS files the build emitted, e.g. /_next/static/chunks/app/page-
7
+ * 3f1e.js or /assets/index-9ab2.js; the tag reports one only when the top frame's script URL has that path. Adapters
8
+ * that publish before their framework's final output exists declare none (errors then carry chunk null).
9
+ */
10
+ import { createHash } from 'node:crypto';
11
+ import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { CAPTURE_MANIFEST_MAX_BYTES, CAPTURE_ROUTE_ID_MAX, CAPTURE_VERSION } from '../commands/capture-contract.js';
14
+ import { compareRoutePatterns, parsePattern } from './route-pattern.js';
15
+ export const MANIFEST_DIR = '.well-known/haystack-capture';
16
+ /** Route id (rule 4): the first 16 hex of sha256(`${source}\n${match}`): the same across releases while the route's file
17
+ * and rule are. */
18
+ export function routeId(source, match) {
19
+ return createHash('sha256').update(`${source}\n${match}`).digest('hex').slice(0, 16);
20
+ }
21
+ /** The routes when every one is exact; else the manual step: a widened rule would attribute visits to the wrong route,
22
+ * and a dropped one to a broader route (init never guesses). */
23
+ export function exactRoutes(routes) {
24
+ if ('manual' in routes)
25
+ return routes;
26
+ const inexact = routes.filter(route => route.inexact);
27
+ if (inexact.length === 0)
28
+ return routes;
29
+ const first = inexact[0];
30
+ return { manual: `${inexact.length} route${inexact.length === 1 ? '' : 's'} cannot be expressed exactly in the route manifest's pattern syntax`
31
+ + ` (${first.source}: ${first.inexact}${inexact.length > 1 ? '; …' : ''}), so the manifest would attribute visits to the wrong routes` };
32
+ }
33
+ export function buildManifest(config, adapter, entries, chunks) {
34
+ for (const entry of entries)
35
+ parsePattern(entry.match);
36
+ const ordered = [...entries].sort((a, b) => compareRoutePatterns(a.match, b.match) || (a.rank ?? 0) - (b.rank ?? 0)
37
+ || (a.source < b.source ? -1 : a.source > b.source ? 1 : 0));
38
+ const seen = new Set();
39
+ const routes = ordered.filter(entry => !seen.has(entry.match) && seen.add(entry.match))
40
+ .map(entry => ({ id: routeId(entry.source, entry.match), match: entry.match, source: entry.source }));
41
+ // Rule 4: a route id is at most CAPTURE_ROUTE_ID_MAX characters; a longer one is a bug in routeId, never data to trim.
42
+ const tooLong = routes.find(route => route.id.length > CAPTURE_ROUTE_ID_MAX);
43
+ if (tooLong)
44
+ throw new Error(`Route id ${tooLong.id} is longer than ${CAPTURE_ROUTE_ID_MAX} characters.`);
45
+ const sortedChunks = [...new Set(chunks)].sort();
46
+ const release = createHash('sha256').update(JSON.stringify({ routes, chunks: sortedChunks })).digest('hex').slice(0, 16);
47
+ return {
48
+ version: CAPTURE_VERSION,
49
+ repositoryId: config.repositoryId,
50
+ applicationId: config.applicationId,
51
+ release,
52
+ adapter,
53
+ routes,
54
+ chunks: sortedChunks,
55
+ };
56
+ }
57
+ /** The manifest as published: refused over CAPTURE_MANIFEST_MAX_BYTES (rule 4 Publishing: the tag parses it on the page's
58
+ * main thread and stops for the page on a larger one). */
59
+ export function manifestDocument(manifest) {
60
+ const body = `${JSON.stringify(manifest)}\n`;
61
+ const bytes = Buffer.byteLength(body);
62
+ if (bytes > CAPTURE_MANIFEST_MAX_BYTES) {
63
+ throw new Error(`the route manifest is ${bytes} bytes, over CAPTURE_MANIFEST_MAX_BYTES (${CAPTURE_MANIFEST_MAX_BYTES}); ${manifest.routes.length} routes and ${manifest.chunks.length} chunks`);
64
+ }
65
+ return body;
66
+ }
67
+ /** A file replaced in one step (written beside it, then renamed over it), so a reader never sees half of it. */
68
+ function replaceFile(file, body) {
69
+ const temporary = `${file}.${process.pid}.tmp`;
70
+ writeFileSync(temporary, body);
71
+ renameSync(temporary, file);
72
+ }
73
+ /** Writes <release>.json and current.json (the same document) under each publish directory; returns the files written.
74
+ * Earlier releases' files stay (immutable): a tab still on the previous release keeps sending its release id. */
75
+ export function writeManifest(publishDirs, manifest) {
76
+ const body = manifestDocument(manifest);
77
+ return publishDirs.flatMap(dir => {
78
+ const target = join(dir, MANIFEST_DIR);
79
+ mkdirSync(target, { recursive: true });
80
+ const files = [join(target, `${manifest.release}.json`), join(target, 'current.json')];
81
+ for (const file of files)
82
+ replaceFile(file, body);
83
+ return files;
84
+ });
85
+ }
86
+ /** A build whose manifest could not be made: its current.json files go, so no build ships a manifest of another release
87
+ * (the tag then records nothing on this one); <release>.json files stay. Returns the files removed. */
88
+ export function withdrawCurrent(publishDirs) {
89
+ return publishDirs.map(dir => join(dir, MANIFEST_DIR, 'current.json')).filter(file => {
90
+ if (!existsSync(file))
91
+ return false;
92
+ unlinkSync(file);
93
+ return true;
94
+ });
95
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * What an adapter knows about the app before it touches anything: its package manifest, and the framework version the
3
+ * app actually builds with (CAPTURE-V1 rule 8b: version-aware adapters; an unknown version is a manual step). The
4
+ * version comes from the installed package when the checkout has one, else the lockfile's resolution, else an exact pin
5
+ * or a caret/tilde range in the manifest (which fixes the major, all an adapter's choices depend on).
6
+ */
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { dirname, join, posix, relative, sep } from 'node:path';
9
+ import { parse as parseYaml } from 'yaml';
10
+ export function toPosix(path) {
11
+ return path.split(sep).join('/');
12
+ }
13
+ /** A path in the repository (posix), for manifest sources and messages. */
14
+ export function repoPath(ctx, absolute) {
15
+ return posix.normalize(posix.join(ctx.appPath, toPosix(relative(ctx.appDir, absolute))));
16
+ }
17
+ /** A file of the app as the repository names it (`next.config.*` at the root, `apps/web/next.config.*` below it). */
18
+ export function appFile(ctx, name) {
19
+ return posix.join(ctx.appPath, name);
20
+ }
21
+ export function appContext(repoRoot, appDir, appPath = toPosix(relative(repoRoot, appDir)) || '.') {
22
+ return { repoRoot, appDir, appPath, pkg: readPackageJson(appDir) };
23
+ }
24
+ export function readPackageJson(dir) {
25
+ const file = join(dir, 'package.json');
26
+ if (!existsSync(file))
27
+ return null;
28
+ try {
29
+ return JSON.parse(readFileSync(file, 'utf8'));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function declaredRange(pkg, name) {
36
+ return pkg?.dependencies?.[name] ?? pkg?.devDependencies?.[name] ?? null;
37
+ }
38
+ function dirsUp(from, to) {
39
+ const dirs = [];
40
+ let current = from;
41
+ for (;;) {
42
+ dirs.push(current);
43
+ if (current === to || relative(to, current).startsWith('..'))
44
+ break;
45
+ const parent = dirname(current);
46
+ if (parent === current)
47
+ break;
48
+ current = parent;
49
+ }
50
+ return dirs;
51
+ }
52
+ function exact(version, from) {
53
+ const match = /^(\d+)\.\d+\.\d+/.exec(version);
54
+ return { version, major: match ? Number(match[1]) : null, from };
55
+ }
56
+ function fromRange(range) {
57
+ const trimmed = range.trim().replace(/^npm:[^@]+@/, '');
58
+ const match = /^[\^~]?(\d+)(?:\.(?:\d+|x|\*))*$/.exec(trimmed);
59
+ return { version: range, major: match ? Number(match[1]) : null, from: 'package.json' };
60
+ }
61
+ /** Parsed lockfiles by path: discovery reads the same monorepo lockfile for every package. */
62
+ const pnpmLocks = new Map();
63
+ function fromPnpmLock(file, importer, name) {
64
+ let lock = pnpmLocks.get(file);
65
+ if (!lock) {
66
+ lock = parseYaml(readFileSync(file, 'utf8'), { maxAliasCount: -1 });
67
+ pnpmLocks.set(file, lock);
68
+ }
69
+ const entry = lock.importers?.[importer];
70
+ const dependency = entry?.dependencies?.[name] ?? entry?.devDependencies?.[name];
71
+ const version = typeof dependency === 'string' ? dependency : dependency?.version;
72
+ if (!version || version.startsWith('link:'))
73
+ return null;
74
+ return version.split('(')[0];
75
+ }
76
+ function fromPackageLock(file, importer, name) {
77
+ const lock = JSON.parse(readFileSync(file, 'utf8'));
78
+ const nested = importer === '.' ? null : lock.packages?.[`${importer}/node_modules/${name}`]?.version;
79
+ return nested ?? lock.packages?.[`node_modules/${name}`]?.version ?? lock.dependencies?.[name]?.version ?? null;
80
+ }
81
+ /** yarn.lock (v1 and berry): the entry whose header lists `<name>@<range>`. */
82
+ function fromYarnLock(file, name, range) {
83
+ const wanted = new Set([`${name}@${range}`, `${name}@npm:${range}`]);
84
+ let inEntry = false;
85
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
86
+ if (line !== '' && !line.startsWith(' ') && !line.startsWith('#')) {
87
+ const specs = line.replace(/:$/, '').split(',').map(spec => spec.trim().replace(/^"|"$/g, ''));
88
+ inEntry = specs.some(spec => wanted.has(spec));
89
+ continue;
90
+ }
91
+ const version = inEntry ? /^ {2}version:? "?([^"\s]+)"?$/.exec(line) : null;
92
+ if (version)
93
+ return version[1];
94
+ }
95
+ return null;
96
+ }
97
+ /** The version of `name` the app builds with, or null when the app does not depend on it. */
98
+ export function resolveVersion(ctx, name) {
99
+ const range = declaredRange(ctx.pkg, name);
100
+ if (range === null)
101
+ return null;
102
+ const dirs = dirsUp(ctx.appDir, ctx.repoRoot);
103
+ for (const dir of dirs) {
104
+ const installed = join(dir, 'node_modules', name, 'package.json');
105
+ if (!existsSync(installed))
106
+ continue;
107
+ try {
108
+ const version = JSON.parse(readFileSync(installed, 'utf8')).version;
109
+ if (version)
110
+ return exact(version, repoPath(ctx, installed));
111
+ }
112
+ catch { /* an unreadable install says nothing; the lockfile decides */ }
113
+ }
114
+ for (const dir of dirs) {
115
+ const importer = toPosix(relative(dir, ctx.appDir)) || '.';
116
+ const readers = [
117
+ ['pnpm-lock.yaml', () => fromPnpmLock(join(dir, 'pnpm-lock.yaml'), importer, name)],
118
+ ['package-lock.json', () => fromPackageLock(join(dir, 'package-lock.json'), importer, name)],
119
+ ['yarn.lock', () => fromYarnLock(join(dir, 'yarn.lock'), name, range)],
120
+ ];
121
+ for (const [lockfile, read] of readers) {
122
+ if (!existsSync(join(dir, lockfile)))
123
+ continue;
124
+ let version = null;
125
+ try {
126
+ version = read();
127
+ }
128
+ catch {
129
+ version = null;
130
+ }
131
+ if (version)
132
+ return exact(version, repoPath(ctx, join(dir, lockfile)));
133
+ // One lockfile per install root: a lockfile that does not resolve it leaves the manifest's range to decide.
134
+ return /^\d+\.\d+\.\d+$/.test(range) ? exact(range, 'package.json') : fromRange(range);
135
+ }
136
+ }
137
+ return /^\d+\.\d+\.\d+$/.test(range) ? exact(range, 'package.json') : fromRange(range);
138
+ }
139
+ /** Gemfile.lock's resolved version of a gem (` rails (7.1.3)` under specs). */
140
+ export function gemVersion(appDir, gem) {
141
+ const file = join(appDir, 'Gemfile.lock');
142
+ if (!existsSync(file))
143
+ return null;
144
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
145
+ const match = /^ {4}([A-Za-z0-9_.-]+) \(([^)]+)\)$/.exec(line);
146
+ if (match && match[1] === gem)
147
+ return exact(match[2], 'Gemfile.lock');
148
+ }
149
+ return null;
150
+ }
151
+ /** Django's version from the app's lockfile or pinned requirements. */
152
+ export function djangoVersion(appDir) {
153
+ for (const lockfile of ['uv.lock', 'poetry.lock']) {
154
+ const file = join(appDir, lockfile);
155
+ if (!existsSync(file))
156
+ continue;
157
+ const lines = readFileSync(file, 'utf8').split('\n');
158
+ for (let index = 0; index < lines.length - 1; index += 1) {
159
+ if (/^name = "django"$/i.test(lines[index].trim())) {
160
+ const version = /^version = "([^"]+)"$/.exec(lines[index + 1].trim());
161
+ if (version)
162
+ return exact(version[1], lockfile);
163
+ }
164
+ }
165
+ }
166
+ for (const requirements of ['requirements.txt', 'requirements/base.txt', 'requirements/production.txt']) {
167
+ const file = join(appDir, requirements);
168
+ if (!existsSync(file))
169
+ continue;
170
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
171
+ const pinned = /^django==(\d+\.\d+(?:\.\d+)?)\b/i.exec(line.trim());
172
+ if (pinned)
173
+ return { version: pinned[1], major: Number(pinned[1].split('.')[0]), from: requirements };
174
+ }
175
+ }
176
+ return null;
177
+ }