mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
fix: Resolve the codespace box name from disk and fix the dev-loop guidance (no-changelog) (#36254)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,10 +80,15 @@ session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow
|
||||
- **Bring the app up with one command: `pnpm dev:up`.** It installs missing
|
||||
dependencies, starts the backend, waits for health, and prints the URL. Add
|
||||
`--build` only when a frontend change must appear (see below).
|
||||
- **Open the app** at `https://<codespace-name>-5678.app.github.dev`. The port
|
||||
is private. It opens for you in a browser that is signed in to GitHub. You do
|
||||
not need a tunnel. An anonymous or server caller gets a 302. That is why the
|
||||
worker polls outward instead.
|
||||
- **Open the app** at `https://<codespace-name>-5678.app.github.dev`. `dev:up`
|
||||
makes that port visible to the org, thus any n8n member who is signed into
|
||||
GitHub can open it. You do not need a tunnel. GitHub makes every forwarded port
|
||||
private again at each container start, so `dev:up` shares it again on each run.
|
||||
To see the current state, run `gh codespace ports`. The share command needs `gh`
|
||||
with the codespace scope (see the one-time setup above). If it fails, `dev:up`
|
||||
starts the app, prints the reason, and gives you the command to try again. A
|
||||
private port opens for you only, in a browser that is signed in to GitHub. An
|
||||
anonymous or server caller gets a 302. That is why the worker polls outward.
|
||||
- **`pnpm dev` no longer exists.** Use `pnpm dev:be` for the backend (on 5678).
|
||||
Use `pnpm dev:fe:editor` for the editor UI with hot reload (on 8080).
|
||||
- **`dev:be` serves the editor from the `dist` build.** So a frontend edit does
|
||||
@@ -187,6 +192,14 @@ After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
|
||||
Code shows `Missing environment variables: FLAKY_MCP_TOKEN`, the shell that
|
||||
started Claude did not source the file. Run
|
||||
`. /usr/local/lib/codespaces-env.sh` and start Claude again.
|
||||
- **Do not read `CODESPACE_NAME` or `GITHUB_USER` from the process env** — use
|
||||
`scripts/codespace-env.mjs`. Codespaces gives these variables to VS Code
|
||||
sessions only. Other processes read them from `codespaces-env.sh`, and a
|
||||
process that tmux starts can get an empty copy: tmux keeps the environment of
|
||||
its own start, and `update-environment` does not refresh these keys. A worker
|
||||
polled correctly as its owner while `dev:up` in the same session saw an empty
|
||||
box name, printed the localhost URL, and did not share the port. The helper
|
||||
reads `/workspaces/.codespaces/shared`, which is always correct.
|
||||
- **You cannot paste images into a remote Claude session.** Image paste reads
|
||||
the clipboard of the machine where `claude` runs — the codespace, not your
|
||||
laptop. Drag the file into the VS Code explorer (or
|
||||
|
||||
@@ -10,17 +10,20 @@
|
||||
// Env:
|
||||
// N8N_DEQUEUE_URL n8n webhook that hands back one pending turn (required)
|
||||
// AGENT_WORKER_TOKEN shared bearer sent on every dequeue (required)
|
||||
// GITHUB_USER box owner's login; the bootstrap route for a new thread (codespaces set this)
|
||||
// CODESPACE_NAME stable box id; routes a thread back to the box holding its session (codespaces set this)
|
||||
// GITHUB_USER box owner's login; the bootstrap route for a new thread (see codespace-env.mjs)
|
||||
// CODESPACE_NAME stable box id; routes a thread back to the box holding its session (same source)
|
||||
// TURN_TIMEOUT_MS per-turn limit; keep below the n8n Wait limit (default 25 min)
|
||||
import { execFile } from 'node:child_process';
|
||||
import { resolve as resolvePath, sep } from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { codespaceEnv } from '../../scripts/codespace-env.mjs';
|
||||
|
||||
const DEQUEUE_URL = process.env.N8N_DEQUEUE_URL;
|
||||
const TOKEN = process.env.AGENT_WORKER_TOKEN;
|
||||
const GITHUB_USER = process.env.GITHUB_USER;
|
||||
const BOX_ID = process.env.CODESPACE_NAME;
|
||||
// Read both identities from the codespace. tmux can give an empty copy of either.
|
||||
const GITHUB_USER = codespaceEnv('GITHUB_USER');
|
||||
const BOX_ID = codespaceEnv('CODESPACE_NAME');
|
||||
const ROOT = '/workspaces';
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
@@ -53,7 +56,16 @@ for (const [k, v] of Object.entries({
|
||||
|
||||
// Not fatal, but box pinning needs it: without a box id every turn routes by
|
||||
// owner, so a thread cannot follow the box holding its session.
|
||||
if (!BOX_ID) console.error('CODESPACE_NAME is not set — box pinning disabled; turns route by githubUser only.');
|
||||
if (!BOX_ID)
|
||||
console.error(
|
||||
'CODESPACE_NAME did not resolve — box pinning disabled; turns route by githubUser only.',
|
||||
);
|
||||
|
||||
// A turn gets a copy of this environment. Put the correct values in it, because
|
||||
// `pnpm dev:up` and `gh -c $CODESPACE_NAME` in the session need them.
|
||||
const TURN_ENV = { ...process.env };
|
||||
if (BOX_ID) TURN_ENV.CODESPACE_NAME = BOX_ID;
|
||||
if (GITHUB_USER) TURN_ENV.GITHUB_USER = GITHUB_USER;
|
||||
|
||||
function runClaude({ message, sessionId, cwd }) {
|
||||
const safeCwd = resolvePath(typeof cwd === 'string' && cwd ? cwd : `${ROOT}/n8n`);
|
||||
@@ -66,7 +78,7 @@ function runClaude({ message, sessionId, cwd }) {
|
||||
execFile(
|
||||
'claude',
|
||||
args,
|
||||
{ cwd: safeCwd, timeout: TURN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 },
|
||||
{ cwd: safeCwd, env: TURN_ENV, timeout: TURN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
try {
|
||||
res(JSON.parse(stdout));
|
||||
|
||||
@@ -28,6 +28,7 @@ services:
|
||||
DB_POSTGRESDB_PASSWORD: password
|
||||
# Prompt-cache TTL in seconds. 1 hour keeps the cache warm between turns.
|
||||
ANTHROPIC_PROMPT_CACHE_TTL: "3600"
|
||||
# Compose does not inherit the codespace env. Pass the box name through so
|
||||
# the worker can stamp boxId and dev:up can print the forwarded URL.
|
||||
CODESPACE_NAME: ${CODESPACE_NAME:-}
|
||||
# Do not add CODESPACE_NAME here. Codespaces does not give it to compose, so
|
||||
# this file can set an empty value only. An empty value is worse than no
|
||||
# value, because tmux keeps it for each new pane. The consumers read the name
|
||||
# with scripts/codespace-env.mjs.
|
||||
|
||||
@@ -255,8 +255,16 @@ What we use for testing and writing tests:
|
||||
- For E2E tests we use Playwright. Run with `pnpm --filter=n8n-playwright test:local`.
|
||||
See `packages/testing/playwright/README.md` for details.
|
||||
- **To iterate on a feature without docker rebuilds**, boot service containers
|
||||
and run `pnpm dev` locally — `pnpm --filter n8n-containers services --services postgres,redis,mailpit,proxy`
|
||||
then `pnpm dev`. See [Develop against running containers](packages/testing/playwright/README.md#develop-against-running-containers-avoid-docker-rebuilds).
|
||||
and run the dev servers locally — `pnpm --filter n8n-containers services --services postgres,redis,mailpit,proxy`
|
||||
then `pnpm dev:be` (backend on 5678). For frontend hot reload, also run
|
||||
`pnpm dev:fe:editor` (8080). The root `pnpm dev` does not exist: it prints a
|
||||
notice and exits with code 0, thus `pnpm dev && …` looks successful but no
|
||||
server runs. See
|
||||
[Develop against running containers](packages/testing/playwright/README.md#develop-against-running-containers-avoid-docker-rebuilds).
|
||||
- **In a codespace agent session**, use `pnpm dev:up`. It installs the missing
|
||||
dependencies, starts the backend, waits for health, shares the port with the
|
||||
org, and prints the URL. See
|
||||
[.devcontainer/codespaces/README.md](.devcontainer/codespaces/README.md).
|
||||
- **For Playwright test maintenance/cleanup**, see `packages/testing/playwright/AGENTS.md` (includes janitor tool for static analysis, dead code removal, architecture enforcement, and TCR workflows).
|
||||
|
||||
### Common Development Tasks
|
||||
|
||||
+7
-5
@@ -224,9 +224,10 @@ pnpm exec dotenvx run -f .env.local -- pnpm dev:be
|
||||
|
||||
## Development cycle
|
||||
|
||||
While iterating on n8n modules code, you can run `pnpm dev`. It will then
|
||||
automatically build your code, restart the backend and refresh the frontend
|
||||
(editor-ui) on every change you make.
|
||||
While iterating on n8n modules code, run `pnpm dev:be` for the backend and
|
||||
`pnpm dev:fe:editor` for the editor UI. They build your code, restart the
|
||||
backend, and refresh the frontend on each change you make. The root `pnpm dev`
|
||||
does not exist: it prints a notice and exits with code 0.
|
||||
Given the size of the code base and the number of modules, we recommend only watching the modules you're
|
||||
actively working on.
|
||||
|
||||
@@ -337,7 +338,7 @@ packages/cli$ N8N_USER_FOLDER=~/.n8n4/ pnpm run dev
|
||||
When developing custom nodes or credentials, you can enable hot reload to automatically detect changes without restarting the server by setting
|
||||
|
||||
```bash
|
||||
N8N_DEV_RELOAD=true pnpm dev
|
||||
N8N_DEV_RELOAD=true pnpm dev:be
|
||||
```
|
||||
|
||||
**Performance considerations:**
|
||||
@@ -417,7 +418,8 @@ For manual testing of the event bus with syslog (TCP or UDP), see [packages/cli/
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
The full development mode (`pnpm dev`) runs multiple processes in parallel:
|
||||
Full development mode (`pnpm dev:be` with `pnpm dev:fe:editor`) runs multiple
|
||||
processes in parallel:
|
||||
|
||||
1. **TypeScript compilation** for each package
|
||||
2. **File watchers** monitoring source files
|
||||
|
||||
@@ -16,8 +16,8 @@ N8N_BASE_URL=localhost:5068 pnpm test:local # Runs the E2E tests against the r
|
||||
## Develop against running containers (avoid docker rebuilds)
|
||||
|
||||
Iterating on a feature that needs postgres/redis/SMTP/an HTTP proxy? You don't
|
||||
need `pnpm build:docker` each time. Boot only the services your local `pnpm dev`
|
||||
needs, and let dev mode pick them up.
|
||||
need `pnpm build:docker` each time. Boot only the services your local dev
|
||||
servers need, and let dev mode pick them up.
|
||||
|
||||
**Two-terminal workflow:**
|
||||
|
||||
@@ -27,14 +27,15 @@ needs, and let dev mode pick them up.
|
||||
pnpm --filter n8n-containers services --services postgres,redis,mailpit,proxy
|
||||
|
||||
# Terminal 2 — run n8n locally as usual. It picks up the .env automatically.
|
||||
pnpm dev
|
||||
# Add `pnpm dev:fe:editor` in a third terminal for frontend hot reload.
|
||||
pnpm dev:be
|
||||
```
|
||||
|
||||
Scope the `--services` list to what you actually need — booting fewer
|
||||
containers makes startup faster.
|
||||
|
||||
| Service | What `pnpm dev` gets | Use when… |
|
||||
|---------|----------------------|-----------|
|
||||
| Service | What dev mode gets | Use when… |
|
||||
|---------|--------------------|-----------|
|
||||
| `postgres` | `DB_*` vars → PostgreSQL backend | testing migrations or PG-specific queries |
|
||||
| `redis` | `QUEUE_*`/`N8N_CACHE_*` → queue mode + cache | testing queue mode or distributed cache |
|
||||
| `mailpit` | `N8N_SMTP_*` → captured SMTP at `http://localhost:<mapped-port>` | testing email flows |
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Read the codespace identity from the files Codespaces writes, not from the env.
|
||||
// Codespaces gives CODESPACE_NAME and GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN to
|
||||
// VS Code sessions only. In this compose-based config, other processes can get an
|
||||
// empty copy. The files in /workspaces/.codespaces/shared are always correct.
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const SHARED = '/workspaces/.codespaces/shared';
|
||||
|
||||
function readShared(dir, file) {
|
||||
try {
|
||||
return readFileSync(`${dir}/${file}`, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a codespace variable, or undefined if it is missing or empty. */
|
||||
export function codespaceEnv(name, sharedDir = SHARED) {
|
||||
if (process.env[name]) return process.env[name];
|
||||
|
||||
try {
|
||||
const fromJson = JSON.parse(readShared(sharedDir, 'environment-variables.json') || '{}')[name];
|
||||
if (fromJson) return fromJson;
|
||||
} catch (error) {
|
||||
console.warn(`Ignoring ${sharedDir}/environment-variables.json: ${error.message}`);
|
||||
}
|
||||
|
||||
// The file has KEY=VALUE lines. Use the last line, as a shell does.
|
||||
const line = readShared(sharedDir, '.env')
|
||||
.split('\n')
|
||||
.findLast((l) => l.startsWith(`${name}=`));
|
||||
return line?.slice(name.length + 1).trim() || undefined;
|
||||
}
|
||||
|
||||
export const codespaceName = (sharedDir) => codespaceEnv('CODESPACE_NAME', sharedDir);
|
||||
|
||||
export const forwardingDomain = (sharedDir) =>
|
||||
codespaceEnv('GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN', sharedDir) ?? 'app.github.dev';
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { after, describe, it } from 'node:test';
|
||||
|
||||
import { codespaceEnv, codespaceName, forwardingDomain } from './codespace-env.mjs';
|
||||
|
||||
const NAME = 'psychic-umbrella-wqj9pvw9p939vp6';
|
||||
|
||||
function shared(files) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codespace-env-'));
|
||||
for (const [file, content] of Object.entries(files)) writeFileSync(join(dir, file), content);
|
||||
return dir;
|
||||
}
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
after(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('codespaceEnv', () => {
|
||||
it('prefers a non-empty env value', () => {
|
||||
process.env.CODESPACE_NAME = NAME;
|
||||
const dir = shared({ '.env': 'CODESPACE_NAME=from-file\n' });
|
||||
assert.equal(codespaceName(dir), NAME);
|
||||
});
|
||||
|
||||
// The bug this helper exists for: compose injects an empty value and tmux keeps it.
|
||||
it('treats an empty env value as missing', () => {
|
||||
process.env.CODESPACE_NAME = '';
|
||||
const dir = shared({ 'environment-variables.json': JSON.stringify({ CODESPACE_NAME: NAME }) });
|
||||
assert.equal(codespaceName(dir), NAME);
|
||||
});
|
||||
|
||||
it('falls back to .env when the JSON has no such key', () => {
|
||||
process.env.CODESPACE_NAME = '';
|
||||
const dir = shared({
|
||||
'environment-variables.json': JSON.stringify({ ACTION_NAME: 'createFromPrebuild' }),
|
||||
'.env': `CODESPACE_NAME=stale\nGITHUB_USER=someone\nCODESPACE_NAME=${NAME}\n`,
|
||||
});
|
||||
assert.equal(codespaceName(dir), NAME, 'the last line wins, as a shell does');
|
||||
assert.equal(codespaceEnv('GITHUB_USER', dir), 'someone');
|
||||
});
|
||||
|
||||
it('falls back to .env when the JSON is malformed', () => {
|
||||
process.env.CODESPACE_NAME = '';
|
||||
const dir = shared({
|
||||
'environment-variables.json': '{ not json',
|
||||
'.env': `CODESPACE_NAME=${NAME}\n`,
|
||||
});
|
||||
assert.equal(codespaceName(dir), NAME);
|
||||
});
|
||||
|
||||
it('returns undefined off a codespace', () => {
|
||||
process.env.CODESPACE_NAME = '';
|
||||
assert.equal(codespaceName(shared({})), undefined);
|
||||
});
|
||||
|
||||
it('ignores an empty value in .env', () => {
|
||||
process.env.CODESPACE_NAME = '';
|
||||
assert.equal(codespaceName(shared({ '.env': 'CODESPACE_NAME=\n' })), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forwardingDomain', () => {
|
||||
it('reads the domain Codespaces reports', () => {
|
||||
process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN = '';
|
||||
const dir = shared({
|
||||
'.env': 'GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=preview.app.github.dev\n',
|
||||
});
|
||||
assert.equal(forwardingDomain(dir), 'preview.app.github.dev');
|
||||
});
|
||||
|
||||
it('defaults to app.github.dev', () => {
|
||||
process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN = '';
|
||||
assert.equal(forwardingDomain(shared({})), 'app.github.dev');
|
||||
});
|
||||
});
|
||||
+18
-8
@@ -12,6 +12,8 @@ import { execFileSync, spawn } from 'node:child_process';
|
||||
import { existsSync, openSync } from 'node:fs';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { codespaceName, forwardingDomain } from './codespace-env.mjs';
|
||||
|
||||
const build = process.argv.includes('--build');
|
||||
const port = process.env.N8N_PORT ?? '5678';
|
||||
// Probe the same health path the backend serves (defaults to /healthz).
|
||||
@@ -57,24 +59,28 @@ while (Date.now() < deadline) {
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
const name = process.env.CODESPACE_NAME;
|
||||
const url = name ? `https://${name}-${port}.app.github.dev` : `http://localhost:${port}`;
|
||||
const name = codespaceName();
|
||||
const url = name ? `https://${name}-${port}.${forwardingDomain()}` : `http://localhost:${port}`;
|
||||
if (!healthy) {
|
||||
console.error(`\nBackend did not answer ${healthPath} within 2 min — check ${LOG}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// In a codespace, share the port with the org so any n8n member can open the
|
||||
// URL. Visibility resets to private on each start, so re-apply it here. gh needs
|
||||
// the codespace scope; skip with a note if the call fails, never block the run.
|
||||
// In a codespace, share the port with the org. Then any n8n member can open the
|
||||
// URL. GitHub makes every port private again at each start, so set it here. If
|
||||
// `gh` fails, print the reason and continue.
|
||||
let orgShared = false;
|
||||
let shareError;
|
||||
if (name) {
|
||||
try {
|
||||
execFileSync('gh', ['codespace', 'ports', 'visibility', `${port}:org`, '-c', name], {
|
||||
stdio: 'ignore',
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
orgShared = true;
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
const stderr = error.stderr?.toString().trim();
|
||||
shareError = (stderr || error.message).split('\n').pop();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nUp: ${url}`);
|
||||
@@ -82,5 +88,9 @@ if (name)
|
||||
console.log(
|
||||
orgShared
|
||||
? '(org-visible — any n8n member signed into GitHub can open it)'
|
||||
: `(still private — run \`gh codespace ports visibility ${port}:org -c $CODESPACE_NAME\` to share with the org)`,
|
||||
: `(still private: ${shareError} — retry with \`gh codespace ports visibility ${port}:org -c ${name}\`)`,
|
||||
);
|
||||
else if (existsSync('/workspaces/.codespaces'))
|
||||
console.log(
|
||||
`(on a codespace but the box name did not resolve, so ${port} was not shared with the org — see .devcontainer/codespaces/README.md)`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user