offwatch 0.5.9 → 0.5.10

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.
Files changed (94) hide show
  1. package/bin/offwatch.js +7 -6
  2. package/package.json +4 -3
  3. package/src/__tests__/agent-jwt-env.test.ts +79 -0
  4. package/src/__tests__/allowed-hostname.test.ts +80 -0
  5. package/src/__tests__/auth-command-registration.test.ts +16 -0
  6. package/src/__tests__/board-auth.test.ts +53 -0
  7. package/src/__tests__/common.test.ts +98 -0
  8. package/src/__tests__/company-delete.test.ts +95 -0
  9. package/src/__tests__/company-import-export-e2e.test.ts +502 -0
  10. package/src/__tests__/company-import-url.test.ts +74 -0
  11. package/src/__tests__/company-import-zip.test.ts +44 -0
  12. package/src/__tests__/company.test.ts +599 -0
  13. package/src/__tests__/context.test.ts +70 -0
  14. package/src/__tests__/data-dir.test.ts +79 -0
  15. package/src/__tests__/doctor.test.ts +102 -0
  16. package/src/__tests__/feedback.test.ts +177 -0
  17. package/src/__tests__/helpers/embedded-postgres.ts +6 -0
  18. package/src/__tests__/helpers/zip.ts +87 -0
  19. package/src/__tests__/home-paths.test.ts +44 -0
  20. package/src/__tests__/http.test.ts +106 -0
  21. package/src/__tests__/network-bind.test.ts +62 -0
  22. package/src/__tests__/onboard.test.ts +166 -0
  23. package/src/__tests__/routines.test.ts +249 -0
  24. package/src/__tests__/telemetry.test.ts +117 -0
  25. package/src/__tests__/worktree-merge-history.test.ts +492 -0
  26. package/src/__tests__/worktree.test.ts +982 -0
  27. package/src/adapters/http/format-event.ts +4 -0
  28. package/src/adapters/http/index.ts +7 -0
  29. package/src/adapters/index.ts +2 -0
  30. package/src/adapters/process/format-event.ts +4 -0
  31. package/src/adapters/process/index.ts +7 -0
  32. package/src/adapters/registry.ts +63 -0
  33. package/src/checks/agent-jwt-secret-check.ts +40 -0
  34. package/src/checks/config-check.ts +33 -0
  35. package/src/checks/database-check.ts +59 -0
  36. package/src/checks/deployment-auth-check.ts +88 -0
  37. package/src/checks/index.ts +18 -0
  38. package/src/checks/llm-check.ts +82 -0
  39. package/src/checks/log-check.ts +30 -0
  40. package/src/checks/path-resolver.ts +1 -0
  41. package/src/checks/port-check.ts +24 -0
  42. package/src/checks/secrets-check.ts +146 -0
  43. package/src/checks/storage-check.ts +51 -0
  44. package/src/client/board-auth.ts +282 -0
  45. package/src/client/command-label.ts +4 -0
  46. package/src/client/context.ts +175 -0
  47. package/src/client/http.ts +255 -0
  48. package/src/commands/allowed-hostname.ts +40 -0
  49. package/src/commands/auth-bootstrap-ceo.ts +138 -0
  50. package/src/commands/client/activity.ts +71 -0
  51. package/src/commands/client/agent.ts +315 -0
  52. package/src/commands/client/approval.ts +259 -0
  53. package/src/commands/client/auth.ts +113 -0
  54. package/src/commands/client/common.ts +221 -0
  55. package/src/commands/client/company.ts +1578 -0
  56. package/src/commands/client/context.ts +125 -0
  57. package/src/commands/client/dashboard.ts +34 -0
  58. package/src/commands/client/feedback.ts +645 -0
  59. package/src/commands/client/issue.ts +411 -0
  60. package/src/commands/client/plugin.ts +374 -0
  61. package/src/commands/client/zip.ts +129 -0
  62. package/src/commands/configure.ts +201 -0
  63. package/src/commands/db-backup.ts +102 -0
  64. package/src/commands/doctor.ts +203 -0
  65. package/src/commands/env.ts +411 -0
  66. package/src/commands/heartbeat-run.ts +344 -0
  67. package/src/commands/onboard.ts +692 -0
  68. package/src/commands/routines.ts +352 -0
  69. package/src/commands/run.ts +216 -0
  70. package/src/commands/worktree-lib.ts +279 -0
  71. package/src/commands/worktree-merge-history-lib.ts +764 -0
  72. package/src/commands/worktree.ts +2876 -0
  73. package/src/config/data-dir.ts +48 -0
  74. package/src/config/env.ts +125 -0
  75. package/src/config/home.ts +80 -0
  76. package/src/config/hostnames.ts +26 -0
  77. package/src/config/schema.ts +30 -0
  78. package/src/config/secrets-key.ts +48 -0
  79. package/src/config/server-bind.ts +183 -0
  80. package/src/config/store.ts +120 -0
  81. package/src/index.ts +182 -0
  82. package/src/prompts/database.ts +157 -0
  83. package/src/prompts/llm.ts +43 -0
  84. package/src/prompts/logging.ts +37 -0
  85. package/src/prompts/secrets.ts +99 -0
  86. package/src/prompts/server.ts +221 -0
  87. package/src/prompts/storage.ts +146 -0
  88. package/src/telemetry.ts +49 -0
  89. package/src/utils/banner.ts +24 -0
  90. package/src/utils/net.ts +18 -0
  91. package/src/utils/path-resolver.ts +25 -0
  92. package/src/version.ts +10 -0
  93. package/lib/downloader.js +0 -112
  94. package/postinstall.js +0 -23
