@haystackeditor/cli 0.25.0 → 0.26.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/README.md +39 -455
- package/dist/capture/adapters/django.js +7 -0
- package/dist/capture/adapters/index.js +10 -0
- package/dist/capture/adapters/next-build.js +113 -0
- package/dist/capture/adapters/next.js +272 -105
- package/dist/capture/adapters/nuxt.js +7 -0
- package/dist/capture/adapters/rails.js +7 -0
- package/dist/capture/adapters/react-router.js +7 -0
- package/dist/capture/adapters/sveltekit.js +7 -0
- package/dist/capture/adapters/vite.js +14 -0
- package/dist/capture/app-config.js +27 -2
- package/dist/capture/csp.js +7 -6
- package/dist/capture/url-rewrites.js +232 -0
- package/dist/capture-step.js +3 -0
- package/dist/commands/capture-brief.js +41 -32
- package/dist/commands/capture-manifest.js +25 -17
- package/dist/commands/feedback.js +66 -0
- package/dist/commands/init-capture.js +44 -27
- package/dist/commands/init-telemetry.js +77 -14
- package/dist/commands/init.js +19 -4
- package/dist/commands/lockfile-pin.js +307 -0
- package/dist/commands/verify-onboarding.js +16 -0
- package/dist/commands/verify.js +70 -15
- package/dist/index.js +29 -923
- package/dist/schema.js +4 -10
- package/dist/utils/haystack-api.js +0 -36
- package/package.json +1 -5
- package/schemas/feedback.v1.json +13 -0
- package/schemas/init.v1.json +1 -1
- package/schemas/pre-verify.v2.json +240 -0
- package/schemas/verify-raw.v1.json +1132 -0
- package/schemas/verify.v2.json +655 -0
- package/dist/assets/hooks/agent-context/detect.ts +0 -316
- package/dist/assets/hooks/agent-context/format.ts +0 -100
- package/dist/assets/hooks/agent-context/index.ts +0 -41
- package/dist/assets/hooks/agent-context/parsers/claude.ts +0 -262
- package/dist/assets/hooks/agent-context/parsers/codex.ts +0 -416
- package/dist/assets/hooks/agent-context/parsers/gemini.ts +0 -155
- package/dist/assets/hooks/agent-context/parsers/opencode.ts +0 -174
- package/dist/assets/hooks/agent-context/tsconfig.json +0 -14
- package/dist/assets/hooks/agent-context/types.ts +0 -58
- package/dist/assets/hooks/llm-rules-template.md +0 -59
- package/dist/assets/hooks/package-lock.json +0 -598
- package/dist/assets/hooks/package.json +0 -12
- package/dist/assets/hooks/scripts/commit-msg.sh +0 -5
- package/dist/assets/hooks/scripts/post-commit.sh +0 -5
- package/dist/assets/hooks/scripts/pre-commit.sh +0 -175
- package/dist/assets/hooks/scripts/pre-push.sh +0 -25
- package/dist/assets/hooks/scripts/prepare-commit-msg.sh +0 -5
- package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +0 -528
- package/dist/assets/hooks/truncation-checker/index.ts +0 -595
- package/dist/assets/hooks/truncation-checker/tsconfig.json +0 -13
- package/dist/assets/skills/map-cloud-verifier-universe/SKILL.md +0 -2051
- package/dist/assets/skills/map-cloud-verifier-universe/agents/openai.yaml +0 -4
- package/dist/assets/skills/map-cloud-verifier-universe/references/output-contract.md +0 -3411
- package/dist/assets/skills/map-your-system.md +0 -143
- package/dist/assets/skills/submit.md +0 -200
- package/dist/commands/ask.js +0 -20
- package/dist/commands/cloud-verifier-behaviors.js +0 -218
- package/dist/commands/cloud-verifier-data-store-census.js +0 -539
- package/dist/commands/cloud-verifier-data-store-drift.js +0 -158
- package/dist/commands/cloud-verifier-identity-census.js +0 -4060
- package/dist/commands/cloud-verifier-materialization.js +0 -704
- package/dist/commands/cloud-verifier-pascal-selector-census.js +0 -1382
- package/dist/commands/cloud-verifier-python-manifest-selector-census.js +0 -2015
- package/dist/commands/cloud-verifier-specialized-operational-census.js +0 -11432
- package/dist/commands/cloud-verifier-universe.js +0 -10178
- package/dist/commands/config.js +0 -549
- package/dist/commands/design-verify.js +0 -311
- package/dist/commands/dismiss.js +0 -159
- package/dist/commands/hooks.js +0 -226
- package/dist/commands/inbox.js +0 -137
- package/dist/commands/mcp.js +0 -201
- package/dist/commands/policy.js +0 -371
- package/dist/commands/pr-status.js +0 -207
- package/dist/commands/pr.js +0 -105
- package/dist/commands/prepare-universe-review.js +0 -1092
- package/dist/commands/production-source-deny-policy.js +0 -100
- package/dist/commands/request-review.js +0 -74
- package/dist/commands/review.js +0 -191
- package/dist/commands/rules.js +0 -98
- package/dist/commands/scaffold-provisional-universe.js +0 -806
- package/dist/commands/setup.js +0 -1170
- package/dist/commands/skills.js +0 -447
- package/dist/commands/submit.js +0 -745
- package/dist/commands/system-map.js +0 -228
- package/dist/commands/triage.js +0 -598
- package/dist/commands/webhooks.js +0 -241
- package/dist/states.js +0 -46
- package/dist/tools/detect.js +0 -832
- package/dist/triage/astra.js +0 -202
- package/dist/triage/prompts.js +0 -188
- package/dist/triage/runner.js +0 -200
- package/dist/triage/types.js +0 -7
- package/dist/utils/action-output.js +0 -26
- package/dist/utils/analysis-api.js +0 -416
- package/dist/utils/design-verifier-api.js +0 -294
- package/dist/utils/design-verifier-history.js +0 -79
- package/dist/utils/design-verifier-result.js +0 -424
- package/dist/utils/github-api.js +0 -324
- package/dist/utils/pending-state.js +0 -86
- package/dist/utils/pr-ref.js +0 -56
- package/dist/utils/prompter.js +0 -328
- package/schemas/action.v1.json +0 -22
- package/schemas/ask.v1.json +0 -40
- package/schemas/inbox.v1.json +0 -27
- package/schemas/pr-status.v1.json +0 -61
- package/schemas/pr.v1.json +0 -97
- package/schemas/pr.v3.json +0 -45
- package/schemas/setup.v1.json +0 -75
- package/schemas/submit.v1.json +0 -90
- package/schemas/triage.v1.json +0 -103
- package/schemas/triage.v2.json +0 -64
|
@@ -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),
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* publish the directories (relative to the app) the build step publishes the manifest into, so haystack-capture-step,
|
|
13
13
|
* which loads nothing of the CLI, can withdraw a stale current.json there when the manifest was not made.
|
|
14
14
|
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
15
16
|
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
-
import { join } from 'node:path';
|
|
17
|
+
import { join, posix } from 'node:path';
|
|
17
18
|
import { CAPTURE_KEY_PATTERN, CAPTURE_STATUS_PATH, CAPTURE_VERSION } from '../commands/capture-contract.js';
|
|
18
19
|
import { haystackFetch, haystackJson } from '../utils/haystack-api.js';
|
|
19
20
|
export const CAPTURE_CONFIG_PATH = '.haystack/capture.json';
|
|
@@ -36,11 +37,35 @@ export function readCaptureConfig(appDir) {
|
|
|
36
37
|
if (parsed.version !== CAPTURE_VERSION || strings.some(name => typeof parsed[name] !== 'string')
|
|
37
38
|
|| !CAPTURE_KEY_PATTERN.test(parsed.key) || !(parsed.adapter === null || typeof parsed.adapter === 'string')
|
|
38
39
|
|| !Array.isArray(parsed.origins) || !parsed.origins.every(origin => typeof origin === 'string')
|
|
39
|
-
|| !Array.isArray(parsed.publish) || !parsed.publish.every(dir => typeof dir === 'string')
|
|
40
|
+
|| !Array.isArray(parsed.publish) || !parsed.publish.every(dir => typeof dir === 'string')
|
|
41
|
+
|| !(parsed.urlRewrites === undefined || parsed.urlRewrites === null || typeof parsed.urlRewrites === 'string')) {
|
|
40
42
|
throw new Error(`${CAPTURE_CONFIG_PATH} is not a capture configuration init wrote; remove it and run \`haystack init\` again.`);
|
|
41
43
|
}
|
|
42
44
|
return parsed;
|
|
43
45
|
}
|
|
46
|
+
/** The directories (repository-relative, posix; '.' for the root) holding a .haystack/capture.json: those git lists
|
|
47
|
+
* (committed, or not yet), and the candidates that have one even when ignored. */
|
|
48
|
+
export function captureConfigDirs(gitRoot, candidates) {
|
|
49
|
+
const listed = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', CAPTURE_CONFIG_PATH,
|
|
50
|
+
`:(glob)**/${CAPTURE_CONFIG_PATH}`], { cwd: gitRoot, encoding: 'utf8' });
|
|
51
|
+
if (listed.status !== 0)
|
|
52
|
+
throw new Error(`git ls-files failed in ${gitRoot}: ${listed.stderr.trim()}`);
|
|
53
|
+
return [...new Set([...listed.stdout.split('\0').filter(path => path !== '' && !path.split('/').includes('node_modules'))
|
|
54
|
+
.map(path => posix.dirname(posix.dirname(path))), ...candidates])].filter(dir => existsSync(captureConfigFile(join(gitRoot, dir)))).sort();
|
|
55
|
+
}
|
|
56
|
+
/** The apps (repository-relative, posix; '.' for the root) whose .haystack/capture.json records them: the user's earlier
|
|
57
|
+
* answer to which app serves production. Records git lists (committed, or not yet), and those in the candidate app
|
|
58
|
+
* directories even when ignored. */
|
|
59
|
+
export function recordedApps(gitRoot, candidates) {
|
|
60
|
+
return captureConfigDirs(gitRoot, candidates).filter(dir => {
|
|
61
|
+
try {
|
|
62
|
+
return readCaptureConfig(join(gitRoot, dir))?.app === dir;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new Error(`${posix.join(dir, CAPTURE_CONFIG_PATH)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
44
69
|
export function formatCaptureConfig(config) {
|
|
45
70
|
return `${JSON.stringify(config, null, 2)}\n`;
|
|
46
71
|
}
|
package/dist/capture/csp.js
CHANGED
|
@@ -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
|
-
|
|
207
|
-
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/dist/capture-step.js
CHANGED
|
@@ -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 */ }
|
|
@@ -1,42 +1,37 @@
|
|
|
1
1
|
/** CAPTURE-V1 rule 7b in `haystack pre-verify`: the routes a change touches (the brief's blast-radius files and the changed
|
|
2
2
|
* files, joined through the route manifest's source entries) with their share of captured sessions over the window, the union
|
|
3
|
-
* over all of them, freshness and drops; sessions on routes no manifest names are unmapped, never zero.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
import { join } from 'node:path';
|
|
3
|
+
* over all of them, freshness and drops; sessions on routes no manifest names are unmapped, never zero. One section per app
|
|
4
|
+
* whose `.haystack/capture.json` (rule 8b, written by `haystack init` in the app's directory, apps/web in a monorepo) records it,
|
|
5
|
+
* found as init finds them (app-config.ts); a checkout without one has no capture and the brief is exactly as before. A capture
|
|
6
|
+
* read that fails is reported in its section and never fails the command. */
|
|
7
|
+
import { join, posix } from 'node:path';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
|
+
import { CAPTURE_CONFIG_PATH, captureConfigDirs, readCaptureConfig } from '../capture/app-config.js';
|
|
9
10
|
import { classifyHttpError } from '../utils/haystack-api.js';
|
|
10
11
|
import { gatewayFetch } from './case-batch.js';
|
|
11
12
|
import { CAPTURE_APPLICATION_ID, CAPTURE_WINDOW_PATH, captureChangedRoutes, } from './capture-contract.js';
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const path = join(gitRoot, CAPTURE_IDENTITY_FILE);
|
|
17
|
-
if (!existsSync(path))
|
|
18
|
-
return null;
|
|
19
|
-
let value;
|
|
13
|
+
/** An app's application id from its record, or why the record cannot give one. */
|
|
14
|
+
function captureApplication(gitRoot, app) {
|
|
15
|
+
const file = posix.join(app, CAPTURE_CONFIG_PATH);
|
|
16
|
+
let config;
|
|
20
17
|
try {
|
|
21
|
-
|
|
18
|
+
config = readCaptureConfig(join(gitRoot, app));
|
|
22
19
|
}
|
|
23
20
|
catch (error) {
|
|
24
|
-
return { unreadable: `${
|
|
25
|
-
}
|
|
26
|
-
const applicationId = value?.applicationId;
|
|
27
|
-
if (typeof applicationId !== 'string' || !CAPTURE_APPLICATION_ID.test(applicationId)) {
|
|
28
|
-
return { unreadable: `${CAPTURE_IDENTITY_FILE} names no valid applicationId` };
|
|
21
|
+
return { unreadable: `${file}: ${error instanceof Error ? error.message : String(error)}` };
|
|
29
22
|
}
|
|
30
|
-
|
|
23
|
+
if (config === null)
|
|
24
|
+
return { unreadable: `${file} is gone` };
|
|
25
|
+
if (config.app !== app)
|
|
26
|
+
return { unreadable: `${file} records the app ${config.app}, not its own directory; run \`haystack init --app ${app}\`` };
|
|
27
|
+
if (!CAPTURE_APPLICATION_ID.test(config.applicationId))
|
|
28
|
+
return { unreadable: `${file} names no valid applicationId` };
|
|
29
|
+
return { applicationId: config.applicationId };
|
|
31
30
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
|
|
35
|
-
const application = captureApplication(gitRoot);
|
|
36
|
-
if (application === null)
|
|
37
|
-
return null;
|
|
31
|
+
async function readApp(gitRoot, app, repository, token, files, now) {
|
|
32
|
+
const application = captureApplication(gitRoot, app);
|
|
38
33
|
if ('unreadable' in application)
|
|
39
|
-
return { applicationId: null, state: 'unavailable', reason: application.unreadable };
|
|
34
|
+
return { app, applicationId: null, state: 'unavailable', reason: application.unreadable };
|
|
40
35
|
const { applicationId } = application;
|
|
41
36
|
try {
|
|
42
37
|
const response = await gatewayFetch(`${CAPTURE_WINDOW_PATH}?repository=${encodeURIComponent(repository)}&app=${encodeURIComponent(applicationId)}`, token);
|
|
@@ -46,18 +41,32 @@ export async function readPreVerifyCapture(gitRoot, repository, token, files, no
|
|
|
46
41
|
if (answer.version !== 'capture-v1' || answer.applicationId !== applicationId)
|
|
47
42
|
throw new Error('the capture window answer names another application');
|
|
48
43
|
if (answer.window === null)
|
|
49
|
-
return { applicationId, state: 'no-data' };
|
|
50
|
-
return { applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
|
|
44
|
+
return { app, applicationId, state: 'no-data' };
|
|
45
|
+
return { app, applicationId, state: 'ready', window: captureChangedRoutes(answer.window, files, now) };
|
|
51
46
|
}
|
|
52
47
|
catch (error) {
|
|
53
|
-
return { applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
|
|
48
|
+
return { app, applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
|
|
54
49
|
}
|
|
55
50
|
}
|
|
51
|
+
/** The pre-verify capture sections, one per app with a record, or null for a checkout with none. `files`: the brief's function
|
|
52
|
+
* files and the changed files. */
|
|
53
|
+
export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
|
|
54
|
+
let apps;
|
|
55
|
+
try {
|
|
56
|
+
apps = captureConfigDirs(gitRoot, []);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return [{ app: '.', applicationId: null, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) }];
|
|
60
|
+
}
|
|
61
|
+
if (apps.length === 0)
|
|
62
|
+
return null;
|
|
63
|
+
return Promise.all(apps.map(app => readApp(gitRoot, app, repository, token, files, now)));
|
|
64
|
+
}
|
|
56
65
|
const percent = (share) => share === null ? 'n/a' : `${(share * 100).toFixed(1)}%`;
|
|
57
66
|
const plain = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '); // eslint-disable-line no-control-regex
|
|
58
|
-
/**
|
|
67
|
+
/** One app's block as a coding agent reads it, after the brief. */
|
|
59
68
|
export function formatCapture(capture) {
|
|
60
|
-
const lines = [chalk.bold(
|
|
69
|
+
const lines = [chalk.bold(`What real users do where your change is (captured sessions of ${capture.app === '.' ? 'the app at the repository root' : plain(capture.app)})`)];
|
|
61
70
|
if (capture.state === 'unavailable') {
|
|
62
71
|
lines.push(` Captured sessions could not be read${capture.applicationId ? ` for ${plain(capture.applicationId)}` : ''}: ${plain(capture.reason)}`);
|
|
63
72
|
return lines.join('\n');
|
|
@@ -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
|
|
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 (
|
|
48
|
-
throw new Error(
|
|
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
|
|
55
|
-
if (!detected
|
|
56
|
-
throw new Error(
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
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(
|
|
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
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* haystack feedback <message...>
|
|
3
|
+
*
|
|
4
|
+
* Tells the Haystack team something, in a sentence: a verify that went wrong,
|
|
5
|
+
* an output that confused you, a missing option. Meant for coding agents as
|
|
6
|
+
* much as for people: when Haystack gets in the way, say so here and keep
|
|
7
|
+
* going. The message lands in the team's Slack at once, with the CLI version,
|
|
8
|
+
* the platform and the repository (when run inside a GitHub checkout).
|
|
9
|
+
*
|
|
10
|
+
* `-` as the whole message reads it from stdin.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import { withSchema } from '../schema.js';
|
|
17
|
+
import { resolveAuthContext } from '../utils/auth.js';
|
|
18
|
+
import { parseRemoteUrl } from '../utils/git.js';
|
|
19
|
+
import { haystackJson } from '../utils/haystack-api.js';
|
|
20
|
+
export const FEEDBACK_MAX_CHARS = 4000;
|
|
21
|
+
function cliVersion() {
|
|
22
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
|
23
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
24
|
+
}
|
|
25
|
+
/** The checkout's GitHub repository, or null outside one (feedback never needs a repository). */
|
|
26
|
+
function currentRepository() {
|
|
27
|
+
try {
|
|
28
|
+
const remote = parseRemoteUrl();
|
|
29
|
+
return `${remote.owner}/${remote.repo}`;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function buildFeedbackRequest(message, options, repository) {
|
|
36
|
+
const text = message.trim();
|
|
37
|
+
if (!text)
|
|
38
|
+
throw new Error('Say what happened: haystack feedback "<message>" (or - to read it from stdin)');
|
|
39
|
+
if (text.length > FEEDBACK_MAX_CHARS) {
|
|
40
|
+
throw new Error(`Feedback is ${text.length} characters; keep it under ${FEEDBACK_MAX_CHARS}`);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
message: text,
|
|
44
|
+
context: {
|
|
45
|
+
cliVersion: cliVersion(),
|
|
46
|
+
platform: `${process.platform}-${process.arch} node ${process.versions.node}`,
|
|
47
|
+
repository,
|
|
48
|
+
runId: options.run ?? null,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export async function feedbackCommand(words, options) {
|
|
53
|
+
const message = words.length === 1 && words[0] === '-' ? readFileSync(0, 'utf8') : words.join(' ');
|
|
54
|
+
const request = buildFeedbackRequest(message, options, currentRepository());
|
|
55
|
+
const { token } = await resolveAuthContext({ preferredLogin: options.account });
|
|
56
|
+
await haystackJson('/api/cli/feedback', token, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify(request),
|
|
60
|
+
});
|
|
61
|
+
if (options.json) {
|
|
62
|
+
process.stdout.write(JSON.stringify(withSchema('feedback', { status: 'sent' }), null, 2) + '\n');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
console.log(chalk.green('Sent to the Haystack team.'));
|
|
66
|
+
}
|