@haystackeditor/cli 0.25.0 → 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.
@@ -132,6 +132,13 @@ export async function detectRails(ctx) {
132
132
  label: `Rails ${version?.version ?? '(unknown version)'}`,
133
133
  manifestAdapter: `rails@${version?.version ?? 'unknown'}`,
134
134
  blocked: blocked.length > 0 ? blocked.join('; ') : null,
135
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
136
+ manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
137
+ fromBuild: false,
138
+ rewriting: null,
139
+ publishNote: null,
140
+ buildPublishDirs: () => [],
141
+ buildNotes: () => [],
135
142
  looked: [layoutsPath, repoPath(ctx, join(ctx.appDir, 'Gemfile.lock'))],
136
143
  staticDir: 'public',
137
144
  async integrate(tags) {
@@ -412,6 +412,13 @@ export async function detectReactRouter(ctx) {
412
412
  label,
413
413
  manifestAdapter: `${reactRouter ? 'react-router' : 'remix'}@${version.version}`,
414
414
  blocked: blocked.length > 0 ? blocked.join('; ') : null,
415
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
416
+ manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
417
+ fromBuild: false,
418
+ rewriting: null,
419
+ publishNote: null,
420
+ buildPublishDirs: () => [],
421
+ buildNotes: () => [],
415
422
  looked,
416
423
  staticDir: 'public',
417
424
  integrate: tags => rootEdit(ctx, root, tags),
@@ -71,6 +71,13 @@ export async function detectSvelteKit(ctx) {
71
71
  label: `SvelteKit ${version.version}`,
72
72
  manifestAdapter: `sveltekit@${version.version}`,
73
73
  blocked: blocked.length > 0 ? blocked.join('; ') : null,
74
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
75
+ manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
76
+ fromBuild: false,
77
+ rewriting: null,
78
+ publishNote: null,
79
+ buildPublishDirs: () => [],
80
+ buildNotes: () => [],
74
81
  looked: [templatePath, where, repoPath(ctx, join(ctx.appDir, routesDir))],
75
82
  staticDir: assets,
76
83
  async integrate(tags) {
@@ -79,6 +79,13 @@ export async function detectVite(ctx) {
79
79
  label: `Vite ${version.version}${router ? ` with ${router}` : ''}`,
80
80
  manifestAdapter: `vite@${version.version}`,
81
81
  blocked: blocked.length > 0 ? blocked.join('; ') : null,
82
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
83
+ manifestBlocked: blocked.length > 0 ? blocked.join('; ') : null,
84
+ fromBuild: false,
85
+ rewriting: null,
86
+ publishNote: null,
87
+ buildPublishDirs: () => [],
88
+ buildNotes: () => [],
82
89
  looked: [indexPath, where, repoPath(ctx, srcDir)],
83
90
  staticDir: toPosix(posix.join(toPosix(root), toPosix(publicDir))),
84
91
  integrate: async (tags) => htmlEdit(index, tags, indexPath),
@@ -109,6 +116,13 @@ export async function detectStaticHtml(ctx) {
109
116
  label: 'a static HTML site',
110
117
  manifestAdapter: 'static-html',
111
118
  blocked: pages.length === 1 ? null : `the site has ${pages.length} HTML pages (${pages.slice(0, 5).join(', ')}${pages.length > 5 ? ', …' : ''}), each needing the tag`,
119
+ // The routes come from the source, so whatever stops init placing the tag stops the manifest too.
120
+ manifestBlocked: pages.length === 1 ? null : `the site has ${pages.length} HTML pages (${pages.slice(0, 5).join(', ')}${pages.length > 5 ? ', …' : ''}), each needing the tag`,
121
+ fromBuild: false,
122
+ rewriting: null,
123
+ publishNote: null,
124
+ buildPublishDirs: () => [],
125
+ buildNotes: () => [],
112
126
  looked: [indexPath],
113
127
  staticDir: '.',
114
128
  integrate: async (tags) => htmlEdit(index, tags, indexPath),
@@ -36,7 +36,8 @@ export function readCaptureConfig(appDir) {
36
36
  if (parsed.version !== CAPTURE_VERSION || strings.some(name => typeof parsed[name] !== 'string')
37
37
  || !CAPTURE_KEY_PATTERN.test(parsed.key) || !(parsed.adapter === null || typeof parsed.adapter === 'string')
38
38
  || !Array.isArray(parsed.origins) || !parsed.origins.every(origin => typeof origin === 'string')
39
- || !Array.isArray(parsed.publish) || !parsed.publish.every(dir => typeof dir === 'string')) {
39
+ || !Array.isArray(parsed.publish) || !parsed.publish.every(dir => typeof dir === 'string')
40
+ || !(parsed.urlRewrites === undefined || parsed.urlRewrites === null || typeof parsed.urlRewrites === 'string')) {
40
41
  throw new Error(`${CAPTURE_CONFIG_PATH} is not a capture configuration init wrote; remove it and run \`haystack init\` again.`);
41
42
  }
42
43
  return parsed;
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import fg from 'fast-glob';
13
13
  import { readFileSync, statSync } from 'node:fs';
14
- import { extname, join } from 'node:path';
14
+ import { extname, join, relative } from 'node:path';
15
15
  import { SAXParser } from 'parse5-sax-parser';
16
16
  import { CAPTURE_ORIGIN } from '../commands/capture-contract.js';
17
17
  import { keyName, parseFile, parseJsonLike, unwrapTs, walk } from './js-ast.js';
@@ -159,7 +159,7 @@ function jsPolicies(text, file, json) {
159
159
  ? (value.properties.find((property) => property.type === 'ObjectProperty' && keyName(property) === 'directives')?.value ?? value) : value;
160
160
  const policy = resolvePolicy(directives, ast);
161
161
  // `csp` is a common key for other things; it counts only when it spells a policy.
162
- if (name !== 'csp' || policy !== null)
162
+ if (name !== 'csp' || (policy !== null && DIRECTIVE.test(policy)))
163
163
  found.push({ offset: node.start, policy, reportOnly: name.endsWith('report-only') });
164
164
  seen.add(node.value);
165
165
  }
@@ -203,8 +203,9 @@ function resolvePolicy(node, ast, depth = 0) {
203
203
  && ['replace', 'replaceAll', 'trim'].includes(current.callee.property.name)) {
204
204
  return resolvePolicy(current.callee.object, ast, depth + 1);
205
205
  }
206
- const policy = policyFromNode(current);
207
- return policy !== null && DIRECTIVE.test(policy) ? policy : null;
206
+ // A header's value is its policy whatever it says (`frame-ancestors 'none'` restricts nothing the tag does); a computed
207
+ // value init cannot read stays null.
208
+ return policyFromNode(current);
208
209
  }
209
210
  /** helmet()'s default policy (helmet 5–8), which applies when its options leave contentSecurityPolicy alone. */
210
211
  const HELMET_DEFAULT = "default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self';"
@@ -298,7 +299,7 @@ export function findCsp(repoRoot, appDir, nextLoader, origins) {
298
299
  if (!MARKER.test(text))
299
300
  continue;
300
301
  const extension = extname(file);
301
- const where = toPosix(file.slice(repoRoot.length + 1));
302
+ const where = toPosix(relative(repoRoot, file));
302
303
  let policies;
303
304
  if (['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx'].includes(extension))
304
305
  policies = jsPolicies(text, file, false);
@@ -327,5 +328,5 @@ export function findCsp(repoRoot, appDir, nextLoader, origins) {
327
328
  });
328
329
  }
329
330
  }
330
- return { findings, looked: `${toPosix(appDir.slice(repoRoot.length + 1)) || '.'} (headers config, middleware, meta tags, framework CSP options, server configs)` };
331
+ return { findings, looked: `${toPosix(relative(repoRoot, appDir)) || '.'} (headers config, middleware, meta tags, framework CSP options, server configs)` };
331
332
  }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * URL rewrites (CAPTURE-V1 rule 4). The tag matches a page's URL against the manifest's routes, so a route's rule must say
3
+ * which URLs show it. A middleware or proxy that rewrites (Rallly's proxy turns every /x into /<locale>/x) or a config
4
+ * rewrite into a page makes the URL and the route's path differ in ways only the app's code says. Init reads that code
5
+ * only where it can read it exactly (a middleware that rewrites nothing and calls nothing it cannot see into, or
6
+ * next-intl's middleware with a literal routing config); for anything else it never guesses: it asks the coding agent's
7
+ * user (who can read that code) for one answer, recorded in .haystack/capture.json:
8
+ *
9
+ * none rewrites never make a page's URL show another page
10
+ * hidden-segment:<name> the middleware or proxy adds the leading dynamic segment [<name>] to every page path, so
11
+ * URLs never contain it (the manifest drops that segment from the routes under it)
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { keyName, literalValue, parseFile, pathOf, readPathAliases, resolveImport, unwrapTs, UNREADABLE, walk } from './js-ast.js';
16
+ import { isFile } from './adapters/files.js';
17
+ import { repoPath, resolveVersion } from './project.js';
18
+ export function parseUrlRewrites(raw) {
19
+ const value = raw.trim();
20
+ if (value === 'none')
21
+ return { kind: 'none' };
22
+ const hidden = /^hidden-segment:([A-Za-z0-9_-]+)$/.exec(value);
23
+ if (hidden)
24
+ return { kind: 'hidden-segment', name: hidden[1] };
25
+ throw new Error(`--url-rewrites ${raw} is not an answer: use none, or hidden-segment:<name> for a leading [<name>] segment the app's middleware or proxy adds to every page path.`);
26
+ }
27
+ export const URL_REWRITES_QUESTION = 'Does it make a page\'s URL show another page? Ask your user (or read that code), then run `haystack init` again with'
28
+ + ' `--url-rewrites none` when its rewrites never do, or `--url-rewrites hidden-segment:<name>` when it adds a leading [<name>] segment to every'
29
+ + ' page path (so URLs never contain it).';
30
+ const MIDDLEWARE_FILES = ['middleware', 'proxy'].flatMap(name => ['ts', 'js', 'mjs', 'mts', 'cts', 'cjs', 'tsx', 'jsx'].map(extension => `${name}.${extension}`));
31
+ const INTL_MIDDLEWARE = 'next-intl/middleware';
32
+ const INTL_ROUTING = 'next-intl/routing';
33
+ /** next/server and the rest of Next's own modules: what they return init knows (NextResponse.rewrite is caught by name). */
34
+ function nextModule(source) {
35
+ return source === 'next' || source.startsWith('next/');
36
+ }
37
+ /** The identifier a call's callee starts from (`a.b.c()` → a), or null (a call on a call's result, `(…)()`). */
38
+ function calleeRoot(callee) {
39
+ let current = unwrapTs(callee);
40
+ while (current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression')
41
+ current = unwrapTs(current.object);
42
+ return current.type === 'Identifier' ? current : null;
43
+ }
44
+ /** The value a routing config names: an object literal, a top-level const bound to one or to defineRouting({...}), or
45
+ * the same exported from a local module; null when it is not literally there. */
46
+ function routingObject(node, ast, file, ctx, depth = 0) {
47
+ const current = unwrapTs(node);
48
+ if (depth > 4)
49
+ return null;
50
+ if (current.type === 'ObjectExpression')
51
+ return { value: current, ast };
52
+ if (current.type === 'CallExpression' && current.arguments.length === 1) {
53
+ const root = calleeRoot(current.callee);
54
+ const source = root ? importOf(ast, root.name) : null;
55
+ if (source?.module === INTL_ROUTING && source.imported === 'defineRouting')
56
+ return routingObject(current.arguments[0], ast, file, ctx, depth + 1);
57
+ return null;
58
+ }
59
+ if (current.type !== 'Identifier')
60
+ return null;
61
+ for (const statement of ast.program.body) {
62
+ const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement;
63
+ if (declaration?.type !== 'VariableDeclaration')
64
+ continue;
65
+ for (const declarator of declaration.declarations) {
66
+ if (declarator.id.type === 'Identifier' && declarator.id.name === current.name && declarator.init) {
67
+ return routingObject(declarator.init, ast, file, ctx, depth + 1);
68
+ }
69
+ }
70
+ }
71
+ const imported = importOf(ast, current.name);
72
+ if (!imported)
73
+ return null;
74
+ const target = resolveImport(file, imported.module, readPathAliases(ctx.appDir));
75
+ if (!target)
76
+ return null;
77
+ const targetAst = parseFile(readFileSync(target, 'utf8'), target);
78
+ if (!targetAst)
79
+ return null;
80
+ const exported = exportedExpression(targetAst, imported.imported);
81
+ return exported ? routingObject(exported, targetAst, target, ctx, depth + 1) : null;
82
+ }
83
+ function importOf(ast, local) {
84
+ for (const statement of ast.program.body) {
85
+ if (statement.type !== 'ImportDeclaration')
86
+ continue;
87
+ for (const specifier of statement.specifiers) {
88
+ if (specifier.local.name !== local)
89
+ continue;
90
+ const imported = specifier.type === 'ImportDefaultSpecifier' ? 'default'
91
+ : specifier.type === 'ImportNamespaceSpecifier' ? '*' : specifier.imported.name ?? specifier.imported.value;
92
+ return { module: statement.source.value, imported };
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+ /** The expression a module exports under `name` ('default' for its default export), or null. */
98
+ function exportedExpression(ast, name) {
99
+ for (const statement of ast.program.body) {
100
+ if (name === 'default' && statement.type === 'ExportDefaultDeclaration')
101
+ return statement.declaration;
102
+ if (statement.type !== 'ExportNamedDeclaration' || statement.source)
103
+ continue;
104
+ for (const declarator of statement.declaration?.type === 'VariableDeclaration' ? statement.declaration.declarations : []) {
105
+ if (declarator.id.type === 'Identifier' && declarator.id.name === name)
106
+ return declarator.init;
107
+ }
108
+ for (const specifier of statement.specifiers ?? []) {
109
+ if ((specifier.exported?.name ?? specifier.exported?.value) === name)
110
+ return specifier.local;
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+ /** next-intl's routing, read exactly, or why it cannot be. */
116
+ function intlRouting(config, ast, file, ctx, where) {
117
+ const unknown = (why) => ({ kind: 'unknown', why: `${where} uses next-intl's middleware, ${why}` });
118
+ const found = config ? routingObject(config, ast, file, ctx) : null;
119
+ if (!found)
120
+ return unknown('whose routing config init cannot read literally');
121
+ const value = literalValue(found.value, found.ast);
122
+ if (value === UNREADABLE || typeof value !== 'object' || value === null || Array.isArray(value)) {
123
+ return unknown('whose routing config is not literal');
124
+ }
125
+ const routing = value;
126
+ if (routing.pathnames !== undefined)
127
+ return unknown('whose localized pathnames make a URL show a page under another path');
128
+ if (routing.domains !== undefined)
129
+ return unknown('whose domains decide the locale by host');
130
+ const locales = Array.isArray(routing.locales) && routing.locales.every(locale => typeof locale === 'string') ? routing.locales : null;
131
+ const defaultLocale = typeof routing.defaultLocale === 'string' ? routing.defaultLocale : null;
132
+ if (!locales || !defaultLocale)
133
+ return unknown('whose locales or defaultLocale are not a literal list and string');
134
+ let mode;
135
+ let prefixes = {};
136
+ const prefix = routing.localePrefix;
137
+ if (prefix === undefined) {
138
+ // next-intl 3 made `always` the default; 2 had `as-needed`.
139
+ const version = resolveVersion(ctx, 'next-intl');
140
+ if (!version || version.major === null)
141
+ return unknown('whose default localePrefix depends on a next-intl version init cannot resolve');
142
+ mode = version.major >= 3 ? 'always' : 'as-needed';
143
+ }
144
+ else if (typeof prefix === 'string') {
145
+ mode = prefix;
146
+ }
147
+ else if (prefix && typeof prefix === 'object' && !Array.isArray(prefix)) {
148
+ mode = prefix.mode;
149
+ if (prefix.prefixes !== undefined) {
150
+ const given = prefix.prefixes;
151
+ if (!given || typeof given !== 'object' || Array.isArray(given) || !Object.values(given).every(item => typeof item === 'string' && item.startsWith('/'))) {
152
+ return unknown('whose localePrefix.prefixes are not literal paths');
153
+ }
154
+ prefixes = given;
155
+ }
156
+ }
157
+ if (mode !== 'always' && mode !== 'as-needed' && mode !== 'never')
158
+ return unknown(`whose localePrefix mode (${JSON.stringify(mode)}) init does not know`);
159
+ return { kind: 'locale-prefix', where, mode, locales, defaultLocale, prefixes };
160
+ }
161
+ /** One middleware or proxy file, read. */
162
+ function readMiddleware(ctx, file) {
163
+ const where = repoPath(ctx, file);
164
+ const ast = parseFile(readFileSync(file, 'utf8'), file);
165
+ if (!ast)
166
+ return { kind: 'unknown', why: `${where} (which init cannot parse)` };
167
+ const program = pathOf(ast, ast.program);
168
+ if (!program)
169
+ return { kind: 'unknown', why: `${where} (whose names init cannot resolve)` };
170
+ const reasons = [];
171
+ walk(ast.program, node => {
172
+ if (node.type === 'ExportAllDeclaration' || (node.type === 'ExportNamedDeclaration' && node.source)) {
173
+ reasons.push(`re-exports from ${node.source.value}`);
174
+ }
175
+ if (node.type === 'StringLiteral' && node.value.toLowerCase() === 'x-middleware-rewrite')
176
+ reasons.push('sets the x-middleware-rewrite header');
177
+ if ((node.type === 'CallExpression' || node.type === 'OptionalCallExpression') && node.callee.type === 'MemberExpression' && !node.callee.computed
178
+ && node.callee.property.name === 'rewrite')
179
+ reasons.push('calls rewrite()');
180
+ if (node.type === 'ObjectProperty' && keyName(node) === 'rewrite')
181
+ reasons.push('passes a rewrite');
182
+ if (node.type === 'ImportExpression' || (node.type === 'CallExpression' && (node.callee.type === 'Import'
183
+ || (node.callee.type === 'Identifier' && node.callee.name === 'require'))))
184
+ reasons.push('loads a module at runtime');
185
+ });
186
+ // Every value imported from outside Next is code init cannot see into, wherever it is used (called, passed to a local
187
+ // helper that calls it, exported as the middleware), except next-intl's createMiddleware and the routing it is given,
188
+ // which init reads.
189
+ const intlCalls = [];
190
+ for (const [local, binding] of Object.entries(program.scope.bindings)) {
191
+ const declaration = binding.path.parentPath?.node;
192
+ if (binding.kind !== 'module' || declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type'
193
+ || binding.path.node.importKind === 'type' || nextModule(declaration.source.value))
194
+ continue;
195
+ const factory = declaration.source.value === INTL_MIDDLEWARE && binding.path.node.type === 'ImportDefaultSpecifier';
196
+ for (const reference of binding.referencePaths) {
197
+ const parent = reference.parentPath?.node;
198
+ if (factory && parent?.type === 'CallExpression' && parent.callee === reference.node) {
199
+ intlCalls.push(parent);
200
+ continue;
201
+ }
202
+ const factoryArgument = parent?.type === 'CallExpression' && parent.arguments[0] === reference.node && parent.callee.type === 'Identifier'
203
+ && program.scope.getBinding(parent.callee.name)?.path.parentPath?.node.source?.value === INTL_MIDDLEWARE;
204
+ if (factoryArgument)
205
+ continue;
206
+ reasons.push(`uses ${local} from ${declaration.source.value}`);
207
+ break;
208
+ }
209
+ }
210
+ if (reasons.length > 0)
211
+ return { kind: 'unknown', why: `${where}, which ${reasons[0]} (init cannot read what that does to page paths)` };
212
+ if (intlCalls.length === 0)
213
+ return { kind: 'none' };
214
+ // next-intl: every createMiddleware call must read as the same routing.
215
+ const readings = intlCalls.map(call => intlRouting(call.arguments[0], ast, file, ctx, where));
216
+ const first = readings[0];
217
+ if (first.kind !== 'locale-prefix')
218
+ return first;
219
+ return readings.every(reading => JSON.stringify(reading) === JSON.stringify(first)) ? first
220
+ : { kind: 'unknown', why: `${where}, which calls next-intl's createMiddleware with several routing configs` };
221
+ }
222
+ /** What the app's middleware or proxy does to page paths (the first one found, as Next loads one). Needs loadBabel(). */
223
+ export function middlewareRewrites(ctx) {
224
+ for (const dir of [ctx.appDir, join(ctx.appDir, 'src')]) {
225
+ for (const name of MIDDLEWARE_FILES) {
226
+ const file = join(dir, name);
227
+ if (isFile(file))
228
+ return readMiddleware(ctx, file);
229
+ }
230
+ }
231
+ return { kind: 'none' };
232
+ }
@@ -42,6 +42,9 @@ try {
42
42
  if (written) {
43
43
  const files = Array.isArray(result.files) ? result.files.join(', ') : '';
44
44
  console.log(`Haystack route manifest ${String(result.release)}: ${files}`);
45
+ // What the person must still do for it to be reachable (a Next.js basePath): said on every build until done.
46
+ for (const note of Array.isArray(result.notes) ? result.notes : [])
47
+ console.warn(`haystack capture manifest: ${String(note)}.`);
45
48
  }
46
49
  }
47
50
  catch { /* the command did not answer: no manifest was written */ }
@@ -5,13 +5,13 @@
5
5
  * current.json. No credential is involved: whoever deploys the app publishes its manifest.
6
6
  *
7
7
  * It runs inside the customer's build (the package script init extends, or the Rails rake file), from the CLI the app pins
8
- * as an exact devDependency. It never fails that build (rule 13): whatever goes wrong, it prints one warning line and exits
8
+ * as an exact production dependency. It never fails that build (rule 13): whatever goes wrong, it prints one warning line and exits
9
9
  * 0, because a missing manifest only stops the tag on this release (rule 13a: no manifest, no work). It needs no git
10
10
  * checkout: repository paths come from the app path init recorded.
11
11
  */
12
12
  import chalk from 'chalk';
13
13
  import { relative, resolve, sep } from 'node:path';
14
- import { detectApp } from '../capture/adapters/index.js';
14
+ import { ADAPTER_IDS, detectApp, detectWith } from '../capture/adapters/index.js';
15
15
  import { isManual } from '../capture/adapters/types.js';
16
16
  import { CAPTURE_CONFIG_PATH, readCaptureConfig } from '../capture/app-config.js';
17
17
  import { loadBabel } from '../capture/js-ast.js';
@@ -44,35 +44,43 @@ async function publish(options, locations) {
44
44
  if (!config)
45
45
  throw new Error(`${appDir} has no ${CAPTURE_CONFIG_PATH}: run \`haystack init\` in the repository to set capture up.`);
46
46
  locations.push(...config.publish.map(dir => resolve(appDir, dir)));
47
- if (config.adapter === null)
48
- throw new Error(`${CAPTURE_CONFIG_PATH} names no framework adapter: init left this app's manifest to a manual step.`);
47
+ if (options.adapter !== undefined && !ADAPTER_IDS.includes(options.adapter)) {
48
+ throw new Error(`--adapter ${options.adapter} is not one of ${ADAPTER_IDS.join(', ')}`);
49
+ }
50
+ const adapter = (options.adapter ?? config.adapter);
51
+ if (adapter === null)
52
+ throw new Error(`${CAPTURE_CONFIG_PATH} names no framework adapter: run it with --adapter <${ADAPTER_IDS.join('|')}>`);
49
53
  // The checkout's root when the app sits at its recorded path; else (a build that sees only the app) the app itself.
50
54
  const suffix = config.app === '.' ? '' : config.app.split('/').join(sep);
51
55
  const repoRoot = suffix !== '' && appDir.endsWith(`${sep}${suffix}`) ? appDir.slice(0, -suffix.length - 1) : appDir;
52
56
  await loadBabel();
53
57
  const ctx = appContext(repoRoot, appDir, config.app);
54
- const detected = await detectApp(ctx);
55
- if (!detected || detected.id !== config.adapter) {
56
- throw new Error(`${CAPTURE_CONFIG_PATH} was written for ${config.adapter}, and this app is now ${detected?.label ?? 'no framework init recognizes'}; run \`haystack init\` again.`);
57
- }
58
- if (!isManual(detected.publishDirs))
59
- locations.push(...detected.publishDirs.map(dir => resolve(appDir, dir)).filter(dir => !locations.includes(dir)));
60
- if (detected.blocked)
61
- throw new Error(`${detected.label}: ${detected.blocked}.`);
62
- const routes = exactRoutes(await detected.routes({ railsRoutes: options.railsRoutes ? resolve(options.railsRoutes) : undefined }));
63
- if (isManual(routes))
64
- throw new Error(routes.manual);
58
+ const detected = await detectWith(ctx, adapter);
59
+ if (!detected)
60
+ throw new Error(`the ${adapter} adapter does not recognize ${appDir} (${(await detectApp(ctx))?.label ?? 'no framework init recognizes'}); run \`haystack init\` again`);
61
+ // Checked on every build, so once the user removes the cause the next build publishes, with nothing to rerun.
62
+ if (detected.manifestBlocked)
63
+ throw new Error(`${detected.label}: ${detected.manifestBlocked}`);
65
64
  if (isManual(detected.publishDirs))
66
65
  throw new Error(detected.publishDirs.manual);
66
+ const publishDirs = [...detected.publishDirs, ...detected.buildPublishDirs()].map(dir => resolve(appDir, dir));
67
+ locations.push(...publishDirs.filter(dir => !locations.includes(dir)));
68
+ const routes = exactRoutes(await detected.routes({ railsRoutes: options.railsRoutes ? resolve(options.railsRoutes) : undefined,
69
+ urlRewrites: config.urlRewrites ?? null }));
70
+ if (isManual(routes))
71
+ throw new Error(routes.manual);
67
72
  const manifest = buildManifest(config, detected.manifestAdapter, routes, detected.chunks());
68
- const written = writeManifest(detected.publishDirs.map(dir => resolve(appDir, dir)), manifest);
73
+ const written = writeManifest(publishDirs, manifest);
74
+ const notes = detected.buildNotes();
69
75
  if (options.json) {
70
76
  process.stdout.write(`${JSON.stringify({ written: true, release: manifest.release, routes: manifest.routes.length, chunks: manifest.chunks.length,
71
- files: written.map(file => relative(appDir, file)) }, null, 2)}\n`);
77
+ files: written.map(file => relative(appDir, file)), notes }, null, 2)}\n`);
72
78
  return;
73
79
  }
74
80
  const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
75
81
  console.log(chalk.green(`Haystack route manifest ${manifest.release}: ${count(manifest.routes.length, 'route')}, ${count(manifest.chunks.length, 'chunk')} (${detected.label})`));
76
82
  for (const file of written)
77
83
  console.log(chalk.dim(` ${relative(appDir, file)}`));
84
+ for (const note of notes)
85
+ console.warn(chalk.yellow(`haystack capture manifest: ${note}.`));
78
86
  }
@@ -31,8 +31,9 @@ import { findCsp } from '../capture/csp.js';
31
31
  import { applyEdits, keyName, loadBabel, parseJsonLike } from '../capture/js-ast.js';
32
32
  import { buildManifest, exactRoutes, manifestDocument, MANIFEST_DIR } from '../capture/manifest.js';
33
33
  import { appContext } from '../capture/project.js';
34
+ import { parseUrlRewrites, URL_REWRITES_QUESTION } from '../capture/url-rewrites.js';
34
35
  import { loadCaptureScript } from '../capture/script-release.js';
35
- import { htmlTags, STATIC_SUBDIR } from '../capture/tag.js';
36
+ import { STATIC_SUBDIR } from '../capture/tag.js';
36
37
  import { CAPTURE_ORIGIN, CAPTURE_VERSION } from './capture-contract.js';
37
38
  import { HaystackApiError } from '../utils/haystack-api.js';
38
39
  import { lineDiff } from './init.js';
@@ -50,9 +51,10 @@ const COMPOSABLE_SCRIPT = /^[A-Za-z0-9_./=:@\s&-]+$/;
50
51
  const STAGED_DIR = `.haystack/${STATIC_SUBDIR}`;
51
52
  /** The answers in effect: the flags, else the ones init recorded in .haystack/capture.json (rule 8b: the user's answers,
52
53
  * never inferred, so a rerun that only updates the script needs none). */
53
- export function captureAnswers(appDir, origins, consent) {
54
+ export function captureAnswers(appDir, origins, consent, urlRewrites) {
54
55
  const recorded = readCaptureConfig(appDir);
55
- return { origins: origins.length > 0 ? origins : recorded?.origins ?? [], consent: consent ?? recorded?.consent };
56
+ return { origins: origins.length > 0 ? origins : recorded?.origins ?? [], consent: consent ?? recorded?.consent,
57
+ urlRewrites: urlRewrites ?? recorded?.urlRewrites ?? undefined };
56
58
  }
57
59
  function readText(path) {
58
60
  try {
@@ -126,6 +128,9 @@ function scriptFiles(dir) {
126
128
  return [];
127
129
  }
128
130
  }
131
+ function formatUrlRewrites(rewrites) {
132
+ return rewrites.kind === 'none' ? 'none' : `hidden-segment:${rewrites.name}`;
133
+ }
129
134
  function manifestCommand(appPath) {
130
135
  return `haystack capture manifest --app ${appPath}`;
131
136
  }
@@ -144,6 +149,7 @@ export async function planCapture(input) {
144
149
  if (input.answers.consent === undefined)
145
150
  throw new Error('planCapture needs the consent answer.');
146
151
  const consent = parseConsentChoice(input.answers.consent);
152
+ const urlRewrites = input.answers.urlRewrites === undefined ? null : formatUrlRewrites(parseUrlRewrites(input.answers.urlRewrites));
147
153
  const plan = { changes: [], packageText: null, needsCli: false, registration: null, notices: [], part: part([]) };
148
154
  if (input.registration && 'failed' in input.registration) {
149
155
  plan.part = part([`Run \`haystack init\` again: registering the app for capture failed (${input.registration.failed}).`]);
@@ -182,18 +188,36 @@ export async function planCapture(input) {
182
188
  key: tags.key,
183
189
  repositoryId: identity?.repositoryId ?? ID_PLACEHOLDER,
184
190
  applicationId: identity?.applicationId ?? ID_PLACEHOLDER,
185
- adapter: detected && !detected.blocked ? detected.id : null,
191
+ // The adapter init recognized, whatever it left manual: the manifest command uses it once the cause is fixed.
192
+ adapter: detected ? detected.id : null,
186
193
  app: ctx.appPath,
187
194
  origins,
188
195
  consent: formatConsentChoice(consent),
189
196
  // Where a build step publishes (none for a static site, whose manifest init writes and the repository keeps).
190
- publish: detected && !detected.blocked && !isManual(detected.publishDirs) && !isManual(detected.build)
191
- && detected.build.kind !== 'init-writes' ? detected.publishDirs : [],
197
+ publish: detected && !isManual(detected.publishDirs) && !isManual(detected.build) && detected.build.kind !== 'init-writes'
198
+ ? detected.publishDirs : [],
199
+ urlRewrites,
192
200
  };
193
201
  planner.text(captureConfigFile(appDir), formatCaptureConfig(config), 'The app\'s capture identity, committed for the build step that publishes its route manifest (CAPTURE-V1 rule 8b).');
194
202
  const consentStep = manualConsentStep(consent);
195
- const automatic = detected !== null && detected.blocked === null;
196
- const staticDir = automatic && typeof detected.staticDir === 'string' ? detected.staticDir : null;
203
+ if (!detected) {
204
+ plan.part = { status: 'unsupported', why: `init recognized no web framework at ${ctx.appPath} (it looked for package.json, Gemfile,`
205
+ + ' manage.py and index.html there), and the tag records nothing without the route manifest only a recognized framework\'s routes'
206
+ + ' give; run `haystack init --app <dir>` with the app\'s directory' };
207
+ plan.changes = planner.changes;
208
+ return plan;
209
+ }
210
+ // A manifest that cannot be made exactly (a base path, a config init cannot read) leaves nothing worth placing: a tag
211
+ // without a manifest records nothing. Two steps that work: remove the cause, run init again (the recorded answers carry
212
+ // over, and the manifest command checks the cause on every build).
213
+ if (detected.manifestBlocked) {
214
+ steps.push(`Remove what stops ${detected.label}'s route manifest: ${detected.manifestBlocked} (init looked at ${detected.looked.join(', ')}).`);
215
+ steps.push('Then run `haystack init` again: it places the tag, the files and the build step that publishes the manifest itself.');
216
+ plan.changes = planner.changes;
217
+ plan.part = part(steps);
218
+ return plan;
219
+ }
220
+ const staticDir = typeof detected.staticDir === 'string' ? detected.staticDir : null;
197
221
  // The files: in the app's static output, or staged for a manual step.
198
222
  const filesDir = staticDir === null ? join(appDir, STAGED_DIR) : join(appDir, staticDir, STATIC_SUBDIR);
199
223
  const wanted = new Set([script.file, ...(consentFile ? [consentFile.file] : [])]);
@@ -205,24 +229,8 @@ export async function planCapture(input) {
205
229
  if (!wanted.has(old))
206
230
  planner.remove(join(filesDir, old), 'An earlier capture file, replaced by this version.');
207
231
  const servedAt = `/${STATIC_SUBDIR}/`;
208
- if (!automatic) {
209
- const why = detected ? `${detected.label}: ${detected.blocked}` : `init recognized no framework at ${ctx.appPath}`;
210
- const looked = detected?.looked.join(', ') ?? `${ctx.appPath} (package.json, Gemfile, manage.py, index.html)`;
211
- steps.push(`Add the tag yourself (${why}; init looked at ${looked}):\n${(detected ? detected.manualTag(tags) : ['Inside <head> of every page:', ...htmlTags(tags)]).map(line => ` ${line}`).join('\n')}`);
212
- steps.push(`Serve the files init staged in ${posix.join(ctx.appPath, STAGED_DIR)}/ at ${servedAt} on each production origin.`);
213
- steps.push(`Publish the route manifest (CAPTURE-V1 rule 4) after each build at /${MANIFEST_DIR}/current.json and`
214
- + ` /${MANIFEST_DIR}/<release>.json on each production origin; until one is published the tag records nothing.`
215
- + ` \`${manifestCommand(ctx.appPath)}\` generates it only for an app init sets up itself: once the reason above is gone,`
216
- + ' run `haystack init` again and it adds that build step.');
217
- steps.push(`If production sends a Content-Security-Policy, it needs: ${cspFallbackLine()}.`);
218
- if (consentStep)
219
- steps.push(consentStep);
220
- plan.changes = planner.changes;
221
- plan.part = part(steps);
222
- return plan;
223
- }
224
232
  // The tag.
225
- const integration = await detected.integrate(tags);
233
+ const integration = detected.blocked ? { manual: detected.blocked } : await detected.integrate(tags);
226
234
  if (isManual(integration)) {
227
235
  steps.push(`Add the tag yourself (${integration.manual}; init looked at ${detected.looked.join(', ')}):\n${detected.manualTag(tags).map(line => ` ${line}`).join('\n')}`);
228
236
  }
@@ -234,8 +242,17 @@ export async function planCapture(input) {
234
242
  steps.push(`Serve ${posix.join(ctx.appPath, STAGED_DIR)} at ${servedAt}: ${detected.staticDir.manual}.`);
235
243
  // The build step and where the manifest goes.
236
244
  await planBuild(plan, planner, steps, ctx, detected, config);
237
- // Routes now, so a router init cannot read is a step today rather than a manifest missing after the next build.
238
- if (!isManual(detected.build) && detected.build.kind !== 'rake') {
245
+ if (detected.publishNote)
246
+ plan.notices.push(`Capture: ${detected.publishNote}`);
247
+ // A middleware or proxy that may rewrite page URLs: the routes are exact only with the user's answer.
248
+ if (detected.rewriting && urlRewrites === null)
249
+ steps.push(`Answer one question about URL rewrites: ${detected.rewriting}. ${URL_REWRITES_QUESTION}`);
250
+ // Routes now, so a router init cannot read is a step today rather than a manifest missing after the next build; an
251
+ // adapter whose routes come from the build reads them there.
252
+ if (detected.fromBuild) {
253
+ plan.notices.push(`Capture: the routes come from each production build (${detected.label}), read after it by the build step.`);
254
+ }
255
+ else if (!isManual(detected.build) && detected.build.kind !== 'rake') {
239
256
  const routes = exactRoutes(await detected.routes({}));
240
257
  if (isManual(routes))
241
258
  steps.push(`Make the routes readable or publish the manifest yourself: ${routes.manual}.`);
@@ -841,7 +841,7 @@ export async function planTelemetry(gitRoot, repository, options) {
841
841
  const manifest = join(appDir, 'package.json');
842
842
  // CONNECT(lane D1): browser capture (init-capture.ts) plans with the answers in effect (flags, else the ones init recorded
843
843
  // for this app); a missing answer is a question, never a guess.
844
- const answers = captureAnswers(appDir, options.origins, options.consent);
844
+ const answers = captureAnswers(appDir, options.origins, options.consent, options.urlRewrites);
845
845
  const questions = captureQuestions(answers.origins, answers.consent);
846
846
  const capture = questions.length > 0 ? null
847
847
  : await planCapture({ gitRoot, appDir, repository, answers, registration: options.registration });
@@ -940,19 +940,36 @@ function dbProfile(repository, onboarding, notes) {
940
940
  + '`haystack db profile upload --help` says how a profile names the one it describes.'] : []),
941
941
  ] };
942
942
  }
943
+ function serverPartOf(plan) {
944
+ return plan.server.status === 'unsupported' ? { status: 'unsupported', why: plan.server.why }
945
+ : plan.server.status === 'manual' ? { status: 'manual', steps: plan.server.steps, received: null } : { status: 'installed', received: null };
946
+ }
947
+ const WAITING_STEP = 'Telemetry waits for answers only your user can give: ask what the `steps` of each `manual` part say, then run `haystack init` '
948
+ + 'again with them.';
949
+ /** Rule 8(e) before anything is changed (a preview without --yes, or no login yet): the report the apply gives, with
950
+ * `received` null (checking it needs Haystack), so the coding agent can show its user what telemetry will do; with the steps that need
951
+ * nothing from Haystack (the token and environment steps follow with --yes). */
952
+ export function previewTelemetry(plan, repository, notes) {
953
+ const steps = [];
954
+ if (plan.app === null || plan.capture.status === 'manual')
955
+ steps.push(WAITING_STEP);
956
+ if (plan.app !== null && plan.server.status !== 'unsupported') {
957
+ steps.push(`With --yes, init then says how production gets its telemetry token (\`haystack telemetry token --repo ${repository}\`, run by your`
958
+ + ' user in their own terminal) and which variables production\'s server environment needs.');
959
+ }
960
+ const profile = dbProfile(repository, null, notes);
961
+ return { telemetry: { capture: plan.capture, server: serverPartOf(plan), dbProfileCommand: profile?.command ?? null }, steps };
962
+ }
943
963
  /** Rule 8(e): the report, and what the person does for telemetry to arrive (the token, production's environment, the start,
944
964
  * the deploy) with the off switches (rule 13c). `onboarding` and `notes` feed the data profile. */
945
965
  export async function telemetryReport(plan, context) {
946
966
  const { repository, token } = context;
947
967
  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 };
968
+ const serverPart = serverPartOf(plan);
950
969
  let server = serverPart;
951
970
  // 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
- }
971
+ if (plan.app === null || plan.capture.status === 'manual')
972
+ steps.push(WAITING_STEP);
956
973
  if (plan.app !== null && plan.server.status !== 'unsupported') {
957
974
  const status = await readTelemetryServerStatus(repository, token);
958
975
  server = withReceived(serverPart, status.newestFlush === null ? null : received(status.newestFlush.storedAt, status.newestFlush.buildId));