@@ -0,0 +1,502 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import net from "node:net";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { promisify } from "node:util";
8
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
9
+ import {
10
+ getEmbeddedPostgresTestSupport,
11
+ startEmbeddedPostgresTestDatabase,
12
+ } from "./helpers/embedded-postgres.js";
13
+ import { createStoredZipArchive } from "./helpers/zip.js";
14
+
15
+ const execFileAsync = promisify(execFile);
16
+ type ServerProcess = ReturnType<typeof spawn>;
17
+
18
+ async function getAvailablePort(): Promise<number> {
19
+ return await new Promise((resolve, reject) => {
20
+ const server = net.createServer();
21
+ server.unref();
22
+ server.on("error", reject);
23
+ server.listen(0, "127.0.0.1", () => {
24
+ const address = server.address();
25
+ if (!address || typeof address === "string") {
26
+ server.close(() => reject(new Error("Failed to allocate test port")));
27
+ return;
28
+ }
29
+ const { port } = address;
30
+ server.close((error) => {
31
+ if (error) reject(error);
32
+ else resolve(port);
33
+ });
34
+ });
35
+ });
36
+ }
37
+
38
+ const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
39
+ const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
40
+
41
+ if (!embeddedPostgresSupport.supported) {
42
+ console.warn(
43
+ `Skipping embedded Postgres company import/export e2e tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
44
+ );
45
+ }
46
+
47
+ function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) {
48
+ const config = {
49
+ $meta: {
50
+ version: 1,
51
+ updatedAt: new Date().toISOString(),
52
+ source: "doctor",
53
+ },
54
+ database: {
55
+ mode: "postgres",
56
+ connectionString,
57
+ embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"),
58
+ embeddedPostgresPort: 54329,
59
+ backup: {
60
+ enabled: false,
61
+ intervalMinutes: 60,
62
+ retentionDays: 30,
63
+ dir: path.join(tempRoot, "backups"),
64
+ },
65
+ },
66
+ logging: {
67
+ mode: "file",
68
+ logDir: path.join(tempRoot, "logs"),
69
+ },
70
+ server: {
71
+ deploymentMode: "local_trusted",
72
+ exposure: "private",
73
+ host: "127.0.0.1",
74
+ port,
75
+ allowedHostnames: [],
76
+ serveUi: false,
77
+ },
78
+ auth: {
79
+ baseUrlMode: "auto",
80
+ disableSignUp: false,
81
+ },
82
+ storage: {
83
+ provider: "local_disk",
84
+ localDisk: {
85
+ baseDir: path.join(tempRoot, "storage"),
86
+ },
87
+ s3: {
88
+ bucket: "paperclip",
89
+ region: "us-east-1",
90
+ prefix: "",
91
+ forcePathStyle: false,
92
+ },
93
+ },
94
+ secrets: {
95
+ provider: "local_encrypted",
96
+ strictMode: false,
97
+ localEncrypted: {
98
+ keyFilePath: path.join(tempRoot, "secrets", "master.key"),
99
+ },
100
+ },
101
+ };
102
+
103
+ mkdirSync(path.dirname(configPath), { recursive: true });
104
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
105
+ }
106
+
107
+ function createServerEnv(configPath: string, port: number, connectionString: string) {
108
+ const env = { ...process.env };
109
+ for (const key of Object.keys(env)) {
110
+ if (key.startsWith("PAPERCLIP_")) {
111
+ delete env[key];
112
+ }
113
+ }
114
+ delete env.DATABASE_URL;
115
+ delete env.PORT;
116
+ delete env.HOST;
117
+ delete env.SERVE_UI;
118
+ delete env.HEARTBEAT_SCHEDULER_ENABLED;
119
+
120
+ env.PAPERCLIP_CONFIG = configPath;
121
+ env.DATABASE_URL = connectionString;
122
+ env.HOST = "127.0.0.1";
123
+ env.PORT = String(port);
124
+ env.SERVE_UI = "false";
125
+ env.PAPERCLIP_DB_BACKUP_ENABLED = "false";
126
+ env.HEARTBEAT_SCHEDULER_ENABLED = "false";
127
+ env.PAPERCLIP_MIGRATION_AUTO_APPLY = "true";
128
+ env.PAPERCLIP_UI_DEV_MIDDLEWARE = "false";
129
+
130
+ return env;
131
+ }
132
+
133
+ function createCliEnv() {
134
+ const env = { ...process.env };
135
+ for (const key of Object.keys(env)) {
136
+ if (key.startsWith("PAPERCLIP_")) {
137
+ delete env[key];
138
+ }
139
+ }
140
+ delete env.DATABASE_URL;
141
+ delete env.PORT;
142
+ delete env.HOST;
143
+ delete env.SERVE_UI;
144
+ delete env.PAPERCLIP_DB_BACKUP_ENABLED;
145
+ delete env.HEARTBEAT_SCHEDULER_ENABLED;
146
+ delete env.PAPERCLIP_MIGRATION_AUTO_APPLY;
147
+ delete env.PAPERCLIP_UI_DEV_MIDDLEWARE;
148
+ return env;
149
+ }
150
+
151
+ function collectTextFiles(root: string, current: string, files: Record<string, string>) {
152
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
153
+ const absolutePath = path.join(current, entry.name);
154
+ if (entry.isDirectory()) {
155
+ collectTextFiles(root, absolutePath, files);
156
+ continue;
157
+ }
158
+ if (!entry.isFile()) continue;
159
+ const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
160
+ files[relativePath] = readFileSync(absolutePath, "utf8");
161
+ }
162
+ }
163
+
164
+ async function stopServerProcess(child: ServerProcess | null) {
165
+ if (!child || child.exitCode !== null) return;
166
+ child.kill("SIGTERM");
167
+ await new Promise<void>((resolve) => {
168
+ child.once("exit", () => resolve());
169
+ setTimeout(() => {
170
+ if (child.exitCode === null) {
171
+ child.kill("SIGKILL");
172
+ }
173
+ }, 5_000);
174
+ });
175
+ }
176
+
177
+ async function api<T>(baseUrl: string, pathname: string, init?: RequestInit): Promise<T> {
178
+ const res = await fetch(`${baseUrl}${pathname}`, init);
179
+ const text = await res.text();
180
+ if (!res.ok) {
181
+ throw new Error(`Request failed ${res.status} ${pathname}: ${text}`);
182
+ }
183
+ return text ? JSON.parse(text) as T : (null as T);
184
+ }
185
+
186
+ async function runCliJson<T>(args: string[], opts: { apiBase: string; configPath: string }) {
187
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
188
+ const result = await execFileAsync(
189
+ "pnpm",
190
+ ["--silent", "paperclipai", ...args, "--api-base", opts.apiBase, "--config", opts.configPath, "--json"],
191
+ {
192
+ cwd: repoRoot,
193
+ env: createCliEnv(),
194
+ maxBuffer: 10 * 1024 * 1024,
195
+ },
196
+ );
197
+ const stdout = result.stdout.trim();
198
+ const jsonStart = stdout.search(/[\[{]/);
199
+ if (jsonStart === -1) {
200
+ throw new Error(`CLI did not emit JSON.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
201
+ }
202
+ return JSON.parse(stdout.slice(jsonStart)) as T;
203
+ }
204
+
205
+ async function waitForServer(
206
+ apiBase: string,
207
+ child: ServerProcess,
208
+ output: { stdout: string[]; stderr: string[] },
209
+ ) {
210
+ const startedAt = Date.now();
211
+ while (Date.now() - startedAt < 30_000) {
212
+ if (child.exitCode !== null) {
213
+ throw new Error(
214
+ `paperclipai run exited before healthcheck succeeded.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
215
+ );
216
+ }
217
+
218
+ try {
219
+ const res = await fetch(`${apiBase}/api/health`);
220
+ if (res.ok) return;
221
+ } catch {
222
+ // Server is still starting.
223
+ }
224
+
225
+ await new Promise((resolve) => setTimeout(resolve, 250));
226
+ }
227
+
228
+ throw new Error(
229
+ `Timed out waiting for ${apiBase}/api/health.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
230
+ );
231
+ }
232
+
233
+ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
234
+ let tempRoot = "";
235
+ let configPath = "";
236
+ let exportDir = "";
237
+ let apiBase = "";
238
+ let serverProcess: ServerProcess | null = null;
239
+ let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
240
+
241
+ beforeAll(async () => {
242
+ tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-"));
243
+ configPath = path.join(tempRoot, "config", "config.json");
244
+ exportDir = path.join(tempRoot, "exported-company");
245
+
246
+ tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-");
247
+
248
+ const port = await getAvailablePort();
249
+ writeTestConfig(configPath, tempRoot, port, tempDb.connectionString);
250
+ apiBase = `http://127.0.0.1:${port}`;
251
+
252
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
253
+ const output = { stdout: [] as string[], stderr: [] as string[] };
254
+ const child = spawn(
255
+ "pnpm",
256
+ ["paperclipai", "run", "--config", configPath],
257
+ {
258
+ cwd: repoRoot,
259
+ env: createServerEnv(configPath, port, tempDb.connectionString),
260
+ stdio: ["ignore", "pipe", "pipe"],
261
+ },
262
+ );
263
+ serverProcess = child;
264
+ child.stdout?.on("data", (chunk) => {
265
+ output.stdout.push(String(chunk));
266
+ });
267
+ child.stderr?.on("data", (chunk) => {
268
+ output.stderr.push(String(chunk));
269
+ });
270
+
271
+ await waitForServer(apiBase, child, output);
272
+ }, 60_000);
273
+
274
+ afterAll(async () => {
275
+ await stopServerProcess(serverProcess);
276
+ await tempDb?.cleanup();
277
+ if (tempRoot) {
278
+ rmSync(tempRoot, { recursive: true, force: true });
279
+ }
280
+ });
281
+
282
+ it("exports a company package and imports it into new and existing companies", async () => {
283
+ expect(serverProcess).not.toBeNull();
284
+
285
+ const sourceCompany = await api<{ id: string; name: string; issuePrefix: string }>(apiBase, "/api/companies", {
286
+ method: "POST",
287
+ headers: { "content-type": "application/json" },
288
+ body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }),
289
+ });
290
+
291
+ const sourceAgent = await api<{ id: string; name: string }>(
292
+ apiBase,
293
+ `/api/companies/${sourceCompany.id}/agents`,
294
+ {
295
+ method: "POST",
296
+ headers: { "content-type": "application/json" },
297
+ body: JSON.stringify({
298
+ name: "Export Engineer",
299
+ role: "engineer",
300
+ adapterType: "claude_local",
301
+ adapterConfig: {
302
+ promptTemplate: "You verify company portability.",
303
+ },
304
+ }),
305
+ },
306
+ );
307
+
308
+ const sourceProject = await api<{ id: string; name: string }>(
309
+ apiBase,
310
+ `/api/companies/${sourceCompany.id}/projects`,
311
+ {
312
+ method: "POST",
313
+ headers: { "content-type": "application/json" },
314
+ body: JSON.stringify({
315
+ name: "Portability Verification",
316
+ status: "in_progress",
317
+ }),
318
+ },
319
+ );
320
+
321
+ const largeIssueDescription = `Round-trip the company package through the CLI.\n\n${"portable-data ".repeat(12_000)}`;
322
+
323
+ const sourceIssue = await api<{ id: string; title: string; identifier: string }>(
324
+ apiBase,
325
+ `/api/companies/${sourceCompany.id}/issues`,
326
+ {
327
+ method: "POST",
328
+ headers: { "content-type": "application/json" },
329
+ body: JSON.stringify({
330
+ title: "Validate company import/export",
331
+ description: largeIssueDescription,
332
+ status: "todo",
333
+ projectId: sourceProject.id,
334
+ assigneeAgentId: sourceAgent.id,
335
+ }),
336
+ },
337
+ );
338
+
339
+ const exportResult = await runCliJson<{
340
+ ok: boolean;
341
+ out: string;
342
+ filesWritten: number;
343
+ }>(
344
+ [
345
+ "company",
346
+ "export",
347
+ sourceCompany.id,
348
+ "--out",
349
+ exportDir,
350
+ "--include",
351
+ "company,agents,projects,issues",
352
+ ],
353
+ { apiBase, configPath },
354
+ );
355
+
356
+ expect(exportResult.ok).toBe(true);
357
+ expect(exportResult.filesWritten).toBeGreaterThan(0);
358
+ expect(readFileSync(path.join(exportDir, "COMPANY.md"), "utf8")).toContain(sourceCompany.name);
359
+ expect(readFileSync(path.join(exportDir, ".paperclip.yaml"), "utf8")).toContain('schema: "paperclip/v1"');
360
+
361
+ const importedNew = await runCliJson<{
362
+ company: { id: string; name: string; action: string };
363
+ agents: Array<{ id: string | null; action: string; name: string }>;
364
+ }>(
365
+ [
366
+ "company",
367
+ "import",
368
+ exportDir,
369
+ "--target",
370
+ "new",
371
+ "--new-company-name",
372
+ `Imported ${sourceCompany.name}`,
373
+ "--include",
374
+ "company,agents,projects,issues",
375
+ "--yes",
376
+ ],
377
+ { apiBase, configPath },
378
+ );
379
+
380
+ expect(importedNew.company.action).toBe("created");
381
+ expect(importedNew.agents).toHaveLength(1);
382
+ expect(importedNew.agents[0]?.action).toBe("created");
383
+
384
+ const importedAgents = await api<Array<{ id: string; name: string }>>(
385
+ apiBase,
386
+ `/api/companies/${importedNew.company.id}/agents`,
387
+ );
388
+ const importedProjects = await api<Array<{ id: string; name: string }>>(
389
+ apiBase,
390
+ `/api/companies/${importedNew.company.id}/projects`,
391
+ );
392
+ const importedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
393
+ apiBase,
394
+ `/api/companies/${importedNew.company.id}/issues`,
395
+ );
396
+
397
+ expect(importedAgents.map((agent) => agent.name)).toContain(sourceAgent.name);
398
+ expect(importedProjects.map((project) => project.name)).toContain(sourceProject.name);
399
+ expect(importedIssues.map((issue) => issue.title)).toContain(sourceIssue.title);
400
+
401
+ const previewExisting = await runCliJson<{
402
+ errors: string[];
403
+ plan: {
404
+ companyAction: string;
405
+ agentPlans: Array<{ action: string }>;
406
+ projectPlans: Array<{ action: string }>;
407
+ issuePlans: Array<{ action: string }>;
408
+ };
409
+ }>(
410
+ [
411
+ "company",
412
+ "import",
413
+ exportDir,
414
+ "--target",
415
+ "existing",
416
+ "--company-id",
417
+ importedNew.company.id,
418
+ "--include",
419
+ "company,agents,projects,issues",
420
+ "--collision",
421
+ "rename",
422
+ "--dry-run",
423
+ ],
424
+ { apiBase, configPath },
425
+ );
426
+
427
+ expect(previewExisting.errors).toEqual([]);
428
+ expect(previewExisting.plan.companyAction).toBe("none");
429
+ expect(previewExisting.plan.agentPlans.some((plan) => plan.action === "create")).toBe(true);
430
+ expect(previewExisting.plan.projectPlans.some((plan) => plan.action === "create")).toBe(true);
431
+ expect(previewExisting.plan.issuePlans.some((plan) => plan.action === "create")).toBe(true);
432
+
433
+ const importedExisting = await runCliJson<{
434
+ company: { id: string; action: string };
435
+ agents: Array<{ id: string | null; action: string; name: string }>;
436
+ }>(
437
+ [
438
+ "company",
439
+ "import",
440
+ exportDir,
441
+ "--target",
442
+ "existing",
443
+ "--company-id",
444
+ importedNew.company.id,
445
+ "--include",
446
+ "company,agents,projects,issues",
447
+ "--collision",
448
+ "rename",
449
+ "--yes",
450
+ ],
451
+ { apiBase, configPath },
452
+ );
453
+
454
+ expect(importedExisting.company.action).toBe("unchanged");
455
+ expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true);
456
+
457
+ const twiceImportedAgents = await api<Array<{ id: string; name: string }>>(
458
+ apiBase,
459
+ `/api/companies/${importedNew.company.id}/agents`,
460
+ );
461
+ const twiceImportedProjects = await api<Array<{ id: string; name: string }>>(
462
+ apiBase,
463
+ `/api/companies/${importedNew.company.id}/projects`,
464
+ );
465
+ const twiceImportedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
466
+ apiBase,
467
+ `/api/companies/${importedNew.company.id}/issues`,
468
+ );
469
+
470
+ expect(twiceImportedAgents).toHaveLength(2);
471
+ expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
472
+ expect(twiceImportedProjects).toHaveLength(2);
473
+ expect(twiceImportedIssues).toHaveLength(2);
474
+
475
+ const zipPath = path.join(tempRoot, "exported-company.zip");
476
+ const portableFiles: Record<string, string> = {};
477
+ collectTextFiles(exportDir, exportDir, portableFiles);
478
+ writeFileSync(zipPath, createStoredZipArchive(portableFiles, "paperclip-demo"));
479
+
480
+ const importedFromZip = await runCliJson<{
481
+ company: { id: string; name: string; action: string };
482
+ agents: Array<{ id: string | null; action: string; name: string }>;
483
+ }>(
484
+ [
485
+ "company",
486
+ "import",
487
+ zipPath,
488
+ "--target",
489
+ "new",
490
+ "--new-company-name",
491
+ `Zip Imported ${sourceCompany.name}`,
492
+ "--include",
493
+ "company,agents,projects,issues",
494
+ "--yes",
495
+ ],
496
+ { apiBase, configPath },
497
+ );
498
+
499
+ expect(importedFromZip.company.action).toBe("created");
500
+ expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true);
501
+ }, 60_000);
502
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ isGithubShorthand,
4
+ looksLikeRepoUrl,
5
+ isHttpUrl,
6
+ normalizeGithubImportSource,
7
+ } from "../commands/client/company.js";
8
+
9
+ describe("isHttpUrl", () => {
10
+ it("matches http URLs", () => {
11
+ expect(isHttpUrl("http://example.com/foo")).toBe(true);
12
+ });
13
+
14
+ it("matches https URLs", () => {
15
+ expect(isHttpUrl("https://example.com/foo")).toBe(true);
16
+ });
17
+
18
+ it("rejects local paths", () => {
19
+ expect(isHttpUrl("/tmp/my-company")).toBe(false);
20
+ expect(isHttpUrl("./relative")).toBe(false);
21
+ });
22
+ });
23
+
24
+ describe("looksLikeRepoUrl", () => {
25
+ it("matches GitHub URLs", () => {
26
+ expect(looksLikeRepoUrl("https://github.com/org/repo")).toBe(true);
27
+ });
28
+
29
+ it("rejects URLs without owner/repo path", () => {
30
+ expect(looksLikeRepoUrl("https://example.com/foo")).toBe(false);
31
+ });
32
+
33
+ it("rejects local paths", () => {
34
+ expect(looksLikeRepoUrl("/tmp/my-company")).toBe(false);
35
+ });
36
+ });
37
+
38
+ describe("isGithubShorthand", () => {
39
+ it("matches owner/repo/path shorthands", () => {
40
+ expect(isGithubShorthand("paperclipai/companies/gstack")).toBe(true);
41
+ expect(isGithubShorthand("paperclipai/companies")).toBe(true);
42
+ });
43
+
44
+ it("rejects local-looking paths", () => {
45
+ expect(isGithubShorthand("./exports/acme")).toBe(false);
46
+ expect(isGithubShorthand("/tmp/acme")).toBe(false);
47
+ expect(isGithubShorthand("C:\\temp\\acme")).toBe(false);
48
+ });
49
+ });
50
+
51
+ describe("normalizeGithubImportSource", () => {
52
+ it("normalizes shorthand imports to canonical GitHub sources", () => {
53
+ expect(normalizeGithubImportSource("paperclipai/companies/gstack")).toBe(
54
+ "https://github.com/paperclipai/companies?ref=main&path=gstack",
55
+ );
56
+ });
57
+
58
+ it("applies --ref to shorthand imports", () => {
59
+ expect(normalizeGithubImportSource("paperclipai/companies/gstack", "feature/demo")).toBe(
60
+ "https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack",
61
+ );
62
+ });
63
+
64
+ it("applies --ref to existing GitHub tree URLs without losing the package path", () => {
65
+ expect(
66
+ normalizeGithubImportSource(
67
+ "https://github.com/paperclipai/companies/tree/main/gstack",
68
+ "release/2026-03-23",
69
+ ),
70
+ ).toBe(
71
+ "https://github.com/paperclipai/companies?ref=release%2F2026-03-23&path=gstack",
72
+ );
73
+ });
74
+ });
@@ -0,0 +1,44 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { resolveInlineSourceFromPath } from "../commands/client/company.js";
6
+ import { createStoredZipArchive } from "./helpers/zip.js";
7
+
8
+ const tempDirs: string[] = [];
9
+
10
+ afterEach(async () => {
11
+ for (const dir of tempDirs.splice(0)) {
12
+ await rm(dir, { recursive: true, force: true });
13
+ }
14
+ });
15
+
16
+ describe("resolveInlineSourceFromPath", () => {
17
+ it("imports portable files from a zip archive instead of scanning the parent directory", async () => {
18
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-company-import-zip-"));
19
+ tempDirs.push(tempDir);
20
+
21
+ const archivePath = path.join(tempDir, "paperclip-demo.zip");
22
+ const archive = createStoredZipArchive(
23
+ {
24
+ "COMPANY.md": "# Company\n",
25
+ ".paperclip.yaml": "schema: paperclip/v1\n",
26
+ "agents/ceo/AGENT.md": "# CEO\n",
27
+ "notes/todo.txt": "ignore me\n",
28
+ },
29
+ "paperclip-demo",
30
+ );
31
+ await writeFile(archivePath, archive);
32
+
33
+ const resolved = await resolveInlineSourceFromPath(archivePath);
34
+
35
+ expect(resolved).toEqual({
36
+ rootPath: "paperclip-demo",
37
+ files: {
38
+ "COMPANY.md": "# Company\n",
39
+ ".paperclip.yaml": "schema: paperclip/v1\n",
40
+ "agents/ceo/AGENT.md": "# CEO\n",
41
+ },
42
+ });
43
+ });
44
+ });