mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bd38fb236 | ||
|
|
7e5ad53491 | ||
|
|
a529578cd1 |
@@ -670,11 +670,11 @@ describe("paseo daemon bootstrap", () => {
|
|||||||
|
|
||||||
test("rolls back standalone listener without starting plugins when main listen fails", async () => {
|
test("rolls back standalone listener without starting plugins when main listen fails", async () => {
|
||||||
const mainPort = await findFreePort();
|
const mainPort = await findFreePort();
|
||||||
|
const standalonePort = await findFreePort();
|
||||||
const occupiedMain = http.createServer((_req, res) => {
|
const occupiedMain = http.createServer((_req, res) => {
|
||||||
res.end("occupied-main");
|
res.end("occupied-main");
|
||||||
});
|
});
|
||||||
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
|
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
|
||||||
const standalonePort = await findFreePort();
|
|
||||||
|
|
||||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
|
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
|
||||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||||
|
|||||||
@@ -61,24 +61,10 @@ describe("managed Git plugin sources", () => {
|
|||||||
roots.push(home);
|
roots.push(home);
|
||||||
const sources = new ManagedPluginSources(home);
|
const sources = new ManagedPluginSources(home);
|
||||||
|
|
||||||
// Own the failure response instead of relying on a closed port and OS connect timing.
|
const failure = sources.prepareInstall({
|
||||||
const server = createServer((_request, response) => {
|
source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git",
|
||||||
response.writeHead(500, { "Content-Type": "text/plain" });
|
|
||||||
response.end("clone rejected for oauth2:super-secret\n");
|
|
||||||
});
|
});
|
||||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
|
||||||
const address = server.address() as { port: number };
|
|
||||||
try {
|
|
||||||
const failure = sources.prepareInstall({
|
|
||||||
source: `git:http://oauth2:super-secret@127.0.0.1:${address.port}/missing.git`,
|
|
||||||
});
|
|
||||||
await expect(failure).rejects.toThrow("500");
|
|
||||||
await expect(failure).rejects.toThrow("clone rejected for [redacted]:[redacted]");
|
|
||||||
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
|
|
||||||
} finally {
|
|
||||||
server.closeAllConnections();
|
|
||||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => {
|
it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { type EventEmitter, once } from "node:events";
|
|
||||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import type { Duplex } from "node:stream";
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
@@ -334,39 +332,48 @@ describe("service proxy subsystem shape", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ProxiedServiceFixture {
|
interface ForwardedFixture {
|
||||||
upstreamPort: number;
|
upstreamPort: number;
|
||||||
daemonPort: number;
|
daemonPort: number;
|
||||||
daemon: http.Server;
|
|
||||||
hostname: string;
|
hostname: string;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs a real workspace service behind a real daemon listener that serves the
|
* Runs a real workspace service behind a real daemon listener. The upstream
|
||||||
* service proxy for both HTTP and upgrades.
|
* echoes the headers it received so tests can assert what actually crossed the
|
||||||
|
* proxy, not what a helper returned.
|
||||||
*/
|
*/
|
||||||
async function startProxiedService(
|
async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> {
|
||||||
upstream: http.Server,
|
const upstream = http.createServer((req, res) => {
|
||||||
options: { upstreamHost?: string; logger?: pino.Logger } = {},
|
let body = "";
|
||||||
): Promise<ProxiedServiceFixture> {
|
req.on("data", (chunk: Buffer) => {
|
||||||
const { upstreamHost = "127.0.0.1", logger: proxyLogger = logger } = options;
|
body += chunk.toString();
|
||||||
// Upgraded sockets keep the server alive; close() alone would hang.
|
|
||||||
const sockets = new Set<net.Socket>();
|
|
||||||
function trackConnections(server: http.Server) {
|
|
||||||
server.on("connection", (socket: net.Socket) => {
|
|
||||||
sockets.add(socket);
|
|
||||||
socket.once("close", () => sockets.delete(socket));
|
|
||||||
});
|
});
|
||||||
}
|
req.on("end", () => {
|
||||||
trackConnections(upstream);
|
res.writeHead(200, { "content-type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const upgradeSockets: net.Socket[] = [];
|
||||||
|
upstream.on("upgrade", (req, socket) => {
|
||||||
|
upgradeSockets.push(socket);
|
||||||
|
// The client resets this connection once it has the echo, so the reset
|
||||||
|
// reaches us as an 'error'. A raw upgrade socket has no default handler —
|
||||||
|
// without this the event goes unhandled and takes down the test process.
|
||||||
|
socket.on("error", () => socket.destroy());
|
||||||
|
const payload = JSON.stringify(req.headers);
|
||||||
|
socket.write(
|
||||||
|
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
upstream.once("error", reject);
|
upstream.once("error", reject);
|
||||||
upstream.listen(0, upstreamHost, resolve);
|
upstream.listen(0, upstreamHost, resolve);
|
||||||
});
|
});
|
||||||
const upstreamPort = (upstream.address() as net.AddressInfo).port;
|
const upstreamPort = (upstream.address() as net.AddressInfo).port;
|
||||||
|
|
||||||
const serviceProxy = createServiceProxySubsystem({ logger: proxyLogger });
|
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||||
const route = serviceProxy.registerWorkspaceService({
|
const route = serviceProxy.registerWorkspaceService({
|
||||||
workspaceId: "workspace-a",
|
workspaceId: "workspace-a",
|
||||||
projectSlug: "repo",
|
projectSlug: "repo",
|
||||||
@@ -383,51 +390,24 @@ async function startProxiedService(
|
|||||||
res.status(404).send("404 Not Found");
|
res.status(404).send("404 Not Found");
|
||||||
});
|
});
|
||||||
const daemon = http.createServer(app);
|
const daemon = http.createServer(app);
|
||||||
trackConnections(daemon);
|
|
||||||
daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false }));
|
daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false }));
|
||||||
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
|
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
upstreamPort,
|
upstreamPort,
|
||||||
daemonPort,
|
daemonPort,
|
||||||
daemon,
|
|
||||||
hostname: route.hostname,
|
hostname: route.hostname,
|
||||||
async close() {
|
async close() {
|
||||||
for (const socket of sockets) socket.destroy();
|
// Upgraded sockets keep the server alive; close() alone would hang.
|
||||||
|
for (const socket of upgradeSockets) socket.destroy();
|
||||||
|
daemon.closeAllConnections();
|
||||||
await new Promise<void>((resolve) => daemon.close(() => resolve()));
|
await new Promise<void>((resolve) => daemon.close(() => resolve()));
|
||||||
|
upstream.closeAllConnections();
|
||||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The upstream echoes the headers it received so tests can assert what
|
|
||||||
* actually crossed the proxy, not what a helper returned.
|
|
||||||
*/
|
|
||||||
function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ProxiedServiceFixture> {
|
|
||||||
const upstream = http.createServer((req, res) => {
|
|
||||||
let body = "";
|
|
||||||
req.on("data", (chunk: Buffer) => {
|
|
||||||
body += chunk.toString();
|
|
||||||
});
|
|
||||||
req.on("end", () => {
|
|
||||||
res.writeHead(200, { "content-type": "application/json" });
|
|
||||||
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
upstream.on("upgrade", (req, socket) => {
|
|
||||||
// The client resets this connection once it has the echo, so the reset
|
|
||||||
// reaches us as an 'error'. A raw upgrade socket has no default handler —
|
|
||||||
// without this the event goes unhandled and takes down the test process.
|
|
||||||
socket.on("error", () => socket.destroy());
|
|
||||||
const payload = JSON.stringify(req.headers);
|
|
||||||
socket.write(
|
|
||||||
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return startProxiedService(upstream, { upstreamHost });
|
|
||||||
}
|
|
||||||
|
|
||||||
function upgradeThroughProxy(
|
function upgradeThroughProxy(
|
||||||
port: number,
|
port: number,
|
||||||
host: string,
|
host: string,
|
||||||
@@ -671,198 +651,3 @@ describe("service proxy forwarded headers", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function closed(emitter: EventEmitter): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
emitter.once("close", () => resolve());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function within<T>(promise: Promise<T>, ms: number, failure: string): Promise<T> {
|
|
||||||
let timer: NodeJS.Timeout | undefined;
|
|
||||||
const timeout = new Promise<never>((_resolve, reject) => {
|
|
||||||
timer = setTimeout(() => reject(new Error(`${failure} after ${ms}ms`)), ms);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
return await Promise.race([promise, timeout]);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestThroughProxy(
|
|
||||||
fixture: ProxiedServiceFixture,
|
|
||||||
requestPath: string,
|
|
||||||
): http.ClientRequest {
|
|
||||||
return http.get({
|
|
||||||
hostname: "127.0.0.1",
|
|
||||||
port: fixture.daemonPort,
|
|
||||||
path: requestPath,
|
|
||||||
headers: { host: fixture.hostname },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const STRAY_WARNING_GRACE_MS = 100;
|
|
||||||
|
|
||||||
function createUpgradeUpstream(onUpgrade: (socket: Duplex) => void): http.Server {
|
|
||||||
const upstream = http.createServer();
|
|
||||||
upstream.on("upgrade", (_req, socket) => {
|
|
||||||
socket.on("error", () => socket.destroy());
|
|
||||||
socket.on("end", () => socket.end());
|
|
||||||
socket.write(
|
|
||||||
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n",
|
|
||||||
);
|
|
||||||
onUpgrade(socket);
|
|
||||||
});
|
|
||||||
return upstream;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openUpgradeThroughProxy(fixture: ProxiedServiceFixture): Promise<net.Socket> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const socket = net.connect({ host: "127.0.0.1", port: fixture.daemonPort }, () => {
|
|
||||||
const lines = [
|
|
||||||
"GET /ws HTTP/1.1",
|
|
||||||
`Host: ${fixture.hostname}`,
|
|
||||||
"Upgrade: websocket",
|
|
||||||
"Connection: Upgrade",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
];
|
|
||||||
socket.write(lines.join("\r\n"));
|
|
||||||
});
|
|
||||||
let raw = "";
|
|
||||||
socket.on("data", (chunk: Buffer) => {
|
|
||||||
raw += chunk.toString();
|
|
||||||
if (raw.includes("\r\n\r\n")) resolve(socket);
|
|
||||||
});
|
|
||||||
socket.on("close", () => reject(new Error("upgrade closed before the 101 arrived")));
|
|
||||||
socket.on("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("service proxy connection teardown", () => {
|
|
||||||
it("closes the upstream connection when a client leaves an event stream", async () => {
|
|
||||||
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
|
|
||||||
const upstream = http.createServer((_req, res) => {
|
|
||||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
||||||
res.write("data: open\n\n");
|
|
||||||
upstreamResponse.resolve(res);
|
|
||||||
});
|
|
||||||
const fixture = await startProxiedService(upstream);
|
|
||||||
try {
|
|
||||||
const request = requestThroughProxy(fixture, "/events");
|
|
||||||
const [response] = (await once(request, "response")) as [http.IncomingMessage];
|
|
||||||
await once(response, "data");
|
|
||||||
const upstreamClosed = closed(await upstreamResponse.promise);
|
|
||||||
|
|
||||||
request.destroy();
|
|
||||||
|
|
||||||
await within(upstreamClosed, 1000, "the service still holds the event stream");
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
it("aborts the upstream request when a client leaves before the response starts", async () => {
|
|
||||||
const warnings: unknown[] = [];
|
|
||||||
const proxyLogger = pino(
|
|
||||||
{ level: "warn" },
|
|
||||||
{ write: (line: string) => warnings.push(JSON.parse(line)) },
|
|
||||||
);
|
|
||||||
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
|
|
||||||
const upstream = http.createServer((_req, res) => {
|
|
||||||
upstreamResponse.resolve(res);
|
|
||||||
});
|
|
||||||
const fixture = await startProxiedService(upstream, { logger: proxyLogger });
|
|
||||||
try {
|
|
||||||
const request = requestThroughProxy(fixture, "/slow");
|
|
||||||
request.on("error", () => undefined);
|
|
||||||
const upstreamClosed = closed(await upstreamResponse.promise);
|
|
||||||
|
|
||||||
request.destroy();
|
|
||||||
|
|
||||||
await within(upstreamClosed, 1000, "the service still holds the abandoned request");
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, STRAY_WARNING_GRACE_MS));
|
|
||||||
expect(warnings).toEqual([]);
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
it("lets a complete response finish without resetting the service", async () => {
|
|
||||||
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
|
|
||||||
const upstream = http.createServer((_req, res) => {
|
|
||||||
res.writeHead(200, { "content-type": "text/plain" });
|
|
||||||
res.write("first;");
|
|
||||||
upstreamResponse.resolve(res);
|
|
||||||
});
|
|
||||||
const fixture = await startProxiedService(upstream);
|
|
||||||
try {
|
|
||||||
const response = httpGet(fixture.daemonPort, fixture.hostname, { path: "/" });
|
|
||||||
const res = await upstreamResponse.promise;
|
|
||||||
const upstreamClosed = closed(res);
|
|
||||||
|
|
||||||
res.end("second");
|
|
||||||
|
|
||||||
await expect(response).resolves.toEqual({ status: 200, body: "first;second" });
|
|
||||||
await within(upstreamClosed, 1000, "the service response never closed");
|
|
||||||
expect(res.writableFinished).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
it("closes the upstream WebSocket when the daemon drops the client socket", async () => {
|
|
||||||
const upstreamSocket = Promise.withResolvers<Duplex>();
|
|
||||||
const fixture = await startProxiedService(
|
|
||||||
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
|
|
||||||
);
|
|
||||||
const daemonSide = Promise.withResolvers<Duplex>();
|
|
||||||
fixture.daemon.on("upgrade", (_req, socket) => daemonSide.resolve(socket));
|
|
||||||
try {
|
|
||||||
const client = await openUpgradeThroughProxy(fixture);
|
|
||||||
const upstreamClosed = closed(await upstreamSocket.promise);
|
|
||||||
|
|
||||||
(await daemonSide.promise).destroy();
|
|
||||||
|
|
||||||
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
|
|
||||||
client.destroy();
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
it("closes the upstream WebSocket when the client disconnects", async () => {
|
|
||||||
const upstreamSocket = Promise.withResolvers<Duplex>();
|
|
||||||
const fixture = await startProxiedService(
|
|
||||||
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const client = await openUpgradeThroughProxy(fixture);
|
|
||||||
const upstreamClosed = closed(await upstreamSocket.promise);
|
|
||||||
|
|
||||||
client.destroy();
|
|
||||||
|
|
||||||
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
it("closes the client WebSocket when the service closes it", async () => {
|
|
||||||
const upstreamSocket = Promise.withResolvers<Duplex>();
|
|
||||||
const fixture = await startProxiedService(
|
|
||||||
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const client = await openUpgradeThroughProxy(fixture);
|
|
||||||
const clientClosed = closed(client);
|
|
||||||
|
|
||||||
(await upstreamSocket.promise).destroy();
|
|
||||||
|
|
||||||
await within(clientClosed, 1000, "the client WebSocket is still open");
|
|
||||||
} finally {
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -342,14 +342,7 @@ function proxyHttpRequest({
|
|||||||
proxyRes.pipe(res, { end: true });
|
proxyRes.pipe(res, { end: true });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let clientClosed = false;
|
|
||||||
res.on("close", () => {
|
|
||||||
if (res.writableFinished) return;
|
|
||||||
clientClosed = true;
|
|
||||||
proxyReq.destroy();
|
|
||||||
});
|
|
||||||
proxyReq.on("error", (err) => {
|
proxyReq.on("error", (err) => {
|
||||||
if (clientClosed) return;
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ err, hostname: route.hostname, port: route.port },
|
{ err, hostname: route.hostname, port: route.port },
|
||||||
"Service proxy: upstream unreachable",
|
"Service proxy: upstream unreachable",
|
||||||
@@ -406,9 +399,6 @@ function proxyUpgradeRequest({
|
|||||||
socket.on("error", () => {
|
socket.on("error", () => {
|
||||||
targetSocket.destroy();
|
targetSocket.destroy();
|
||||||
});
|
});
|
||||||
socket.on("close", () => {
|
|
||||||
targetSocket.destroy();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean {
|
function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean {
|
||||||
|
|||||||
@@ -95,13 +95,7 @@ it("accepts terminalId and token reports through the route into the tracker", as
|
|||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
args: [
|
args: [
|
||||||
"-e",
|
"-e",
|
||||||
`
|
`require("node:fs").writeFileSync(${JSON.stringify(envPath)}, JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); setInterval(() => {}, 1000);`,
|
||||||
const fs = require("node:fs");
|
|
||||||
const envPath = ${JSON.stringify(envPath)};
|
|
||||||
fs.writeFileSync(envPath + ".tmp", JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL }));
|
|
||||||
fs.renameSync(envPath + ".tmp", envPath);
|
|
||||||
setInterval(() => {}, 1000);
|
|
||||||
`,
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await waitForCondition(() => existsSync(envPath), 10000);
|
await waitForCondition(() => existsSync(envPath), 10000);
|
||||||
|
|||||||
@@ -186,13 +186,7 @@ function insertDocByPath(nodes: DocsNavNode[], doc: Doc, stripTopDirectory = fal
|
|||||||
|
|
||||||
// A section's top-level directory and its category name the same thing, so
|
// A section's top-level directory and its category name the same thing, so
|
||||||
// only directories below it become collapsible groups.
|
// only directories below it become collapsible groups.
|
||||||
if (
|
if (stripTopDirectory) directories.shift();
|
||||||
stripTopDirectory &&
|
|
||||||
directories[0] &&
|
|
||||||
doc.frontmatter.category?.toLowerCase().endsWith(directories[0].toLowerCase())
|
|
||||||
) {
|
|
||||||
directories.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
let current = nodes;
|
let current = nodes;
|
||||||
let parent: Extract<DocsNavNode, { type: "group" }> | undefined;
|
let parent: Extract<DocsNavNode, { type: "group" }> | undefined;
|
||||||
|
|||||||
@@ -2,25 +2,6 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs";
|
import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs";
|
||||||
|
|
||||||
describe("plugin documentation", () => {
|
describe("plugin documentation", () => {
|
||||||
it.each(["en", "zh-CN"] as const)(
|
|
||||||
"nests local file copying under Git worktrees in %s",
|
|
||||||
(locale) => {
|
|
||||||
const workspaces = buildDocsNavTree(getDocs(locale)).find(
|
|
||||||
(node) => node.type === "category" && node.label === "Workspaces",
|
|
||||||
);
|
|
||||||
expect(workspaces).toMatchObject({
|
|
||||||
children: expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
type: "group",
|
|
||||||
href: "/docs/worktrees",
|
|
||||||
children: [expect.objectContaining({ href: "/docs/worktrees/worktreeinclude" })],
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
expect(getDoc("worktrees/worktreeinclude", locale)).toBeDefined();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("groups current guides by task without version navigation", () => {
|
it("groups current guides by task without version navigation", () => {
|
||||||
const plugins = buildDocsNavTree(getDocs("en")).find(
|
const plugins = buildDocsNavTree(getDocs("en")).find(
|
||||||
(node) => node.type === "category" && node.label === "Plugins",
|
(node) => node.type === "category" && node.label === "Plugins",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { expect, it } from "vitest";
|
|||||||
|
|
||||||
const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs");
|
const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs");
|
||||||
|
|
||||||
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees/index.md"])(
|
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])(
|
||||||
"%s makes schedule run retention discoverable",
|
"%s makes schedule run retention discoverable",
|
||||||
async (file) => {
|
async (file) => {
|
||||||
const markdown = await readFile(path.join(docsRoot, file), "utf8");
|
const markdown = await readFile(path.join(docsRoot, file), "utf8");
|
||||||
|
|||||||
@@ -84,7 +84,16 @@ Add `--forge <name>` when Paseo cannot infer the forge from the source checkout.
|
|||||||
|
|
||||||
## Copy local files with .worktreeinclude
|
## Copy local files with .worktreeinclude
|
||||||
|
|
||||||
Use [`.worktreeinclude`](/docs/worktrees/worktreeinclude) to copy selected gitignored files, such as `.env`, from your source checkout before setup runs.
|
Create `.worktreeinclude` at the root of your source checkout to copy selected gitignored files into new worktrees:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
.env
|
||||||
|
config/*.local.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Patterns use gitignore syntax. A file must match `.worktreeinclude` and be gitignored to be copied. Tracked files are never copied, and existing destination files are never overwritten. Without `.worktreeinclude`, no local files are copied. Symlinked directories and nested repositories inside an included path cause the copy to fail and abort worktree creation.
|
||||||
|
|
||||||
|
The built-in `worktree-include` plugin copies from the checkout you create the worktree from, including when that checkout is another worktree. Copying finishes before setup, terminals, and the first agent. A copy failure aborts creation and removes the new worktree. Copies use copy-on-write clones on APFS and btrfs when supported, with a regular copy elsewhere.
|
||||||
|
|
||||||
## paseo.json
|
## paseo.json
|
||||||
|
|
||||||
@@ -82,7 +82,16 @@ paseo workspace create \
|
|||||||
|
|
||||||
## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude}
|
## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude}
|
||||||
|
|
||||||
使用 [`.worktreeinclude`](/docs/worktrees/worktreeinclude),在 setup 运行前将 `.env` 等选定的 Git 忽略文件从源检出复制到新 worktree。
|
在源检出的根目录创建 `.worktreeinclude`,将选定的 Git 忽略文件复制到新的 worktree:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
.env
|
||||||
|
config/*.local.json
|
||||||
|
```
|
||||||
|
|
||||||
|
模式使用 gitignore 语法。只有同时匹配 `.worktreeinclude` 且被 Git 忽略的文件才会复制。已跟踪的文件不会被复制,目标中已存在的文件不会被覆盖。没有 `.worktreeinclude` 时,不会复制任何本地文件。包含路径中的符号链接目录和嵌套仓库会导致复制失败,并中止 worktree 创建。
|
||||||
|
|
||||||
|
内置的 `worktree-include` 插件从你创建 worktree 时所选的检出复制文件,即使源检出本身也是 worktree。复制会在 setup、终端和第一个 Agent 运行之前完成。复制失败会中止创建并删除新的 worktree。支持时会在 APFS 和 btrfs 上使用写时复制克隆,否则执行普通复制。
|
||||||
|
|
||||||
## paseo.json {#paseojson}
|
## paseo.json {#paseojson}
|
||||||
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
title: .worktreeinclude
|
|
||||||
description: Copy gitignored local files into new Paseo worktrees.
|
|
||||||
nav: .worktreeinclude
|
|
||||||
order: 12
|
|
||||||
category: Workspaces
|
|
||||||
---
|
|
||||||
|
|
||||||
# .worktreeinclude
|
|
||||||
|
|
||||||
Add `.worktreeinclude` at the root of your source checkout to copy local configuration into new [Git worktrees](/docs/worktrees). Paseo copies matching gitignored files before setup, terminals, and the first Agent start.
|
|
||||||
|
|
||||||
## Copy your local configuration
|
|
||||||
|
|
||||||
For a project whose `.gitignore` includes `.env` and `config/*.local.json`, create this `.worktreeinclude`:
|
|
||||||
|
|
||||||
```gitignore
|
|
||||||
.env
|
|
||||||
config/*.local.json
|
|
||||||
```
|
|
||||||
|
|
||||||
When you create a worktree through Paseo, an existing `.env` and matching files such as `config/dev.local.json` are copied to the same relative paths in the new worktree. Their contents stay out of Git.
|
|
||||||
|
|
||||||
Commit `.worktreeinclude` to share the patterns with your team. Each teammate supplies their own ignored files in the checkout they create worktrees from.
|
|
||||||
|
|
||||||
## Pattern syntax
|
|
||||||
|
|
||||||
Use gitignore syntax, with one pattern per line. Comments start with `#`. Prefix a pattern with `!` to exclude a match:
|
|
||||||
|
|
||||||
```gitignore
|
|
||||||
# Local environment and configuration
|
|
||||||
.env
|
|
||||||
config/*.local.json
|
|
||||||
!config/production.local.json
|
|
||||||
```
|
|
||||||
|
|
||||||
A pattern only selects files that Git already ignores. Adding a path here does not ignore it, so also configure `.gitignore` when needed. Tracked files and untracked files that Git does not ignore are excluded.
|
|
||||||
|
|
||||||
## When files are copied
|
|
||||||
|
|
||||||
The built-in `worktree-include` plugin reads the file from the checkout you create the worktree from. That source can itself be another worktree. Keep the plugin enabled for copying to run.
|
|
||||||
|
|
||||||
- Without `.worktreeinclude`, the plugin copies nothing.
|
|
||||||
- Matching files must exist in the source checkout.
|
|
||||||
- Existing destination files are skipped, preserving their contents.
|
|
||||||
- Copies happen during creation, with no ongoing synchronization between checkouts.
|
|
||||||
- A copy error aborts creation and removes the new worktree. Avoid including symlinked directories or nested repositories, which cannot be copied as files.
|
|
||||||
|
|
||||||
## Run commands during setup
|
|
||||||
|
|
||||||
Use `.worktreeinclude` for files that already exist locally. Use a [setup hook](/docs/worktrees#setup-and-teardown) to install dependencies, generate files, or fetch secrets:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"worktree": {
|
|
||||||
"setup": "npm ci"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Put this configuration in `paseo.json` on the base branch used for the new worktree. Copied files are available when setup runs.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
title: .worktreeinclude
|
|
||||||
description: 将 Git 忽略的本地文件复制到新的 Paseo worktree。
|
|
||||||
nav: .worktreeinclude
|
|
||||||
order: 12
|
|
||||||
category: Workspaces
|
|
||||||
---
|
|
||||||
|
|
||||||
# .worktreeinclude
|
|
||||||
|
|
||||||
在源检出的根目录添加 `.worktreeinclude`,将本地配置复制到新的 [Git worktree](/docs/worktrees)。Paseo 会在 setup、终端和首个 Agent 启动之前复制匹配的 Git 忽略文件。
|
|
||||||
|
|
||||||
## 复制本地配置 {#copy-your-local-configuration}
|
|
||||||
|
|
||||||
如果项目的 `.gitignore` 包含 `.env` 和 `config/*.local.json`,创建以下 `.worktreeinclude`:
|
|
||||||
|
|
||||||
```gitignore
|
|
||||||
.env
|
|
||||||
config/*.local.json
|
|
||||||
```
|
|
||||||
|
|
||||||
通过 Paseo 创建 worktree 时,已有的 `.env` 和 `config/dev.local.json` 等匹配文件会被复制到新 worktree 中的相同相对路径。文件内容仍不会纳入 Git。
|
|
||||||
|
|
||||||
提交 `.worktreeinclude`,与团队共享这些模式。每位团队成员在用于创建 worktree 的检出中提供自己的忽略文件。
|
|
||||||
|
|
||||||
## 模式语法 {#pattern-syntax}
|
|
||||||
|
|
||||||
使用 gitignore 语法,每行一个模式。注释以 `#` 开头。模式前加 `!` 可排除匹配项:
|
|
||||||
|
|
||||||
```gitignore
|
|
||||||
# Local environment and configuration
|
|
||||||
.env
|
|
||||||
config/*.local.json
|
|
||||||
!config/production.local.json
|
|
||||||
```
|
|
||||||
|
|
||||||
模式只会选择 Git 已忽略的文件。在此添加路径不会使 Git 忽略该文件,需要时还应配置 `.gitignore`。已跟踪的文件和未被 Git 忽略的未跟踪文件都不会被复制。
|
|
||||||
|
|
||||||
## 文件复制时机 {#when-files-are-copied}
|
|
||||||
|
|
||||||
内置的 `worktree-include` 插件从用于创建 worktree 的检出读取该文件。源检出也可以是另一个 worktree。保持该插件启用才能执行复制。
|
|
||||||
|
|
||||||
- 没有 `.worktreeinclude` 时,插件不会复制任何文件。
|
|
||||||
- 匹配的文件必须存在于源检出中。
|
|
||||||
- 目标中已存在的文件会被跳过,保留原有内容。
|
|
||||||
- 复制发生在创建期间,之后不会在检出之间持续同步。
|
|
||||||
- 复制错误会中止创建并删除新 worktree。避免包含符号链接目录或嵌套仓库,因为它们无法作为文件复制。
|
|
||||||
|
|
||||||
## 在 setup 期间运行命令 {#run-commands-during-setup}
|
|
||||||
|
|
||||||
使用 `.worktreeinclude` 复制本地已有文件。使用 [setup 钩子](/docs/worktrees#setup-and-teardown) 安装依赖、生成文件或获取密钥:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"worktree": {
|
|
||||||
"setup": "npm ci"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
将此配置放入新 worktree 所用基准分支的 `paseo.json` 中。setup 运行时,复制的文件已可使用。
|
|
||||||
Reference in New Issue
Block a user