offwatch 0.5.11 → 0.5.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -178
- package/bin/offwatch.js +6 -7
- package/lib/downloader.js +112 -0
- package/package.json +17 -7
- package/postinstall.js +21 -0
- package/src/__tests__/agent-jwt-env.test.ts +0 -79
- package/src/__tests__/allowed-hostname.test.ts +0 -80
- package/src/__tests__/auth-command-registration.test.ts +0 -16
- package/src/__tests__/board-auth.test.ts +0 -53
- package/src/__tests__/common.test.ts +0 -98
- package/src/__tests__/company-delete.test.ts +0 -95
- package/src/__tests__/company-import-export-e2e.test.ts +0 -502
- package/src/__tests__/company-import-url.test.ts +0 -74
- package/src/__tests__/company-import-zip.test.ts +0 -44
- package/src/__tests__/company.test.ts +0 -599
- package/src/__tests__/context.test.ts +0 -70
- package/src/__tests__/data-dir.test.ts +0 -79
- package/src/__tests__/doctor.test.ts +0 -102
- package/src/__tests__/feedback.test.ts +0 -177
- package/src/__tests__/helpers/embedded-postgres.ts +0 -6
- package/src/__tests__/helpers/zip.ts +0 -87
- package/src/__tests__/home-paths.test.ts +0 -44
- package/src/__tests__/http.test.ts +0 -106
- package/src/__tests__/network-bind.test.ts +0 -62
- package/src/__tests__/onboard.test.ts +0 -166
- package/src/__tests__/routines.test.ts +0 -249
- package/src/__tests__/telemetry.test.ts +0 -117
- package/src/__tests__/worktree-merge-history.test.ts +0 -492
- package/src/__tests__/worktree.test.ts +0 -982
- package/src/adapters/http/format-event.ts +0 -4
- package/src/adapters/http/index.ts +0 -7
- package/src/adapters/index.ts +0 -2
- package/src/adapters/process/format-event.ts +0 -4
- package/src/adapters/process/index.ts +0 -7
- package/src/adapters/registry.ts +0 -63
- package/src/checks/agent-jwt-secret-check.ts +0 -40
- package/src/checks/config-check.ts +0 -33
- package/src/checks/database-check.ts +0 -59
- package/src/checks/deployment-auth-check.ts +0 -88
- package/src/checks/index.ts +0 -18
- package/src/checks/llm-check.ts +0 -82
- package/src/checks/log-check.ts +0 -30
- package/src/checks/path-resolver.ts +0 -1
- package/src/checks/port-check.ts +0 -24
- package/src/checks/secrets-check.ts +0 -146
- package/src/checks/storage-check.ts +0 -51
- package/src/client/board-auth.ts +0 -282
- package/src/client/command-label.ts +0 -4
- package/src/client/context.ts +0 -175
- package/src/client/http.ts +0 -255
- package/src/commands/allowed-hostname.ts +0 -40
- package/src/commands/auth-bootstrap-ceo.ts +0 -138
- package/src/commands/client/activity.ts +0 -71
- package/src/commands/client/agent.ts +0 -315
- package/src/commands/client/approval.ts +0 -259
- package/src/commands/client/auth.ts +0 -113
- package/src/commands/client/common.ts +0 -221
- package/src/commands/client/company.ts +0 -1578
- package/src/commands/client/context.ts +0 -125
- package/src/commands/client/dashboard.ts +0 -34
- package/src/commands/client/feedback.ts +0 -645
- package/src/commands/client/issue.ts +0 -411
- package/src/commands/client/plugin.ts +0 -374
- package/src/commands/client/zip.ts +0 -129
- package/src/commands/configure.ts +0 -201
- package/src/commands/db-backup.ts +0 -102
- package/src/commands/doctor.ts +0 -203
- package/src/commands/env.ts +0 -411
- package/src/commands/heartbeat-run.ts +0 -344
- package/src/commands/onboard.ts +0 -692
- package/src/commands/routines.ts +0 -352
- package/src/commands/run.ts +0 -216
- package/src/commands/worktree-lib.ts +0 -279
- package/src/commands/worktree-merge-history-lib.ts +0 -764
- package/src/commands/worktree.ts +0 -2876
- package/src/config/data-dir.ts +0 -48
- package/src/config/env.ts +0 -125
- package/src/config/home.ts +0 -80
- package/src/config/hostnames.ts +0 -26
- package/src/config/schema.ts +0 -30
- package/src/config/secrets-key.ts +0 -48
- package/src/config/server-bind.ts +0 -183
- package/src/config/store.ts +0 -120
- package/src/index.ts +0 -182
- package/src/prompts/database.ts +0 -157
- package/src/prompts/llm.ts +0 -43
- package/src/prompts/logging.ts +0 -37
- package/src/prompts/secrets.ts +0 -99
- package/src/prompts/server.ts +0 -221
- package/src/prompts/storage.ts +0 -146
- package/src/telemetry.ts +0 -49
- package/src/utils/banner.ts +0 -24
- package/src/utils/net.ts +0 -18
- package/src/utils/path-resolver.ts +0 -25
- package/src/version.ts +0 -10
|
@@ -1,982 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { execFileSync } from "node:child_process";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
6
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
-
import {
|
|
8
|
-
agents,
|
|
9
|
-
companies,
|
|
10
|
-
createDb,
|
|
11
|
-
projects,
|
|
12
|
-
routines,
|
|
13
|
-
routineTriggers,
|
|
14
|
-
} from "@paperclipai/db";
|
|
15
|
-
import {
|
|
16
|
-
copyGitHooksToWorktreeGitDir,
|
|
17
|
-
copySeededSecretsKey,
|
|
18
|
-
pauseSeededScheduledRoutines,
|
|
19
|
-
readSourceAttachmentBody,
|
|
20
|
-
rebindWorkspaceCwd,
|
|
21
|
-
resolveSourceConfigPath,
|
|
22
|
-
resolveWorktreeReseedSource,
|
|
23
|
-
resolveWorktreeReseedTargetPaths,
|
|
24
|
-
resolveGitWorktreeAddArgs,
|
|
25
|
-
resolveWorktreeMakeTargetPath,
|
|
26
|
-
worktreeInitCommand,
|
|
27
|
-
worktreeMakeCommand,
|
|
28
|
-
worktreeReseedCommand,
|
|
29
|
-
} from "../commands/worktree.js";
|
|
30
|
-
import {
|
|
31
|
-
buildWorktreeConfig,
|
|
32
|
-
buildWorktreeEnvEntries,
|
|
33
|
-
formatShellExports,
|
|
34
|
-
generateWorktreeColor,
|
|
35
|
-
resolveWorktreeSeedPlan,
|
|
36
|
-
resolveWorktreeLocalPaths,
|
|
37
|
-
rewriteLocalUrlPort,
|
|
38
|
-
sanitizeWorktreeInstanceId,
|
|
39
|
-
} from "../commands/worktree-lib.js";
|
|
40
|
-
import type { PaperclipConfig } from "../config/schema.js";
|
|
41
|
-
import {
|
|
42
|
-
getEmbeddedPostgresTestSupport,
|
|
43
|
-
startEmbeddedPostgresTestDatabase,
|
|
44
|
-
} from "./helpers/embedded-postgres.js";
|
|
45
|
-
|
|
46
|
-
const ORIGINAL_CWD = process.cwd();
|
|
47
|
-
const ORIGINAL_ENV = { ...process.env };
|
|
48
|
-
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
49
|
-
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
50
|
-
|
|
51
|
-
if (!embeddedPostgresSupport.supported) {
|
|
52
|
-
console.warn(
|
|
53
|
-
`Skipping embedded Postgres worktree CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
afterEach(() => {
|
|
58
|
-
process.chdir(ORIGINAL_CWD);
|
|
59
|
-
for (const key of Object.keys(process.env)) {
|
|
60
|
-
if (!(key in ORIGINAL_ENV)) delete process.env[key];
|
|
61
|
-
}
|
|
62
|
-
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
|
63
|
-
if (value === undefined) delete process.env[key];
|
|
64
|
-
else process.env[key] = value;
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
function buildSourceConfig(): PaperclipConfig {
|
|
69
|
-
return {
|
|
70
|
-
$meta: {
|
|
71
|
-
version: 1,
|
|
72
|
-
updatedAt: "2026-03-09T00:00:00.000Z",
|
|
73
|
-
source: "configure",
|
|
74
|
-
},
|
|
75
|
-
database: {
|
|
76
|
-
mode: "embedded-postgres",
|
|
77
|
-
embeddedPostgresDataDir: "/tmp/main/db",
|
|
78
|
-
embeddedPostgresPort: 54329,
|
|
79
|
-
backup: {
|
|
80
|
-
enabled: true,
|
|
81
|
-
intervalMinutes: 60,
|
|
82
|
-
retentionDays: 30,
|
|
83
|
-
dir: "/tmp/main/backups",
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
logging: {
|
|
87
|
-
mode: "file",
|
|
88
|
-
logDir: "/tmp/main/logs",
|
|
89
|
-
},
|
|
90
|
-
server: {
|
|
91
|
-
deploymentMode: "authenticated",
|
|
92
|
-
exposure: "private",
|
|
93
|
-
host: "127.0.0.1",
|
|
94
|
-
port: 3100,
|
|
95
|
-
allowedHostnames: ["localhost"],
|
|
96
|
-
serveUi: true,
|
|
97
|
-
},
|
|
98
|
-
auth: {
|
|
99
|
-
baseUrlMode: "explicit",
|
|
100
|
-
publicBaseUrl: "http://127.0.0.1:3100",
|
|
101
|
-
disableSignUp: false,
|
|
102
|
-
},
|
|
103
|
-
telemetry: {
|
|
104
|
-
enabled: true,
|
|
105
|
-
},
|
|
106
|
-
storage: {
|
|
107
|
-
provider: "local_disk",
|
|
108
|
-
localDisk: {
|
|
109
|
-
baseDir: "/tmp/main/storage",
|
|
110
|
-
},
|
|
111
|
-
s3: {
|
|
112
|
-
bucket: "paperclip",
|
|
113
|
-
region: "us-east-1",
|
|
114
|
-
prefix: "",
|
|
115
|
-
forcePathStyle: false,
|
|
116
|
-
},
|
|
117
|
-
},
|
|
118
|
-
secrets: {
|
|
119
|
-
provider: "local_encrypted",
|
|
120
|
-
strictMode: false,
|
|
121
|
-
localEncrypted: {
|
|
122
|
-
keyFilePath: "/tmp/main/secrets/master.key",
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
describe("worktree helpers", () => {
|
|
129
|
-
it("sanitizes instance ids", () => {
|
|
130
|
-
expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support");
|
|
131
|
-
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("resolves worktree:make target paths under the user home directory", () => {
|
|
135
|
-
expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe(
|
|
136
|
-
path.resolve(os.homedir(), "paperclip-pr-432"),
|
|
137
|
-
);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("rejects worktree:make names that are not safe directory/branch names", () => {
|
|
141
|
-
expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow(
|
|
142
|
-
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
|
|
143
|
-
);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("builds git worktree add args for new and existing branches", () => {
|
|
147
|
-
expect(
|
|
148
|
-
resolveGitWorktreeAddArgs({
|
|
149
|
-
branchName: "feature-branch",
|
|
150
|
-
targetPath: "/tmp/feature-branch",
|
|
151
|
-
branchExists: false,
|
|
152
|
-
}),
|
|
153
|
-
).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]);
|
|
154
|
-
|
|
155
|
-
expect(
|
|
156
|
-
resolveGitWorktreeAddArgs({
|
|
157
|
-
branchName: "feature-branch",
|
|
158
|
-
targetPath: "/tmp/feature-branch",
|
|
159
|
-
branchExists: true,
|
|
160
|
-
}),
|
|
161
|
-
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("builds git worktree add args with a start point", () => {
|
|
165
|
-
expect(
|
|
166
|
-
resolveGitWorktreeAddArgs({
|
|
167
|
-
branchName: "my-worktree",
|
|
168
|
-
targetPath: "/tmp/my-worktree",
|
|
169
|
-
branchExists: false,
|
|
170
|
-
startPoint: "public-gh/master",
|
|
171
|
-
}),
|
|
172
|
-
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("uses start point even when a local branch with the same name exists", () => {
|
|
176
|
-
expect(
|
|
177
|
-
resolveGitWorktreeAddArgs({
|
|
178
|
-
branchName: "my-worktree",
|
|
179
|
-
targetPath: "/tmp/my-worktree",
|
|
180
|
-
branchExists: true,
|
|
181
|
-
startPoint: "origin/main",
|
|
182
|
-
}),
|
|
183
|
-
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it("rewrites loopback auth URLs to the new port only", () => {
|
|
187
|
-
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
|
188
|
-
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("builds isolated config and env paths for a worktree", () => {
|
|
192
|
-
const paths = resolveWorktreeLocalPaths({
|
|
193
|
-
cwd: "/tmp/paperclip-feature",
|
|
194
|
-
homeDir: "/tmp/paperclip-worktrees",
|
|
195
|
-
instanceId: "feature-worktree-support",
|
|
196
|
-
});
|
|
197
|
-
const config = buildWorktreeConfig({
|
|
198
|
-
sourceConfig: buildSourceConfig(),
|
|
199
|
-
paths,
|
|
200
|
-
serverPort: 3110,
|
|
201
|
-
databasePort: 54339,
|
|
202
|
-
now: new Date("2026-03-09T12:00:00.000Z"),
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
expect(config.database.embeddedPostgresDataDir).toBe(
|
|
206
|
-
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"),
|
|
207
|
-
);
|
|
208
|
-
expect(config.database.embeddedPostgresPort).toBe(54339);
|
|
209
|
-
expect(config.server.port).toBe(3110);
|
|
210
|
-
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/");
|
|
211
|
-
expect(config.storage.localDisk.baseDir).toBe(
|
|
212
|
-
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
const env = buildWorktreeEnvEntries(paths, {
|
|
216
|
-
name: "feature-worktree-support",
|
|
217
|
-
color: "#3abf7a",
|
|
218
|
-
});
|
|
219
|
-
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
|
|
220
|
-
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
|
|
221
|
-
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
|
|
222
|
-
expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support");
|
|
223
|
-
expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a");
|
|
224
|
-
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("falls back across storage roots before skipping a missing attachment object", async () => {
|
|
228
|
-
const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" });
|
|
229
|
-
const expected = Buffer.from("image-bytes");
|
|
230
|
-
await expect(
|
|
231
|
-
readSourceAttachmentBody(
|
|
232
|
-
[
|
|
233
|
-
{
|
|
234
|
-
getObject: vi.fn().mockRejectedValue(missingErr),
|
|
235
|
-
},
|
|
236
|
-
{
|
|
237
|
-
getObject: vi.fn().mockResolvedValue(expected),
|
|
238
|
-
},
|
|
239
|
-
],
|
|
240
|
-
"company-1",
|
|
241
|
-
"company-1/issues/issue-1/missing.png",
|
|
242
|
-
),
|
|
243
|
-
).resolves.toEqual(expected);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it("returns null when an attachment object is missing from every lookup storage", async () => {
|
|
247
|
-
const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" });
|
|
248
|
-
await expect(
|
|
249
|
-
readSourceAttachmentBody(
|
|
250
|
-
[
|
|
251
|
-
{
|
|
252
|
-
getObject: vi.fn().mockRejectedValue(missingErr),
|
|
253
|
-
},
|
|
254
|
-
{
|
|
255
|
-
getObject: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { status: 404 })),
|
|
256
|
-
},
|
|
257
|
-
],
|
|
258
|
-
"company-1",
|
|
259
|
-
"company-1/issues/issue-1/missing.png",
|
|
260
|
-
),
|
|
261
|
-
).resolves.toBeNull();
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("generates vivid worktree colors as hex", () => {
|
|
265
|
-
expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
|
|
269
|
-
const minimal = resolveWorktreeSeedPlan("minimal");
|
|
270
|
-
const full = resolveWorktreeSeedPlan("full");
|
|
271
|
-
|
|
272
|
-
expect(minimal.excludedTables).toContain("heartbeat_runs");
|
|
273
|
-
expect(minimal.excludedTables).toContain("heartbeat_run_events");
|
|
274
|
-
expect(minimal.excludedTables).toContain("workspace_runtime_services");
|
|
275
|
-
expect(minimal.excludedTables).toContain("agent_task_sessions");
|
|
276
|
-
expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]);
|
|
277
|
-
|
|
278
|
-
expect(full.excludedTables).toEqual([]);
|
|
279
|
-
expect(full.nullifyColumns).toEqual({});
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
|
|
283
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
|
284
|
-
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
|
285
|
-
const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
286
|
-
try {
|
|
287
|
-
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
|
288
|
-
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
289
|
-
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
|
290
|
-
const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key");
|
|
291
|
-
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
|
|
292
|
-
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
|
|
293
|
-
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
|
|
294
|
-
|
|
295
|
-
const sourceConfig = buildSourceConfig();
|
|
296
|
-
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
|
|
297
|
-
|
|
298
|
-
copySeededSecretsKey({
|
|
299
|
-
sourceConfigPath,
|
|
300
|
-
sourceConfig,
|
|
301
|
-
sourceEnvEntries: {},
|
|
302
|
-
targetKeyFilePath: targetKeyPath,
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key");
|
|
306
|
-
} finally {
|
|
307
|
-
if (originalInlineMasterKey === undefined) {
|
|
308
|
-
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
|
309
|
-
} else {
|
|
310
|
-
process.env.PAPERCLIP_SECRETS_MASTER_KEY = originalInlineMasterKey;
|
|
311
|
-
}
|
|
312
|
-
if (originalKeyFile === undefined) {
|
|
313
|
-
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
314
|
-
} else {
|
|
315
|
-
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = originalKeyFile;
|
|
316
|
-
}
|
|
317
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
318
|
-
}
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
it("writes the source inline secrets master key into the seeded worktree instance", () => {
|
|
322
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
|
323
|
-
try {
|
|
324
|
-
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
|
325
|
-
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
|
|
326
|
-
|
|
327
|
-
copySeededSecretsKey({
|
|
328
|
-
sourceConfigPath,
|
|
329
|
-
sourceConfig: buildSourceConfig(),
|
|
330
|
-
sourceEnvEntries: {
|
|
331
|
-
PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key",
|
|
332
|
-
},
|
|
333
|
-
targetKeyFilePath: targetKeyPath,
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key");
|
|
337
|
-
} finally {
|
|
338
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
339
|
-
}
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it("persists the current agent jwt secret into the worktree env file", async () => {
|
|
343
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-"));
|
|
344
|
-
const repoRoot = path.join(tempRoot, "repo");
|
|
345
|
-
const originalCwd = process.cwd();
|
|
346
|
-
const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
|
347
|
-
|
|
348
|
-
try {
|
|
349
|
-
fs.mkdirSync(repoRoot, { recursive: true });
|
|
350
|
-
process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret";
|
|
351
|
-
process.chdir(repoRoot);
|
|
352
|
-
|
|
353
|
-
await worktreeInitCommand({
|
|
354
|
-
seed: false,
|
|
355
|
-
fromConfig: path.join(tempRoot, "missing", "config.json"),
|
|
356
|
-
home: path.join(tempRoot, ".paperclip-worktrees"),
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
const envPath = path.join(repoRoot, ".paperclip", ".env");
|
|
360
|
-
const envContents = fs.readFileSync(envPath, "utf8");
|
|
361
|
-
expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
|
|
362
|
-
expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo");
|
|
363
|
-
expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/);
|
|
364
|
-
} finally {
|
|
365
|
-
process.chdir(originalCwd);
|
|
366
|
-
if (originalJwtSecret === undefined) {
|
|
367
|
-
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
|
368
|
-
} else {
|
|
369
|
-
process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret;
|
|
370
|
-
}
|
|
371
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
372
|
-
}
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
it("avoids ports already claimed by sibling worktree instance configs", async () => {
|
|
376
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-"));
|
|
377
|
-
const repoRoot = path.join(tempRoot, "repo");
|
|
378
|
-
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
|
|
379
|
-
const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree");
|
|
380
|
-
const originalCwd = process.cwd();
|
|
381
|
-
|
|
382
|
-
try {
|
|
383
|
-
fs.mkdirSync(repoRoot, { recursive: true });
|
|
384
|
-
fs.mkdirSync(siblingInstanceRoot, { recursive: true });
|
|
385
|
-
fs.writeFileSync(
|
|
386
|
-
path.join(siblingInstanceRoot, "config.json"),
|
|
387
|
-
JSON.stringify(
|
|
388
|
-
{
|
|
389
|
-
...buildSourceConfig(),
|
|
390
|
-
database: {
|
|
391
|
-
mode: "embedded-postgres",
|
|
392
|
-
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
|
|
393
|
-
embeddedPostgresPort: 54330,
|
|
394
|
-
backup: {
|
|
395
|
-
enabled: true,
|
|
396
|
-
intervalMinutes: 60,
|
|
397
|
-
retentionDays: 30,
|
|
398
|
-
dir: path.join(siblingInstanceRoot, "backups"),
|
|
399
|
-
},
|
|
400
|
-
},
|
|
401
|
-
logging: {
|
|
402
|
-
mode: "file",
|
|
403
|
-
logDir: path.join(siblingInstanceRoot, "logs"),
|
|
404
|
-
},
|
|
405
|
-
server: {
|
|
406
|
-
deploymentMode: "authenticated",
|
|
407
|
-
exposure: "private",
|
|
408
|
-
host: "127.0.0.1",
|
|
409
|
-
port: 3101,
|
|
410
|
-
allowedHostnames: ["localhost"],
|
|
411
|
-
serveUi: true,
|
|
412
|
-
},
|
|
413
|
-
storage: {
|
|
414
|
-
provider: "local_disk",
|
|
415
|
-
localDisk: {
|
|
416
|
-
baseDir: path.join(siblingInstanceRoot, "storage"),
|
|
417
|
-
},
|
|
418
|
-
s3: {
|
|
419
|
-
bucket: "paperclip",
|
|
420
|
-
region: "us-east-1",
|
|
421
|
-
prefix: "",
|
|
422
|
-
forcePathStyle: false,
|
|
423
|
-
},
|
|
424
|
-
},
|
|
425
|
-
secrets: {
|
|
426
|
-
provider: "local_encrypted",
|
|
427
|
-
strictMode: false,
|
|
428
|
-
localEncrypted: {
|
|
429
|
-
keyFilePath: path.join(siblingInstanceRoot, "secrets", "master.key"),
|
|
430
|
-
},
|
|
431
|
-
},
|
|
432
|
-
},
|
|
433
|
-
null,
|
|
434
|
-
2,
|
|
435
|
-
) + "\n",
|
|
436
|
-
);
|
|
437
|
-
|
|
438
|
-
process.chdir(repoRoot);
|
|
439
|
-
await worktreeInitCommand({
|
|
440
|
-
seed: false,
|
|
441
|
-
fromConfig: path.join(tempRoot, "missing", "config.json"),
|
|
442
|
-
home: homeDir,
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8"));
|
|
446
|
-
expect(config.server.port).toBeGreaterThan(3101);
|
|
447
|
-
expect(config.database.embeddedPostgresPort).not.toBe(54330);
|
|
448
|
-
expect(config.database.embeddedPostgresPort).not.toBe(config.server.port);
|
|
449
|
-
expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330);
|
|
450
|
-
} finally {
|
|
451
|
-
process.chdir(originalCwd);
|
|
452
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
453
|
-
}
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
it("defaults the seed source config to the current repo-local Paperclip config", () => {
|
|
457
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
|
|
458
|
-
const repoRoot = path.join(tempRoot, "repo");
|
|
459
|
-
const localConfigPath = path.join(repoRoot, ".paperclip", "config.json");
|
|
460
|
-
const originalCwd = process.cwd();
|
|
461
|
-
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
|
462
|
-
|
|
463
|
-
try {
|
|
464
|
-
fs.mkdirSync(path.dirname(localConfigPath), { recursive: true });
|
|
465
|
-
fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
|
|
466
|
-
delete process.env.PAPERCLIP_CONFIG;
|
|
467
|
-
process.chdir(repoRoot);
|
|
468
|
-
|
|
469
|
-
expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath));
|
|
470
|
-
} finally {
|
|
471
|
-
process.chdir(originalCwd);
|
|
472
|
-
if (originalPaperclipConfig === undefined) {
|
|
473
|
-
delete process.env.PAPERCLIP_CONFIG;
|
|
474
|
-
} else {
|
|
475
|
-
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
|
476
|
-
}
|
|
477
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
478
|
-
}
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
it("preserves the source config path across worktree:make cwd changes", () => {
|
|
482
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-"));
|
|
483
|
-
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
|
484
|
-
const targetRoot = path.join(tempRoot, "target");
|
|
485
|
-
const originalCwd = process.cwd();
|
|
486
|
-
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
|
487
|
-
|
|
488
|
-
try {
|
|
489
|
-
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
|
490
|
-
fs.mkdirSync(targetRoot, { recursive: true });
|
|
491
|
-
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
|
|
492
|
-
delete process.env.PAPERCLIP_CONFIG;
|
|
493
|
-
process.chdir(targetRoot);
|
|
494
|
-
|
|
495
|
-
expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe(
|
|
496
|
-
path.resolve(sourceConfigPath),
|
|
497
|
-
);
|
|
498
|
-
} finally {
|
|
499
|
-
process.chdir(originalCwd);
|
|
500
|
-
if (originalPaperclipConfig === undefined) {
|
|
501
|
-
delete process.env.PAPERCLIP_CONFIG;
|
|
502
|
-
} else {
|
|
503
|
-
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
|
504
|
-
}
|
|
505
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
506
|
-
}
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
it("requires an explicit reseed source", () => {
|
|
510
|
-
expect(() => resolveWorktreeReseedSource({})).toThrow(
|
|
511
|
-
"Pass --from <worktree> or --from-config/--from-instance explicitly so the reseed source is unambiguous.",
|
|
512
|
-
);
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
it("rejects mixed reseed source selectors", () => {
|
|
516
|
-
expect(() => resolveWorktreeReseedSource({
|
|
517
|
-
from: "current",
|
|
518
|
-
fromInstance: "default",
|
|
519
|
-
})).toThrow(
|
|
520
|
-
"Use either --from <worktree> or --from-config/--from-data-dir/--from-instance, not both.",
|
|
521
|
-
);
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
it("derives worktree reseed target paths from the adjacent env file", () => {
|
|
525
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-"));
|
|
526
|
-
const worktreeRoot = path.join(tempRoot, "repo");
|
|
527
|
-
const configPath = path.join(worktreeRoot, ".paperclip", "config.json");
|
|
528
|
-
const envPath = path.join(worktreeRoot, ".paperclip", ".env");
|
|
529
|
-
|
|
530
|
-
try {
|
|
531
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
532
|
-
fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8");
|
|
533
|
-
fs.writeFileSync(
|
|
534
|
-
envPath,
|
|
535
|
-
[
|
|
536
|
-
"PAPERCLIP_HOME=/tmp/paperclip-worktrees",
|
|
537
|
-
"PAPERCLIP_INSTANCE_ID=pap-1132-chat",
|
|
538
|
-
].join("\n"),
|
|
539
|
-
"utf8",
|
|
540
|
-
);
|
|
541
|
-
expect(
|
|
542
|
-
resolveWorktreeReseedTargetPaths({
|
|
543
|
-
configPath,
|
|
544
|
-
rootPath: worktreeRoot,
|
|
545
|
-
}),
|
|
546
|
-
).toMatchObject({
|
|
547
|
-
cwd: worktreeRoot,
|
|
548
|
-
homeDir: "/tmp/paperclip-worktrees",
|
|
549
|
-
instanceId: "pap-1132-chat",
|
|
550
|
-
});
|
|
551
|
-
} finally {
|
|
552
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
553
|
-
}
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
it("rejects reseed targets without worktree env metadata", () => {
|
|
557
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-missing-"));
|
|
558
|
-
const worktreeRoot = path.join(tempRoot, "repo");
|
|
559
|
-
const configPath = path.join(worktreeRoot, ".paperclip", "config.json");
|
|
560
|
-
|
|
561
|
-
try {
|
|
562
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
563
|
-
fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8");
|
|
564
|
-
fs.writeFileSync(path.join(worktreeRoot, ".paperclip", ".env"), "", "utf8");
|
|
565
|
-
|
|
566
|
-
expect(() =>
|
|
567
|
-
resolveWorktreeReseedTargetPaths({
|
|
568
|
-
configPath,
|
|
569
|
-
rootPath: worktreeRoot,
|
|
570
|
-
})).toThrow("does not look like a worktree-local Paperclip instance");
|
|
571
|
-
} finally {
|
|
572
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
573
|
-
}
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
it("reseed preserves the current worktree ports, instance id, and branding", async () => {
|
|
577
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-"));
|
|
578
|
-
const repoRoot = path.join(tempRoot, "repo");
|
|
579
|
-
const sourceRoot = path.join(tempRoot, "source");
|
|
580
|
-
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
|
|
581
|
-
const currentInstanceId = "existing-worktree";
|
|
582
|
-
const currentPaths = resolveWorktreeLocalPaths({
|
|
583
|
-
cwd: repoRoot,
|
|
584
|
-
homeDir,
|
|
585
|
-
instanceId: currentInstanceId,
|
|
586
|
-
});
|
|
587
|
-
const sourcePaths = resolveWorktreeLocalPaths({
|
|
588
|
-
cwd: sourceRoot,
|
|
589
|
-
homeDir: path.join(tempRoot, ".paperclip-source"),
|
|
590
|
-
instanceId: "default",
|
|
591
|
-
});
|
|
592
|
-
const originalCwd = process.cwd();
|
|
593
|
-
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
|
594
|
-
|
|
595
|
-
try {
|
|
596
|
-
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
|
|
597
|
-
fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true });
|
|
598
|
-
fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true });
|
|
599
|
-
fs.mkdirSync(repoRoot, { recursive: true });
|
|
600
|
-
fs.mkdirSync(sourceRoot, { recursive: true });
|
|
601
|
-
|
|
602
|
-
const currentConfig = buildWorktreeConfig({
|
|
603
|
-
sourceConfig: buildSourceConfig(),
|
|
604
|
-
paths: currentPaths,
|
|
605
|
-
serverPort: 3114,
|
|
606
|
-
databasePort: 54341,
|
|
607
|
-
});
|
|
608
|
-
const sourceConfig = buildWorktreeConfig({
|
|
609
|
-
sourceConfig: buildSourceConfig(),
|
|
610
|
-
paths: sourcePaths,
|
|
611
|
-
serverPort: 3200,
|
|
612
|
-
databasePort: 54400,
|
|
613
|
-
});
|
|
614
|
-
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
|
|
615
|
-
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
|
|
616
|
-
fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8");
|
|
617
|
-
fs.writeFileSync(
|
|
618
|
-
currentPaths.envPath,
|
|
619
|
-
[
|
|
620
|
-
`PAPERCLIP_HOME=${homeDir}`,
|
|
621
|
-
`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`,
|
|
622
|
-
"PAPERCLIP_WORKTREE_NAME=existing-name",
|
|
623
|
-
"PAPERCLIP_WORKTREE_COLOR=\"#112233\"",
|
|
624
|
-
].join("\n"),
|
|
625
|
-
"utf8",
|
|
626
|
-
);
|
|
627
|
-
|
|
628
|
-
delete process.env.PAPERCLIP_CONFIG;
|
|
629
|
-
process.chdir(repoRoot);
|
|
630
|
-
|
|
631
|
-
await worktreeReseedCommand({
|
|
632
|
-
fromConfig: sourcePaths.configPath,
|
|
633
|
-
yes: true,
|
|
634
|
-
});
|
|
635
|
-
|
|
636
|
-
const rewrittenConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8"));
|
|
637
|
-
const rewrittenEnv = fs.readFileSync(currentPaths.envPath, "utf8");
|
|
638
|
-
|
|
639
|
-
expect(rewrittenConfig.server.port).toBe(3114);
|
|
640
|
-
expect(rewrittenConfig.database.embeddedPostgresPort).toBe(54341);
|
|
641
|
-
expect(rewrittenConfig.database.embeddedPostgresDataDir).toBe(currentPaths.embeddedPostgresDataDir);
|
|
642
|
-
expect(rewrittenEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`);
|
|
643
|
-
expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_NAME=existing-name");
|
|
644
|
-
expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_COLOR=\"#112233\"");
|
|
645
|
-
} finally {
|
|
646
|
-
process.chdir(originalCwd);
|
|
647
|
-
if (originalPaperclipConfig === undefined) {
|
|
648
|
-
delete process.env.PAPERCLIP_CONFIG;
|
|
649
|
-
} else {
|
|
650
|
-
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
|
651
|
-
}
|
|
652
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
653
|
-
}
|
|
654
|
-
}, 20_000);
|
|
655
|
-
|
|
656
|
-
it("restores the current worktree config and instance data if reseed fails", async () => {
|
|
657
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-"));
|
|
658
|
-
const repoRoot = path.join(tempRoot, "repo");
|
|
659
|
-
const sourceRoot = path.join(tempRoot, "source");
|
|
660
|
-
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
|
|
661
|
-
const currentInstanceId = "rollback-worktree";
|
|
662
|
-
const currentPaths = resolveWorktreeLocalPaths({
|
|
663
|
-
cwd: repoRoot,
|
|
664
|
-
homeDir,
|
|
665
|
-
instanceId: currentInstanceId,
|
|
666
|
-
});
|
|
667
|
-
const sourcePaths = resolveWorktreeLocalPaths({
|
|
668
|
-
cwd: sourceRoot,
|
|
669
|
-
homeDir: path.join(tempRoot, ".paperclip-source"),
|
|
670
|
-
instanceId: "default",
|
|
671
|
-
});
|
|
672
|
-
const originalCwd = process.cwd();
|
|
673
|
-
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
|
674
|
-
|
|
675
|
-
try {
|
|
676
|
-
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
|
|
677
|
-
fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true });
|
|
678
|
-
fs.mkdirSync(currentPaths.instanceRoot, { recursive: true });
|
|
679
|
-
fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true });
|
|
680
|
-
fs.mkdirSync(repoRoot, { recursive: true });
|
|
681
|
-
fs.mkdirSync(sourceRoot, { recursive: true });
|
|
682
|
-
|
|
683
|
-
const currentConfig = buildWorktreeConfig({
|
|
684
|
-
sourceConfig: buildSourceConfig(),
|
|
685
|
-
paths: currentPaths,
|
|
686
|
-
serverPort: 3114,
|
|
687
|
-
databasePort: 54341,
|
|
688
|
-
});
|
|
689
|
-
const sourceConfig = {
|
|
690
|
-
...buildSourceConfig(),
|
|
691
|
-
database: {
|
|
692
|
-
mode: "postgres",
|
|
693
|
-
connectionString: "",
|
|
694
|
-
},
|
|
695
|
-
secrets: {
|
|
696
|
-
provider: "local_encrypted",
|
|
697
|
-
strictMode: false,
|
|
698
|
-
localEncrypted: {
|
|
699
|
-
keyFilePath: sourcePaths.secretsKeyFilePath,
|
|
700
|
-
},
|
|
701
|
-
},
|
|
702
|
-
} as PaperclipConfig;
|
|
703
|
-
|
|
704
|
-
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
|
|
705
|
-
fs.writeFileSync(currentPaths.envPath, `PAPERCLIP_HOME=${homeDir}\nPAPERCLIP_INSTANCE_ID=${currentInstanceId}\n`, "utf8");
|
|
706
|
-
fs.writeFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "keep me", "utf8");
|
|
707
|
-
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
|
|
708
|
-
fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8");
|
|
709
|
-
|
|
710
|
-
delete process.env.PAPERCLIP_CONFIG;
|
|
711
|
-
process.chdir(repoRoot);
|
|
712
|
-
|
|
713
|
-
await expect(worktreeReseedCommand({
|
|
714
|
-
fromConfig: sourcePaths.configPath,
|
|
715
|
-
yes: true,
|
|
716
|
-
})).rejects.toThrow("Source instance uses postgres mode but has no connection string");
|
|
717
|
-
|
|
718
|
-
const restoredConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8"));
|
|
719
|
-
const restoredEnv = fs.readFileSync(currentPaths.envPath, "utf8");
|
|
720
|
-
const restoredMarker = fs.readFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "utf8");
|
|
721
|
-
|
|
722
|
-
expect(restoredConfig.server.port).toBe(3114);
|
|
723
|
-
expect(restoredConfig.database.embeddedPostgresPort).toBe(54341);
|
|
724
|
-
expect(restoredEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`);
|
|
725
|
-
expect(restoredMarker).toBe("keep me");
|
|
726
|
-
} finally {
|
|
727
|
-
process.chdir(originalCwd);
|
|
728
|
-
if (originalPaperclipConfig === undefined) {
|
|
729
|
-
delete process.env.PAPERCLIP_CONFIG;
|
|
730
|
-
} else {
|
|
731
|
-
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
|
732
|
-
}
|
|
733
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
734
|
-
}
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
it("rebinds same-repo workspace paths onto the current worktree root", () => {
|
|
738
|
-
expect(
|
|
739
|
-
rebindWorkspaceCwd({
|
|
740
|
-
sourceRepoRoot: "/Users/example/paperclip",
|
|
741
|
-
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
|
742
|
-
workspaceCwd: "/Users/example/paperclip",
|
|
743
|
-
}),
|
|
744
|
-
).toBe("/Users/example/paperclip-pr-432");
|
|
745
|
-
|
|
746
|
-
expect(
|
|
747
|
-
rebindWorkspaceCwd({
|
|
748
|
-
sourceRepoRoot: "/Users/example/paperclip",
|
|
749
|
-
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
|
750
|
-
workspaceCwd: "/Users/example/paperclip/packages/db",
|
|
751
|
-
}),
|
|
752
|
-
).toBe("/Users/example/paperclip-pr-432/packages/db");
|
|
753
|
-
});
|
|
754
|
-
|
|
755
|
-
it("does not rebind paths outside the source repo root", () => {
|
|
756
|
-
expect(
|
|
757
|
-
rebindWorkspaceCwd({
|
|
758
|
-
sourceRepoRoot: "/Users/example/paperclip",
|
|
759
|
-
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
|
760
|
-
workspaceCwd: "/Users/example/other-project",
|
|
761
|
-
}),
|
|
762
|
-
).toBeNull();
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
it("copies shared git hooks into a linked worktree git dir", () => {
|
|
766
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-"));
|
|
767
|
-
const repoRoot = path.join(tempRoot, "repo");
|
|
768
|
-
const worktreePath = path.join(tempRoot, "repo-feature");
|
|
769
|
-
|
|
770
|
-
try {
|
|
771
|
-
fs.mkdirSync(repoRoot, { recursive: true });
|
|
772
|
-
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
|
773
|
-
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
|
774
|
-
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
|
775
|
-
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
|
776
|
-
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
|
777
|
-
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
|
778
|
-
|
|
779
|
-
const sourceHooksDir = path.join(repoRoot, ".git", "hooks");
|
|
780
|
-
const sourceHookPath = path.join(sourceHooksDir, "pre-commit");
|
|
781
|
-
const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt");
|
|
782
|
-
fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 });
|
|
783
|
-
fs.chmodSync(sourceHookPath, 0o755);
|
|
784
|
-
fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8");
|
|
785
|
-
|
|
786
|
-
execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" });
|
|
787
|
-
|
|
788
|
-
const copied = copyGitHooksToWorktreeGitDir(worktreePath);
|
|
789
|
-
const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
|
|
790
|
-
cwd: worktreePath,
|
|
791
|
-
encoding: "utf8",
|
|
792
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
793
|
-
}).trim();
|
|
794
|
-
const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir);
|
|
795
|
-
const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks"));
|
|
796
|
-
const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit");
|
|
797
|
-
const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt");
|
|
798
|
-
|
|
799
|
-
expect(copied).toMatchObject({
|
|
800
|
-
sourceHooksPath: resolvedSourceHooksDir,
|
|
801
|
-
targetHooksPath: resolvedTargetHooksDir,
|
|
802
|
-
copied: true,
|
|
803
|
-
});
|
|
804
|
-
expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n");
|
|
805
|
-
expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0);
|
|
806
|
-
expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n");
|
|
807
|
-
} finally {
|
|
808
|
-
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
|
|
809
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
810
|
-
}
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
|
|
814
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
|
|
815
|
-
const repoRoot = path.join(tempRoot, "repo");
|
|
816
|
-
const fakeHome = path.join(tempRoot, "home");
|
|
817
|
-
const worktreePath = path.join(fakeHome, "paperclip-make-test");
|
|
818
|
-
const originalCwd = process.cwd();
|
|
819
|
-
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
|
820
|
-
|
|
821
|
-
try {
|
|
822
|
-
fs.mkdirSync(repoRoot, { recursive: true });
|
|
823
|
-
fs.mkdirSync(fakeHome, { recursive: true });
|
|
824
|
-
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
|
825
|
-
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
|
826
|
-
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
|
827
|
-
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
|
828
|
-
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
|
829
|
-
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
|
830
|
-
|
|
831
|
-
process.chdir(repoRoot);
|
|
832
|
-
|
|
833
|
-
await worktreeMakeCommand("paperclip-make-test", {
|
|
834
|
-
seed: false,
|
|
835
|
-
home: path.join(tempRoot, ".paperclip-worktrees"),
|
|
836
|
-
});
|
|
837
|
-
|
|
838
|
-
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
|
|
839
|
-
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
|
|
840
|
-
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
|
|
841
|
-
} finally {
|
|
842
|
-
process.chdir(originalCwd);
|
|
843
|
-
homedirSpy.mockRestore();
|
|
844
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
845
|
-
}
|
|
846
|
-
}, 20_000);
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
describeEmbeddedPostgres("pauseSeededScheduledRoutines", () => {
|
|
850
|
-
it("pauses only routines with enabled schedule triggers", async () => {
|
|
851
|
-
const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-routines-");
|
|
852
|
-
const db = createDb(tempDb.connectionString);
|
|
853
|
-
const companyId = randomUUID();
|
|
854
|
-
const projectId = randomUUID();
|
|
855
|
-
const agentId = randomUUID();
|
|
856
|
-
const activeScheduledRoutineId = randomUUID();
|
|
857
|
-
const activeApiRoutineId = randomUUID();
|
|
858
|
-
const pausedScheduledRoutineId = randomUUID();
|
|
859
|
-
const archivedScheduledRoutineId = randomUUID();
|
|
860
|
-
const disabledScheduleRoutineId = randomUUID();
|
|
861
|
-
|
|
862
|
-
try {
|
|
863
|
-
await db.insert(companies).values({
|
|
864
|
-
id: companyId,
|
|
865
|
-
name: "Paperclip",
|
|
866
|
-
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
867
|
-
requireBoardApprovalForNewAgents: false,
|
|
868
|
-
});
|
|
869
|
-
await db.insert(agents).values({
|
|
870
|
-
id: agentId,
|
|
871
|
-
companyId,
|
|
872
|
-
name: "Coder",
|
|
873
|
-
adapterType: "process",
|
|
874
|
-
adapterConfig: {},
|
|
875
|
-
runtimeConfig: {},
|
|
876
|
-
permissions: {},
|
|
877
|
-
});
|
|
878
|
-
await db.insert(projects).values({
|
|
879
|
-
id: projectId,
|
|
880
|
-
companyId,
|
|
881
|
-
name: "Project",
|
|
882
|
-
status: "in_progress",
|
|
883
|
-
});
|
|
884
|
-
await db.insert(routines).values([
|
|
885
|
-
{
|
|
886
|
-
id: activeScheduledRoutineId,
|
|
887
|
-
companyId,
|
|
888
|
-
projectId,
|
|
889
|
-
assigneeAgentId: agentId,
|
|
890
|
-
title: "Active scheduled",
|
|
891
|
-
status: "active",
|
|
892
|
-
},
|
|
893
|
-
{
|
|
894
|
-
id: activeApiRoutineId,
|
|
895
|
-
companyId,
|
|
896
|
-
projectId,
|
|
897
|
-
assigneeAgentId: agentId,
|
|
898
|
-
title: "Active API",
|
|
899
|
-
status: "active",
|
|
900
|
-
},
|
|
901
|
-
{
|
|
902
|
-
id: pausedScheduledRoutineId,
|
|
903
|
-
companyId,
|
|
904
|
-
projectId,
|
|
905
|
-
assigneeAgentId: agentId,
|
|
906
|
-
title: "Paused scheduled",
|
|
907
|
-
status: "paused",
|
|
908
|
-
},
|
|
909
|
-
{
|
|
910
|
-
id: archivedScheduledRoutineId,
|
|
911
|
-
companyId,
|
|
912
|
-
projectId,
|
|
913
|
-
assigneeAgentId: agentId,
|
|
914
|
-
title: "Archived scheduled",
|
|
915
|
-
status: "archived",
|
|
916
|
-
},
|
|
917
|
-
{
|
|
918
|
-
id: disabledScheduleRoutineId,
|
|
919
|
-
companyId,
|
|
920
|
-
projectId,
|
|
921
|
-
assigneeAgentId: agentId,
|
|
922
|
-
title: "Disabled schedule",
|
|
923
|
-
status: "active",
|
|
924
|
-
},
|
|
925
|
-
]);
|
|
926
|
-
await db.insert(routineTriggers).values([
|
|
927
|
-
{
|
|
928
|
-
companyId,
|
|
929
|
-
routineId: activeScheduledRoutineId,
|
|
930
|
-
kind: "schedule",
|
|
931
|
-
enabled: true,
|
|
932
|
-
cronExpression: "0 9 * * *",
|
|
933
|
-
timezone: "UTC",
|
|
934
|
-
},
|
|
935
|
-
{
|
|
936
|
-
companyId,
|
|
937
|
-
routineId: activeApiRoutineId,
|
|
938
|
-
kind: "api",
|
|
939
|
-
enabled: true,
|
|
940
|
-
},
|
|
941
|
-
{
|
|
942
|
-
companyId,
|
|
943
|
-
routineId: pausedScheduledRoutineId,
|
|
944
|
-
kind: "schedule",
|
|
945
|
-
enabled: true,
|
|
946
|
-
cronExpression: "0 10 * * *",
|
|
947
|
-
timezone: "UTC",
|
|
948
|
-
},
|
|
949
|
-
{
|
|
950
|
-
companyId,
|
|
951
|
-
routineId: archivedScheduledRoutineId,
|
|
952
|
-
kind: "schedule",
|
|
953
|
-
enabled: true,
|
|
954
|
-
cronExpression: "0 11 * * *",
|
|
955
|
-
timezone: "UTC",
|
|
956
|
-
},
|
|
957
|
-
{
|
|
958
|
-
companyId,
|
|
959
|
-
routineId: disabledScheduleRoutineId,
|
|
960
|
-
kind: "schedule",
|
|
961
|
-
enabled: false,
|
|
962
|
-
cronExpression: "0 12 * * *",
|
|
963
|
-
timezone: "UTC",
|
|
964
|
-
},
|
|
965
|
-
]);
|
|
966
|
-
|
|
967
|
-
const pausedCount = await pauseSeededScheduledRoutines(tempDb.connectionString);
|
|
968
|
-
expect(pausedCount).toBe(1);
|
|
969
|
-
|
|
970
|
-
const rows = await db.select({ id: routines.id, status: routines.status }).from(routines);
|
|
971
|
-
const statusById = new Map(rows.map((row) => [row.id, row.status]));
|
|
972
|
-
expect(statusById.get(activeScheduledRoutineId)).toBe("paused");
|
|
973
|
-
expect(statusById.get(activeApiRoutineId)).toBe("active");
|
|
974
|
-
expect(statusById.get(pausedScheduledRoutineId)).toBe("paused");
|
|
975
|
-
expect(statusById.get(archivedScheduledRoutineId)).toBe("archived");
|
|
976
|
-
expect(statusById.get(disabledScheduleRoutineId)).toBe("active");
|
|
977
|
-
} finally {
|
|
978
|
-
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
|
979
|
-
await tempDb.cleanup();
|
|
980
|
-
}
|
|
981
|
-
}, 20_000);
|
|
982
|
-
});
|