Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
15 changes: 14 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1655,7 +1656,8 @@ async function runSetup(args: ParsedArgs): Promise<number> {
// 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)));
Expand All @@ -1668,6 +1670,8 @@ async function runSetup(args: ParsedArgs): Promise<number> {
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;
Expand Down Expand Up @@ -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;
}

Expand Down
25 changes: 19 additions & 6 deletions src/mark-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html lang="en"><body>…`) — 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;

Expand Down Expand Up @@ -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) };
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 9 additions & 2 deletions tests/bin-invocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.');
Expand All @@ -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 });
Expand Down
23 changes: 23 additions & 0 deletions tests/mark-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html lang="en"><body className="antialiased">{children}</body></html>',
'}',
'',
].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('<body'));
const parsed = ts.createSourceFile('layout.tsx', result.source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
expect((parsed as unknown as { parseDiagnostics: unknown[] }).parseDiagnostics).toEqual([]);
expect(ensureMarkerInJsxShell(result.source, 'next', 'abcdef123456').source).toBe(result.source);
});

it('does not take a tag inside a string on the same line for the shell', () => {
const { action } = ensureMarkerInJsxShell('export const shell = "<html><body>";\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');
Expand Down
Loading