mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
[codex] Add backup endpoint and dev runtime hardening (#4087)
## Thinking Path > - Paperclip is a local-first control plane for AI-agent companies. > - Operators need predictable local dev behavior, recoverable instance data, and scripts that do not churn the running app. > - Several accumulated changes improve backup streaming, dev-server health, static UI caching/logging, diagnostic-file ignores, and instance isolation. > - These are operational improvements that can land independently from product UI work. > - This pull request groups the dev-infra and backup changes from the split branch into one standalone branch. > - The benefit is safer local operation, easier manual backups, less noisy dev output, and less cross-instance auth leakage. ## What Changed - Added a manual instance database backup endpoint and route tests. - Streamed backup/restore handling to avoid materializing large payloads at once. - Reduced dev static UI log/cache churn and ignored Node diagnostic report captures. - Added guarded dev auto-restart health polling coverage. - Preserved worktree config during provisioning and scoped auth cookies by instance. - Added a Discord daily digest helper script and environment documentation. - Hardened adapter-route and startup feedback export tests around the changed infrastructure. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run packages/db/src/backup-lib.test.ts server/src/__tests__/instance-database-backups-routes.test.ts server/src/__tests__/server-startup-feedback-export.test.ts server/src/__tests__/adapter-routes.test.ts server/src/__tests__/dev-runner-paths.test.ts server/src/__tests__/health-dev-server-token.test.ts server/src/__tests__/http-log-policy.test.ts server/src/__tests__/vite-html-renderer.test.ts server/src/__tests__/workspace-runtime.test.ts server/src/__tests__/better-auth.test.ts` - Split integration check: merged after the runtime/governance branch and before UI branches with no merge conflicts. - Confirmed this branch does not include `pnpm-lock.yaml`. ## Risks - Medium risk: touches server startup, backup streaming, auth cookie naming, dev health checks, and worktree provisioning. - Backup endpoint behavior depends on existing board/admin access controls and database backup helpers. - No database migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.4 tool-enabled coding model, agentic code-editing/runtime with local shell and GitHub CLI access; exact context window and reasoning mode are not exposed by the Paperclip harness. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
236d11d36f
commit
e89d3f7e11
27 changed files with 894 additions and 111 deletions
|
|
@ -83,11 +83,12 @@ describe("adapter routes", () => {
|
|||
|
||||
const res = await request(app).get("/api/adapters");
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body.length).toBeGreaterThan(0);
|
||||
const adapters = Array.isArray(res.body) ? res.body : JSON.parse(res.text);
|
||||
expect(Array.isArray(adapters)).toBe(true);
|
||||
expect(adapters.length).toBeGreaterThan(0);
|
||||
|
||||
// Every adapter should have a capabilities object
|
||||
for (const adapter of res.body) {
|
||||
for (const adapter of adapters) {
|
||||
expect(adapter.capabilities).toBeDefined();
|
||||
expect(typeof adapter.capabilities.supportsInstructionsBundle).toBe("boolean");
|
||||
expect(typeof adapter.capabilities.supportsSkills).toBe("boolean");
|
||||
|
|
|
|||
43
server/src/__tests__/better-auth.test.ts
Normal file
43
server/src/__tests__/better-auth.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { BetterAuthOptions } from "better-auth";
|
||||
import { getCookies } from "better-auth/cookies";
|
||||
import {
|
||||
buildBetterAuthAdvancedOptions,
|
||||
deriveAuthCookiePrefix,
|
||||
} from "../auth/better-auth.js";
|
||||
|
||||
const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
else process.env.PAPERCLIP_INSTANCE_ID = ORIGINAL_INSTANCE_ID;
|
||||
});
|
||||
|
||||
describe("Better Auth cookie scoping", () => {
|
||||
it("derives an instance-scoped cookie prefix", () => {
|
||||
expect(deriveAuthCookiePrefix("default")).toBe("paperclip-default");
|
||||
expect(deriveAuthCookiePrefix("PAP-1601-worktree")).toBe("paperclip-PAP-1601-worktree");
|
||||
});
|
||||
|
||||
it("uses PAPERCLIP_INSTANCE_ID for the Better Auth cookie prefix", () => {
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "sat-worktree";
|
||||
|
||||
const advanced = buildBetterAuthAdvancedOptions({ disableSecureCookies: false });
|
||||
|
||||
expect(advanced).toEqual({
|
||||
cookiePrefix: "paperclip-sat-worktree",
|
||||
});
|
||||
expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toBe(
|
||||
"paperclip-sat-worktree.session_token",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps local http auth cookies non-secure while preserving the scoped prefix", () => {
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "pap-worktree";
|
||||
|
||||
expect(buildBetterAuthAdvancedOptions({ disableSecureCookies: true })).toEqual({
|
||||
cookiePrefix: "paperclip-pap-worktree",
|
||||
useSecureCookies: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,12 +2,15 @@ import { describe, expect, it } from "vitest";
|
|||
import { shouldTrackDevServerPath } from "../../../scripts/dev-runner-paths.mjs";
|
||||
|
||||
describe("shouldTrackDevServerPath", () => {
|
||||
it("ignores repo-local Paperclip state and common test file paths", () => {
|
||||
it("ignores generated state, diagnostic reports, and common test file paths", () => {
|
||||
expect(
|
||||
shouldTrackDevServerPath(
|
||||
".paperclip/worktrees/PAP-712-for-project-configuration-get-rid-of-the-overview-tab-for-now/.agents/skills/paperclip",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(shouldTrackDevServerPath("server/report.20260416.154629.4965.0.001.json")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("server/report.20260416.154636.4725.0.001.json")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("server/report.20260416.154636.4965.0.002.json")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("server/src/__tests__/health.test.ts")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.test.ts")).toBe(false);
|
||||
expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.spec.tsx")).toBe(false);
|
||||
|
|
|
|||
128
server/src/__tests__/health-dev-server-token.test.ts
Normal file
128
server/src/__tests__/health-dev-server-token.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { healthRoutes } from "../routes/health.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createDevServerStatusFile(payload: unknown) {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-health-dev-server-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, "dev-server-status.json");
|
||||
writeFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("GET /health dev-server supervisor access", () => {
|
||||
it("exposes dev-server metadata to the supervising dev runner in authenticated mode", async () => {
|
||||
const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
|
||||
const previousToken = process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN;
|
||||
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({
|
||||
dirty: true,
|
||||
lastChangedAt: "2026-03-20T12:00:00.000Z",
|
||||
changedPathCount: 1,
|
||||
changedPathsSample: ["server/src/routes/health.ts"],
|
||||
pendingMigrations: [],
|
||||
lastRestartAt: "2026-03-20T11:30:00.000Z",
|
||||
});
|
||||
process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = "dev-runner-token";
|
||||
|
||||
let selectCall = 0;
|
||||
const db = {
|
||||
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
|
||||
select: vi.fn(() => {
|
||||
selectCall += 1;
|
||||
if (selectCall === 1) {
|
||||
return {
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn().mockResolvedValue([{ count: 1 }]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (selectCall === 2) {
|
||||
return {
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "settings-1",
|
||||
general: {},
|
||||
experimental: { autoRestartDevServerWhenIdle: true },
|
||||
createdAt: new Date("2026-03-20T11:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T11:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return {
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn().mockResolvedValue([{ count: 0 }]),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
} as unknown as Db;
|
||||
|
||||
try {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = { type: "none", source: "none" };
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/health",
|
||||
healthRoutes(db, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
authReady: true,
|
||||
companyDeletionEnabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.get("/health")
|
||||
.set("X-Paperclip-Dev-Server-Status-Token", "dev-runner-token");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
bootstrapStatus: "ready",
|
||||
bootstrapInviteActive: false,
|
||||
devServer: {
|
||||
enabled: true,
|
||||
restartRequired: true,
|
||||
reason: "backend_changes",
|
||||
lastChangedAt: "2026-03-20T12:00:00.000Z",
|
||||
changedPathCount: 1,
|
||||
changedPathsSample: ["server/src/routes/health.ts"],
|
||||
pendingMigrations: [],
|
||||
autoRestartEnabled: true,
|
||||
activeRunCount: 0,
|
||||
waitingForIdle: false,
|
||||
lastRestartAt: "2026-03-20T11:30:00.000Z",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (previousFile === undefined) {
|
||||
delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile;
|
||||
}
|
||||
if (previousToken === undefined) {
|
||||
delete process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN;
|
||||
} else {
|
||||
process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = previousToken;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -53,6 +53,8 @@ describe("shouldSilenceHttpSuccessLog", () => {
|
|||
});
|
||||
|
||||
it("silences successful static asset requests", () => {
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/", 200)).toBe(true);
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/index.html", 200)).toBe(true);
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/paperclip/ui/src/main.tsx", 200)).toBe(true);
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/src/App.tsx?t=123", 200)).toBe(true);
|
||||
expect(shouldSilenceHttpSuccessLog("GET", "/site.webmanifest", 200)).toBe(true);
|
||||
|
|
|
|||
149
server/src/__tests__/instance-database-backups-routes.test.ts
Normal file
149
server/src/__tests__/instance-database-backups-routes.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import {
|
||||
instanceDatabaseBackupRoutes,
|
||||
type InstanceDatabaseBackupService,
|
||||
} from "../routes/instance-database-backups.js";
|
||||
import { conflict } from "../errors.js";
|
||||
|
||||
function createApp(actor: Record<string, unknown>, service: InstanceDatabaseBackupService) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor as typeof req.actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", instanceDatabaseBackupRoutes(service));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function createBackupService(overrides: Partial<InstanceDatabaseBackupService> = {}): InstanceDatabaseBackupService {
|
||||
return {
|
||||
runManualBackup: vi.fn().mockResolvedValue({
|
||||
trigger: "manual",
|
||||
backupFile: "/tmp/paperclip-20260416.sql.gz",
|
||||
sizeBytes: 1234,
|
||||
prunedCount: 2,
|
||||
backupDir: "/tmp",
|
||||
retention: {
|
||||
dailyDays: 7,
|
||||
weeklyWeeks: 4,
|
||||
monthlyMonths: 1,
|
||||
},
|
||||
startedAt: "2026-04-16T20:00:00.000Z",
|
||||
finishedAt: "2026-04-16T20:00:01.000Z",
|
||||
durationMs: 1000,
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("instance database backup routes", () => {
|
||||
it("runs a manual backup for an instance admin and returns the server result", async () => {
|
||||
const service = createBackupService();
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: true,
|
||||
},
|
||||
service,
|
||||
);
|
||||
|
||||
const res = await request(app).post("/api/instance/database-backups").send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(service.runManualBackup).toHaveBeenCalledTimes(1);
|
||||
expect(res.body).toEqual({
|
||||
trigger: "manual",
|
||||
backupFile: "/tmp/paperclip-20260416.sql.gz",
|
||||
sizeBytes: 1234,
|
||||
prunedCount: 2,
|
||||
backupDir: "/tmp",
|
||||
retention: {
|
||||
dailyDays: 7,
|
||||
weeklyWeeks: 4,
|
||||
monthlyMonths: 1,
|
||||
},
|
||||
startedAt: "2026-04-16T20:00:00.000Z",
|
||||
finishedAt: "2026-04-16T20:00:01.000Z",
|
||||
durationMs: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local implicit board access", async () => {
|
||||
const service = createBackupService();
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
service,
|
||||
);
|
||||
|
||||
await request(app).post("/api/instance/database-backups").send({}).expect(201);
|
||||
|
||||
expect(service.runManualBackup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects non-admin board users", async () => {
|
||||
const service = createBackupService();
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
},
|
||||
service,
|
||||
);
|
||||
|
||||
await request(app).post("/api/instance/database-backups").send({}).expect(403);
|
||||
|
||||
expect(service.runManualBackup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agent callers", async () => {
|
||||
const service = createBackupService();
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
},
|
||||
service,
|
||||
);
|
||||
|
||||
await request(app).post("/api/instance/database-backups").send({}).expect(403);
|
||||
|
||||
expect(service.runManualBackup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns conflict when another server backup is already running", async () => {
|
||||
const service = createBackupService({
|
||||
runManualBackup: vi.fn().mockRejectedValue(conflict("Database backup already in progress")),
|
||||
});
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: true,
|
||||
},
|
||||
service,
|
||||
);
|
||||
|
||||
const res = await request(app).post("/api/instance/database-backups").send({});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toEqual({ error: "Database backup already in progress" });
|
||||
});
|
||||
});
|
||||
|
|
@ -128,6 +128,15 @@ vi.mock("../services/index.js", () => ({
|
|||
})),
|
||||
tickTimers: vi.fn(async () => ({ enqueued: 0 })),
|
||||
})),
|
||||
instanceSettingsService: vi.fn(() => ({
|
||||
getGeneral: vi.fn(async () => ({
|
||||
backupRetention: {
|
||||
dailyDays: 7,
|
||||
weeklyWeeks: 4,
|
||||
monthlyMonths: 1,
|
||||
},
|
||||
})),
|
||||
})),
|
||||
reconcilePersistedRuntimeServicesOnStartup: vi.fn(async () => ({ reconciled: 0 })),
|
||||
routineService: vi.fn(() => ({
|
||||
tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })),
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ describe("createCachedViteHtmlRenderer", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("reuses the injected dev html shell until a watched file changes", async () => {
|
||||
it("reuses the injected dev html shell until index.html changes", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-"));
|
||||
tempDirs.push(tempDir);
|
||||
const indexPath = path.join(tempDir, "index.html");
|
||||
|
|
@ -57,6 +57,12 @@ describe("createCachedViteHtmlRenderer", () => {
|
|||
expect(first.match(/\/@vite\/client/g)?.length).toBe(1);
|
||||
expect(first).toContain("window.$RefreshReg$");
|
||||
|
||||
const sourcePath = path.join(tempDir, "src", "main.tsx");
|
||||
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
|
||||
fs.writeFileSync(sourcePath, "export {};\n", "utf8");
|
||||
watcher.emit("change", sourcePath);
|
||||
expect(await renderer.render("/")).toBe(first);
|
||||
|
||||
fs.writeFileSync(
|
||||
indexPath,
|
||||
'<html><body>v2<script type="module" src="/src/main.tsx"></script></body></html>',
|
||||
|
|
|
|||
|
|
@ -897,7 +897,7 @@ describe("realizeExecutionWorkspace", () => {
|
|||
await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]);
|
||||
|
||||
try {
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
const workspaceInput = {
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
|
|
@ -923,7 +923,8 @@ describe("realizeExecutionWorkspace", () => {
|
|||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
} satisfies Parameters<typeof realizeExecutionWorkspace>[0];
|
||||
const workspace = await realizeExecutionWorkspace(workspaceInput);
|
||||
|
||||
const configPath = path.join(workspace.cwd, ".paperclip", "config.json");
|
||||
const envPath = path.join(workspace.cwd, ".paperclip", ".env");
|
||||
|
|
@ -954,6 +955,34 @@ describe("realizeExecutionWorkspace", () => {
|
|||
|
||||
process.chdir(workspace.cwd);
|
||||
expect(resolvePaperclipConfigPath()).toBe(configPath);
|
||||
|
||||
const preservedPort = 39999;
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...configContents,
|
||||
server: {
|
||||
...configContents.server,
|
||||
port: preservedPort,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(envPath, `${envContents}PAPERCLIP_WORKTREE_COLOR="#112233"\n`, "utf8");
|
||||
|
||||
const reusedWorkspace = await realizeExecutionWorkspace(workspaceInput);
|
||||
const reusedConfigContents = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
const reusedEnvContents = await fs.readFile(envPath, "utf8");
|
||||
|
||||
expect(reusedWorkspace.cwd).toBe(workspace.cwd);
|
||||
expect(reusedWorkspace.created).toBe(false);
|
||||
expect(reusedConfigContents.server.port).toBe(preservedPort);
|
||||
expect(reusedConfigContents.database.embeddedPostgresDataDir).toBe(path.join(expectedInstanceRoot, "db"));
|
||||
expect(reusedEnvContents).toContain('PAPERCLIP_WORKTREE_COLOR="#112233"');
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue