diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index 23c8295..36029fa 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -146,7 +146,7 @@ This is a request, not a mechanism: nothing in the install depends on it. Do it ### The message you end on -When setup is finished, the person reads one short message, not a transcript of the install. Many tools do not show the person a command's output, only your reply, so the summary reaches them only if you put it there. When `setup` runs outside a terminal, its output ends with `──── Summary ────` and that message already written in Markdown from this run. **Show it as your final message, exactly as written, with every section** — including the line telling the person to say "done", which `next` answers. It carries one step, the person's: the `✘` lines under `To do` are yours to finish (see "Automated setup"), and they stay out of it. The setup output above it lists every file `setup` changed; do not add a list of your own. Do not reword it, drop a section, or add one of your own: no account of the commands you ran, no explanation of each change. Add at most one line, after it, for something you did beyond `setup` (another page you put the widget on, for example). When the output you got does not include the summary, run `npx @patchstack/connect summary` and show what it prints. Only when there is no summary at all, write the message yourself in this layout, filled in from the `setup` output: +When setup is finished, the person reads one short message, not a transcript of the install. Many tools do not show the person a command's output, only your reply, so the summary reaches them only if you put it there. When `setup` runs outside a terminal, its output ends with `──── Summary ────` and that message already written in Markdown from this run, then one last line saying to pass it on, which carries the link to connect their account while the site has no owner. Reporting only that the command succeeded leaves the person without their results or that link. **Show it as your final message, exactly as written, with every section** — including the line telling the person to say "done", which `next` answers. It carries one step, the person's: the `✘` lines under `To do` are yours to finish (see "Automated setup"), and they stay out of it. The setup output above it lists every file `setup` changed; do not add a list of your own. Do not reword it, drop a section, or add one of your own: no account of the commands you ran, no explanation of each change. Add at most one line, after it, for something you did beyond `setup` (another page you put the widget on, for example). When the output you got does not include the summary, run `npx @patchstack/connect summary` and show what it prints. Only when there is no summary at all, write the message yourself in this layout, filled in from the `setup` output: ````markdown ``` diff --git a/src/cli.ts b/src/cli.ts index 3ac7d4c..007afe1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -97,6 +97,7 @@ import { renderSummary, SUMMARY_HEADING, SUMMARY_INTRO, + summaryClosingLine, type StatusReport, } from './report.js'; import { login, readPendingLogin, redeemIfApproved, startLogin, waitForApproval } from './login.js'; @@ -1655,7 +1656,8 @@ async function runSetup(args: ParsedArgs): Promise { // An assistant reads this output and writes the person one message; a ready-made summary is what it // passes on. A terminal already shows the report above, and a build log is not read by a person. if (!isInstallOrBuildHook() && !runningInCi()) { - const summary = renderSummary({ ...report, missing }, guideProgress(after, reported), context, { widgetOnPage: after.widgetInstalled }); + const progress = guideProgress(after, reported); + const summary = renderSummary({ ...report, missing }, progress, context, { widgetOnPage: after.widgetInstalled }); // Kept, so `summary` can print it again: long setup output can be cut short before it reaches an assistant. try { ensureProjectDirectorySync(cwd, path.join(cwd, path.dirname(SUMMARY_FILE))); @@ -1668,6 +1670,8 @@ async function runSetup(args: ParsedArgs): Promise { console.log(SUMMARY_INTRO); console.log(SUMMARY_HEADING); for (const line of summary) console.log(line); + console.log(''); + console.log(summaryClosingLine(progress.connected, context.claimUrl)); } } return 0; @@ -1707,6 +1711,15 @@ function runSummary(): number { console.log(SUMMARY_INTRO); console.log(SUMMARY_HEADING); console.log(summary); + // Ends the way setup does, from what `.patchstackrc.json` records: whether the site has an owner, and its link. + let recorded: { claimed?: unknown; claimUrl?: unknown } = {}; + try { + recorded = JSON.parse(readFileSync(path.join(process.cwd(), '.patchstackrc.json'), 'utf8')) as typeof recorded; + } catch { + // No readable config: the closing line goes without the link. + } + console.log(''); + console.log(summaryClosingLine(recorded.claimed === true, typeof recorded.claimUrl === 'string' ? recorded.claimUrl : null)); return 0; } diff --git a/src/mark-build.ts b/src/mark-build.ts index be5dcbe..b3e673a 100644 --- a/src/mark-build.ts +++ b/src/mark-build.ts @@ -375,17 +375,30 @@ interface JsxShellAnchor { } /** - * Find a literal JSX opening tag at the start of a line. JSX attributes may - * contain `>` inside strings or braced expressions, so the tag boundary needs - * a small lexical scan rather than a character class. Self-closing tags cannot - * carry the marker as a child and are skipped. + * Find a literal JSX opening tag: at the start of a line first, and failing that as one of the tags a + * `return` or an arrow function opens with, which is how a shell written on one line reads + * (`return …`) — a tag inside a string is not in such a chain. JSX + * attributes may contain `>` inside strings or braced expressions, so the tag boundary needs a small + * lexical scan rather than a character class. Self-closing tags cannot carry the marker as a child and are + * skipped. */ function findJsxShellAnchor( source: string, tagName: 'head' | 'body', from = 0, ): JsxShellAnchor | null { - const candidates = new RegExp(`^([ \\t]*)<${tagName}(?=[\\s/>])`, 'gm'); + return scanJsxShellAnchor(source, new RegExp(`^([ \\t]*)<${tagName}(?=[\\s/>])`, 'gm'), from) + ?? scanJsxShellAnchor(source, new RegExp(`(?<=(?:\\breturn|=>)\\s*\\(?\\s*(?:<[A-Za-z][^<>]*>\\s*)*)<${tagName}(?=[\\s/>])`, 'g'), from); +} + +/** The leading whitespace of the line `index` is on. */ +function lineIndent(source: string, index: number): string { + const lineStart = source.lastIndexOf('\n', index - 1) + 1; + + return /^[ \t]*/.exec(source.slice(lineStart))?.[0] ?? ''; +} + +function scanJsxShellAnchor(source: string, candidates: RegExp, from: number): JsxShellAnchor | null { candidates.lastIndex = from; let candidate: RegExpExecArray | null; @@ -438,7 +451,7 @@ function findJsxShellAnchor( } else if (char === '>' && braces === 0) { const openingTag = source.slice(candidate.index, index + 1); if (/\/\s*>$/.test(openingTag)) break; - return { end: index + 1, indent: candidate[1] ?? '' }; + return { end: index + 1, indent: candidate[1] ?? lineIndent(source, candidate.index) }; } } } diff --git a/src/report.ts b/src/report.ts index 7040d11..36016da 100644 --- a/src/report.ts +++ b/src/report.ts @@ -266,6 +266,17 @@ export const SUMMARY_HEADING = '──── Summary ────'; /** The line printed just before the summary, saying what it is for. */ export const SUMMARY_INTRO = `Setup is finished. The person may not see this command's output, only your reply. The summary below is written for them: put it in your final message exactly as written, with every section. When they say "${NEXT_REPLY}", run npx @patchstack/connect next.`; +/** + * The line setup's output ends on, after the summary. It is the line an assistant is surest to read, so it + * repeats what the intro asks and, while the site has no owner, carries the link the person needs: an + * assistant that reports only that the command succeeded leaves them with no way to see their results. + */ +export function summaryClosingLine(connected: boolean, claimUrl: string | null): string { + return !connected && claimUrl !== null + ? `Your reply to the person must include the summary above, as written, with this link to connect their Patchstack account: ${claimUrl}` + : 'Your reply to the person must include the summary above, as written: it is their result and their next step.'; +} + export interface SummaryOptions { /** The widget tag is on the page, so the summary says where to see it. */ widgetOnPage: boolean; diff --git a/tests/bin-invocation.test.ts b/tests/bin-invocation.test.ts index 0045691..62da24e 100644 --- a/tests/bin-invocation.test.ts +++ b/tests/bin-invocation.test.ts @@ -730,6 +730,10 @@ describe.skipIf(!built)('the packaged bin, invoked as npm invokes it', () => { expect(setup).not.toMatch(/attack-surface|rule lookup/i); // Run outside a terminal, as an assistant runs it: the message to pass on comes last. expect(setup).toContain(SUMMARY_INTRO + '\n──── Summary ────\n```\n████████ P A T C H S T A C K\n▄▄▄▄▀▀▀▀\n```\n\n### 🛡️ Checked '); + // The very last line, the one an assistant is surest to read, says to pass the summary on and carries the link. + const last = setup.trimEnd().split('\n').at(-1) ?? ''; + expect(last).toMatch(/^Your reply to the person must include the summary above, as written, with this link to connect their Patchstack account: /); + expect(last).toContain(`/monitor/claim?site=${SITE}`); // The coloured banner is for a person's terminal; piped output carries only the summary's two-line mark. expect(setup).not.toContain("Checking your app's packages for known vulnerabilities"); expectPlain(setup); @@ -746,7 +750,8 @@ describe.skipIf(!built)('the packaged bin, invoked as npm invokes it', () => { const dir = freshProject(); try { const setup = await run(dir, server.endpoint, ['setup', '--widget'], CONCISE); - const summary = setup.slice(setup.indexOf('──── Summary ────')); + // The summary is what sits between its heading and the closing line for the assistant. + const summary = setup.slice(setup.indexOf('──── Summary ────'), setup.lastIndexOf('\n\nYour reply to the person')); expect(summary).toContain("**Refresh your app's preview** to see the Patchstack widget."); expect(summary).toContain('Your API key is in `.patchstackrc.local.json`: git ignores it, so never share it.'); @@ -760,7 +765,9 @@ describe.skipIf(!built)('the packaged bin, invoked as npm invokes it', () => { expect(report).toContain('`patchstack/rules.json`: protection files, for when your app has a server'); expect(readFileSync(path.join(dir, '.patchstack', 'summary.md'), 'utf8')).toBe(`${summary.slice(summary.indexOf('\n') + 1).trimEnd()}\n`); const again = await run(dir, server.endpoint, ['summary'], CONCISE); - expect(again.trimEnd()).toBe(setup.slice(setup.indexOf('Setup is finished.')).trimEnd()); + const body = (text: string) => text.slice(text.indexOf('Setup is finished.'), text.lastIndexOf('\n\nYour reply to the person')); + expect(body(again)).toBe(body(setup)); + expect(again.trimEnd().split('\n').at(-1)).toMatch(/^Your reply to the person must include the summary above, as written/); } finally { await server.close(); rmSync(dir, { recursive: true, force: true }); diff --git a/tests/mark-build.test.ts b/tests/mark-build.test.ts index 6ac3c4e..2cc37e6 100644 --- a/tests/mark-build.test.ts +++ b/tests/mark-build.test.ts @@ -563,6 +563,29 @@ describe('ensureMarkerInJsxShell', () => { expect(source).toBe(hand); }); + it('anchors on a shell written on one line, as some builders generate the root layout', () => { + const oneLine = [ + "export default function RootLayout({ children }: { children: React.ReactNode }) {", + ' return {children}', + '}', + '', + ].join('\n'); + + const result = ensureMarkerInJsxShell(oneLine, 'next', 'abcdef123456'); + + expect(result.action).toBe('added'); + expect(result.source).toContain('__PATCHSTACK_BUILD__="abcdef123456"'); + expect(result.source.indexOf('#region patchstack')).toBeGreaterThan(result.source.indexOf(' { + const { action } = ensureMarkerInJsxShell('export const shell = "";\n', 'next'); + expect(action).toBe('no-anchor'); + }); + it('reports no-anchor when the file has no JSX shell to attach to', () => { const { action } = ensureMarkerInJsxShell('export const x = 1;\n', 'tanstack-start'); expect(action).toBe('no-anchor');