diff --git a/.gitignore b/.gitignore index a3ad611cd8a..ecd9c59ceac 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ CHANGELOG-*.md !packages/frontend/@n8n/design-system/**/*.mdx build-storybook.log build.log +.agent-setup/ sbom-source.cdx.json *.junit.xml junit.xml diff --git a/AGENTS.md b/AGENTS.md index afcedab66c8..7253ed3bbe3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,23 @@ See [plugin README](.claude/plugins/n8n/README.md) for structure and details. ## Essential Commands +### Fresh checkout / agent setup + +For a fresh checkout (cat-bot, a new hire, any agent verifying the repo +builds), prefer `pnpm agent:setup` over running install + build + tests by +hand. It chains them in one process, caps per-process memory and turbo +concurrency so a 6GB box doesn't OOM, streams all output to +`.agent-setup/.log` (gitignored), and surfaces only a one-line summary +per step plus the tail of the failing log. A machine-readable +`.agent-setup/summary.json` is always written so a backgrounded run is +readable in a single shot — no polling, no scrolling logs. + +```bash +pnpm agent:setup # install → build → test (full suite) +pnpm agent:setup install # one step at a time +pnpm agent:setup --json # JSON summary on stdout (for scripts/agents) +``` + ### Building Use `pnpm build` to build all packages. ALWAYS redirect the output of the build command to a file: diff --git a/package.json b/package.json index efaa08d74a0..0ba53b2799f 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs", "format": "turbo run format && node scripts/format.mjs", "grind": "node scripts/grind.mjs", + "agent:setup": "node scripts/agent-setup.mjs", "format:check": "turbo run format:check", "lint": "turbo run lint", "lint:styles": "turbo run lint:styles", diff --git a/packages/@n8n/constants/package.json b/packages/@n8n/constants/package.json index a81fe188d5e..d6ee382a6aa 100644 --- a/packages/@n8n/constants/package.json +++ b/packages/@n8n/constants/package.json @@ -12,7 +12,7 @@ "lint": "eslint . --quiet", "lint:fix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", - "test": "jest" + "test": "jest --passWithNoTests" }, "main": "dist/index.js", "module": "src/index.ts", diff --git a/packages/@n8n/extension-sdk/package.json b/packages/@n8n/extension-sdk/package.json index 60f1d1ae530..50c45766e66 100644 --- a/packages/@n8n/extension-sdk/package.json +++ b/packages/@n8n/extension-sdk/package.json @@ -39,7 +39,7 @@ "build:unchecked": "pnpm run build", "create-json-schema": "tsx scripts/create-json-schema.ts", "preview": "vite preview", - "test": "jest" + "test": "jest --passWithNoTests" }, "peerDependencies": { "vue": "catalog:frontend", diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 3ad63be56b5..a751dde6a2f 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -22,7 +22,7 @@ "lint:fix": "eslint src --fix", "prepack": "echo \"Building project...\" && rm -rf dist && tsc -b", "watch": "tsc --watch", - "test": "jest" + "test": "jest --passWithNoTests" }, "bin": { "n8n-node-dev": "./bin/n8n-node-dev" diff --git a/scripts/agent-setup.mjs b/scripts/agent-setup.mjs new file mode 100644 index 00000000000..e3391a51849 --- /dev/null +++ b/scripts/agent-setup.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** + * Token- and memory-friendly fresh-checkout setup/verify for agents. + * + * Runs install → build → test in one process and surfaces only a compact + * summary, so a fresh agent (cat-bot, Claude Code, a new hire) can verify a + * checkout without burning context tokens on tens of thousands of lines of + * pnpm/turbo/vitest output. Every spawned Node process is capped via + * NODE_OPTIONS=--max-old-space-size and turbo concurrency is capped so total + * resident memory stays bounded on a 6GB box. + * + * Usage: + * pnpm agent:setup [all|install|build|test] [flags] + * node scripts/agent-setup.mjs all --mem 6144 --concurrency 4 + * + * Exit codes: 0 = all steps pass, 1 = a step failed, 2 = invalid arguments. + * + * See DEVP-367 for design notes. + */ +import { spawn } from 'node:child_process'; +import { mkdirSync, openSync, statSync, writeFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; + +const REPO_ROOT = resolve(import.meta.dirname, '..'); +const VALID_STEPS = ['all', 'install', 'build', 'test']; + +const USAGE = `Usage: pnpm agent:setup [all|install|build|test] [flags] + +Steps: + all install → build → test (default; stops at first failure) + install pnpm install --frozen-lockfile + build turbo run build + test turbo run test (full suite) + +Flags: + --mem per-process old-space cap (default 6144, matches CI) + --concurrency turbo concurrency for build/test (default 4) + --tail lines of the failing log to print on failure (default 60) + --json emit only the JSON summary to stdout + --log-dir write logs and summary.json here (default .agent-setup/) + -h, --help show this help + +All step output streams to /.log. A machine-readable +/summary.json is always written. +`; + +function fail(msg) { + process.stderr.write(`agent-setup: ${msg}\n`); + process.exit(2); +} + +let values; +let positionals; +try { + ({ values, positionals } = parseArgs({ + options: { + mem: { type: 'string', default: '6144' }, + concurrency: { type: 'string', default: '4' }, + tail: { type: 'string', default: '60' }, + json: { type: 'boolean', default: false }, + 'log-dir': { type: 'string' }, + help: { type: 'boolean', default: false, short: 'h' }, + }, + allowPositionals: true, + strict: true, + })); +} catch (err) { + fail(err.message); +} + +if (values.help) { + process.stdout.write(USAGE); + process.exit(0); +} + +if (positionals.length > 1) { + fail(`unexpected extra arguments: ${positionals.slice(1).join(' ')}`); +} +const step = positionals[0] ?? 'all'; +if (!VALID_STEPS.includes(step)) { + fail(`unknown step "${step}" — must be one of: ${VALID_STEPS.join(', ')}`); +} + +const mem = Number(values.mem); +const concurrency = Number(values.concurrency); +const tailLines = Number(values.tail); +if (!Number.isInteger(mem) || mem <= 0) fail('--mem must be a positive integer (MB)'); +if (!Number.isInteger(concurrency) || concurrency <= 0) { + fail('--concurrency must be a positive integer'); +} +if (!Number.isInteger(tailLines) || tailLines < 0) { + fail('--tail must be a non-negative integer'); +} + +const logDir = values['log-dir'] + ? resolve(process.cwd(), values['log-dir']) + : resolve(REPO_ROOT, '.agent-setup'); +mkdirSync(logDir, { recursive: true }); + +// Call turbo directly via `pnpm exec` rather than the wrapper scripts so a +// user-supplied `--concurrency` isn't silently fought by a flag baked into a +// wrapper. +const PLAN = { + install: { cmd: 'pnpm', args: ['install', '--frozen-lockfile'] }, + build: { + cmd: 'pnpm', + args: ['exec', 'turbo', 'run', 'build', `--concurrency=${concurrency}`], + }, + test: { + cmd: 'pnpm', + args: ['exec', 'turbo', 'run', 'test', `--concurrency=${concurrency}`], + }, +}; + +const stepsToRun = step === 'all' ? ['install', 'build', 'test'] : [step]; + +const NODE_OPTS = `--max-old-space-size=${mem}`; +const childEnv = { + ...process.env, + NODE_OPTIONS: process.env.NODE_OPTIONS + ? `${process.env.NODE_OPTIONS} ${NODE_OPTS}` + : NODE_OPTS, + FORCE_COLOR: '0', +}; + +function elapsed(start) { + return Math.max(1, Math.round((Date.now() - start) / 1000)); +} + +function runStep(name) { + return new Promise((res) => { + const { cmd, args } = PLAN[name]; + const logPath = resolve(logDir, `${name}.log`); + const start = Date.now(); + const logFd = openSync(logPath, 'w'); + + if (!values.json) process.stdout.write(`▶ ${name.padEnd(7)} `); + + const child = spawn(cmd, args, { + cwd: REPO_ROOT, + env: childEnv, + stdio: ['ignore', logFd, logFd], + }); + + child.once('error', (err) => { + const seconds = elapsed(start); + if (!values.json) process.stdout.write(`✗ failed to spawn (${err.message})\n`); + res({ name, ok: false, seconds, log: logPath, error: err.message }); + }); + + child.once('exit', (code, signal) => { + const seconds = elapsed(start); + const ok = code === 0; + if (!values.json) { + const reason = signal ?? `exit ${code}`; + process.stdout.write(ok ? `✓ ${seconds}s\n` : `✗ ${seconds}s (${reason})\n`); + } + res({ name, ok, seconds, log: logPath, exitCode: code, signal }); + }); + }); +} + +async function tailFile(path, n) { + if (n <= 0) return ''; + const content = await readFile(path, 'utf8'); + const lines = content.split(/\r?\n/); + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + return lines.slice(-n).join('\n'); +} + +async function main() { + const results = []; + for (const name of stepsToRun) { + const r = await runStep(name); + try { + r.logKb = Math.round(statSync(r.log).size / 1024); + } catch { + r.logKb = 0; + } + results.push(r); + if (!r.ok) break; + } + + const failed = results.find((r) => !r.ok); + const failTail = failed ? await tailFile(failed.log, tailLines) : undefined; + + const summary = { + ok: !failed, + steps: results.map(({ name, ok, seconds, log, logKb }) => ({ + name, + ok, + seconds, + log, + logKb, + })), + ...(failTail !== undefined && { failTail }), + }; + const summaryPath = resolve(logDir, 'summary.json'); + writeFileSync(summaryPath, JSON.stringify(summary, null, 2) + '\n'); + + if (values.json) { + process.stdout.write(JSON.stringify(summary) + '\n'); + } else if (failed) { + process.stdout.write(`\n--- last ${tailLines} lines of ${failed.log} ---\n`); + if (failTail) process.stdout.write(failTail + '\n'); + process.stdout.write(`(full log: ${failed.log})\n`); + process.stdout.write(`(summary: ${summaryPath})\n`); + } else { + process.stdout.write(`(summary: ${summaryPath})\n`); + } + + process.exit(summary.ok ? 0 : 1); +} + +main().catch((err) => { + process.stderr.write(`agent-setup: ${err.stack ?? err.message}\n`); + process.exit(1); +});