Merge public-gh/master into review/pr-162
This commit is contained in:
@@ -1,5 +1,44 @@
|
||||
# paperclipai
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
- @paperclipai/adapter-claude-local@0.3.1
|
||||
- @paperclipai/adapter-codex-local@0.3.1
|
||||
- @paperclipai/adapter-cursor-local@0.3.1
|
||||
- @paperclipai/adapter-gemini-local@0.3.1
|
||||
- @paperclipai/adapter-openclaw-gateway@0.3.1
|
||||
- @paperclipai/adapter-opencode-local@0.3.1
|
||||
- @paperclipai/adapter-pi-local@0.3.1
|
||||
- @paperclipai/db@0.3.1
|
||||
- @paperclipai/shared@0.3.1
|
||||
- @paperclipai/server@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6077ae6]
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.3.0
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
- @paperclipai/adapter-claude-local@0.3.0
|
||||
- @paperclipai/adapter-codex-local@0.3.0
|
||||
- @paperclipai/adapter-cursor-local@0.3.0
|
||||
- @paperclipai/adapter-openclaw-gateway@0.3.0
|
||||
- @paperclipai/adapter-opencode-local@0.3.0
|
||||
- @paperclipai/adapter-pi-local@0.3.0
|
||||
- @paperclipai/db@0.3.0
|
||||
- @paperclipai/server@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -21,7 +21,7 @@ const workspacePaths = [
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/openclaw",
|
||||
"packages/adapters/openclaw-gateway",
|
||||
];
|
||||
|
||||
// Workspace packages that should NOT be bundled — they'll be published
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperclipai",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.1",
|
||||
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -37,9 +37,10 @@
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/server": "workspace:*",
|
||||
@@ -47,6 +48,7 @@
|
||||
"drizzle-orm": "0.38.4",
|
||||
"dotenv": "^17.0.1",
|
||||
"commander": "^13.1.0",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,7 +4,9 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureAgentJwtSecret,
|
||||
mergePaperclipEnvEntries,
|
||||
readAgentJwtSecretFromEnv,
|
||||
readPaperclipEnvEntries,
|
||||
resolveAgentJwtEnvFile,
|
||||
} from "../config/env.js";
|
||||
import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js";
|
||||
@@ -58,4 +60,20 @@ describe("agent jwt env helpers", () => {
|
||||
const result = agentJwtSecretCheck(configPath);
|
||||
expect(result.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("quotes hash-prefixed env values so dotenv round-trips them", () => {
|
||||
const configPath = tempConfigPath();
|
||||
const envPath = resolveAgentJwtEnvFile(configPath);
|
||||
|
||||
mergePaperclipEnvEntries(
|
||||
{
|
||||
PAPERCLIP_WORKTREE_COLOR: "#439edb",
|
||||
},
|
||||
envPath,
|
||||
);
|
||||
|
||||
const contents = fs.readFileSync(envPath, "utf-8");
|
||||
expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"');
|
||||
expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ function writeBaseConfig(configPath: string) {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
|
||||
99
cli/src/__tests__/doctor.test.ts
Normal file
99
cli/src/__tests__/doctor.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { doctor } from "../commands/doctor.js";
|
||||
import { writeConfig } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function createTempConfig(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-"));
|
||||
const configPath = path.join(root, ".paperclip", "config.json");
|
||||
const runtimeRoot = path.join(root, "runtime");
|
||||
|
||||
const config: PaperclipConfig = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
|
||||
embeddedPostgresPort: 55432,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(runtimeRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(runtimeRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3199,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(runtimeRoot, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
writeConfig(config, configPath);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
describe("doctor", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("re-runs repairable checks so repaired failures do not remain blocking", async () => {
|
||||
const configPath = createTempConfig();
|
||||
|
||||
const summary = await doctor({
|
||||
config: configPath,
|
||||
repair: true,
|
||||
yes: true,
|
||||
});
|
||||
|
||||
expect(summary.failed).toBe(0);
|
||||
expect(summary.warned).toBe(0);
|
||||
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy();
|
||||
});
|
||||
});
|
||||
472
cli/src/__tests__/worktree.test.ts
Normal file
472
cli/src/__tests__/worktree.test.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
copyGitHooksToWorktreeGitDir,
|
||||
copySeededSecretsKey,
|
||||
rebindWorkspaceCwd,
|
||||
resolveSourceConfigPath,
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeInitCommand,
|
||||
worktreeMakeCommand,
|
||||
} from "../commands/worktree.js";
|
||||
import {
|
||||
buildWorktreeConfig,
|
||||
buildWorktreeEnvEntries,
|
||||
formatShellExports,
|
||||
generateWorktreeColor,
|
||||
resolveWorktreeSeedPlan,
|
||||
resolveWorktreeLocalPaths,
|
||||
rewriteLocalUrlPort,
|
||||
sanitizeWorktreeInstanceId,
|
||||
} from "../commands/worktree-lib.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in ORIGINAL_ENV)) delete process.env[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function buildSourceConfig(): PaperclipConfig {
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-09T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: "/tmp/main/db",
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: "/tmp/main/backups",
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: "/tmp/main/logs",
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "authenticated",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: ["localhost"],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "explicit",
|
||||
publicBaseUrl: "http://127.0.0.1:3100",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: "/tmp/main/storage",
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: "/tmp/main/secrets/master.key",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("worktree helpers", () => {
|
||||
it("sanitizes instance ids", () => {
|
||||
expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support");
|
||||
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
|
||||
});
|
||||
|
||||
it("resolves worktree:make target paths under the user home directory", () => {
|
||||
expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe(
|
||||
path.resolve(os.homedir(), "paperclip-pr-432"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects worktree:make names that are not safe directory/branch names", () => {
|
||||
expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow(
|
||||
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds git worktree add args for new and existing branches", () => {
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
branchName: "feature-branch",
|
||||
targetPath: "/tmp/feature-branch",
|
||||
branchExists: false,
|
||||
}),
|
||||
).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]);
|
||||
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
branchName: "feature-branch",
|
||||
targetPath: "/tmp/feature-branch",
|
||||
branchExists: true,
|
||||
}),
|
||||
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
|
||||
});
|
||||
|
||||
it("builds git worktree add args with a start point", () => {
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
branchName: "my-worktree",
|
||||
targetPath: "/tmp/my-worktree",
|
||||
branchExists: false,
|
||||
startPoint: "public-gh/master",
|
||||
}),
|
||||
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]);
|
||||
});
|
||||
|
||||
it("uses start point even when a local branch with the same name exists", () => {
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
branchName: "my-worktree",
|
||||
targetPath: "/tmp/my-worktree",
|
||||
branchExists: true,
|
||||
startPoint: "origin/main",
|
||||
}),
|
||||
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
|
||||
});
|
||||
|
||||
it("rewrites loopback auth URLs to the new port only", () => {
|
||||
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
||||
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
||||
});
|
||||
|
||||
it("builds isolated config and env paths for a worktree", () => {
|
||||
const paths = resolveWorktreeLocalPaths({
|
||||
cwd: "/tmp/paperclip-feature",
|
||||
homeDir: "/tmp/paperclip-worktrees",
|
||||
instanceId: "feature-worktree-support",
|
||||
});
|
||||
const config = buildWorktreeConfig({
|
||||
sourceConfig: buildSourceConfig(),
|
||||
paths,
|
||||
serverPort: 3110,
|
||||
databasePort: 54339,
|
||||
now: new Date("2026-03-09T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(config.database.embeddedPostgresDataDir).toBe(
|
||||
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"),
|
||||
);
|
||||
expect(config.database.embeddedPostgresPort).toBe(54339);
|
||||
expect(config.server.port).toBe(3110);
|
||||
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/");
|
||||
expect(config.storage.localDisk.baseDir).toBe(
|
||||
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
|
||||
);
|
||||
|
||||
const env = buildWorktreeEnvEntries(paths, {
|
||||
name: "feature-worktree-support",
|
||||
color: "#3abf7a",
|
||||
});
|
||||
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
|
||||
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
|
||||
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
|
||||
expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support");
|
||||
expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a");
|
||||
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
||||
});
|
||||
|
||||
it("generates vivid worktree colors as hex", () => {
|
||||
expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
|
||||
const minimal = resolveWorktreeSeedPlan("minimal");
|
||||
const full = resolveWorktreeSeedPlan("full");
|
||||
|
||||
expect(minimal.excludedTables).toContain("heartbeat_runs");
|
||||
expect(minimal.excludedTables).toContain("heartbeat_run_events");
|
||||
expect(minimal.excludedTables).toContain("workspace_runtime_services");
|
||||
expect(minimal.excludedTables).toContain("agent_task_sessions");
|
||||
expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]);
|
||||
|
||||
expect(full.excludedTables).toEqual([]);
|
||||
expect(full.nullifyColumns).toEqual({});
|
||||
});
|
||||
|
||||
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
||||
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
try {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key");
|
||||
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
|
||||
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
|
||||
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
|
||||
|
||||
const sourceConfig = buildSourceConfig();
|
||||
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
|
||||
|
||||
copySeededSecretsKey({
|
||||
sourceConfigPath,
|
||||
sourceConfig,
|
||||
sourceEnvEntries: {},
|
||||
targetKeyFilePath: targetKeyPath,
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key");
|
||||
} finally {
|
||||
if (originalInlineMasterKey === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = originalInlineMasterKey;
|
||||
}
|
||||
if (originalKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = originalKeyFile;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("writes the source inline secrets master key into the seeded worktree instance", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
||||
try {
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
|
||||
|
||||
copySeededSecretsKey({
|
||||
sourceConfigPath,
|
||||
sourceConfig: buildSourceConfig(),
|
||||
sourceEnvEntries: {
|
||||
PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key",
|
||||
},
|
||||
targetKeyFilePath: targetKeyPath,
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key");
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists the current agent jwt secret into the worktree env file", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const originalCwd = process.cwd();
|
||||
const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret";
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await worktreeInitCommand({
|
||||
seed: false,
|
||||
fromConfig: path.join(tempRoot, "missing", "config.json"),
|
||||
home: path.join(tempRoot, ".paperclip-worktrees"),
|
||||
});
|
||||
|
||||
const envPath = path.join(repoRoot, ".paperclip", ".env");
|
||||
const envContents = fs.readFileSync(envPath, "utf8");
|
||||
expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
|
||||
expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo");
|
||||
expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalJwtSecret === undefined) {
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
} else {
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults the seed source config to the current repo-local Paperclip config", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const localConfigPath = path.join(repoRoot, ".paperclip", "config.json");
|
||||
const originalCwd = process.cwd();
|
||||
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(localConfigPath), { recursive: true });
|
||||
fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
process.chdir(repoRoot);
|
||||
|
||||
expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath));
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalPaperclipConfig === undefined) {
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
} else {
|
||||
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the source config path across worktree:make cwd changes", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-"));
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
const targetRoot = path.join(tempRoot, "target");
|
||||
const originalCwd = process.cwd();
|
||||
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
process.chdir(targetRoot);
|
||||
|
||||
expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe(
|
||||
path.resolve(sourceConfigPath),
|
||||
);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalPaperclipConfig === undefined) {
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
} else {
|
||||
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rebinds same-repo workspace paths onto the current worktree root", () => {
|
||||
expect(
|
||||
rebindWorkspaceCwd({
|
||||
sourceRepoRoot: "/Users/example/paperclip",
|
||||
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
||||
workspaceCwd: "/Users/example/paperclip",
|
||||
}),
|
||||
).toBe("/Users/example/paperclip-pr-432");
|
||||
|
||||
expect(
|
||||
rebindWorkspaceCwd({
|
||||
sourceRepoRoot: "/Users/example/paperclip",
|
||||
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
||||
workspaceCwd: "/Users/example/paperclip/packages/db",
|
||||
}),
|
||||
).toBe("/Users/example/paperclip-pr-432/packages/db");
|
||||
});
|
||||
|
||||
it("does not rebind paths outside the source repo root", () => {
|
||||
expect(
|
||||
rebindWorkspaceCwd({
|
||||
sourceRepoRoot: "/Users/example/paperclip",
|
||||
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
||||
workspaceCwd: "/Users/example/other-project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("copies shared git hooks into a linked worktree git dir", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const worktreePath = path.join(tempRoot, "repo-feature");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
|
||||
const sourceHooksDir = path.join(repoRoot, ".git", "hooks");
|
||||
const sourceHookPath = path.join(sourceHooksDir, "pre-commit");
|
||||
const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt");
|
||||
fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 });
|
||||
fs.chmodSync(sourceHookPath, 0o755);
|
||||
fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8");
|
||||
|
||||
execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" });
|
||||
|
||||
const copied = copyGitHooksToWorktreeGitDir(worktreePath);
|
||||
const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir);
|
||||
const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks"));
|
||||
const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit");
|
||||
const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt");
|
||||
|
||||
expect(copied).toMatchObject({
|
||||
sourceHooksPath: resolvedSourceHooksDir,
|
||||
targetHooksPath: resolvedTargetHooksDir,
|
||||
copied: true,
|
||||
});
|
||||
expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n");
|
||||
expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0);
|
||||
expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n");
|
||||
} finally {
|
||||
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const fakeHome = path.join(tempRoot, "home");
|
||||
const worktreePath = path.join(fakeHome, "paperclip-make-test");
|
||||
const originalCwd = process.cwd();
|
||||
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
fs.mkdirSync(fakeHome, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await worktreeMakeCommand("paperclip-make-test", {
|
||||
seed: false,
|
||||
home: path.join(tempRoot, ".paperclip-worktrees"),
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
homedirSpy.mockRestore();
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -2,9 +2,10 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
|
||||
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
||||
import { processCLIAdapter } from "./process/index.js";
|
||||
import { httpCLIAdapter } from "./http/index.js";
|
||||
|
||||
@@ -33,13 +34,28 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printCursorStreamEvent,
|
||||
};
|
||||
|
||||
const openclawCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw",
|
||||
formatStdoutEvent: printOpenClawStreamEvent,
|
||||
const geminiLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "gemini_local",
|
||||
formatStdoutEvent: printGeminiStreamEvent,
|
||||
};
|
||||
|
||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
||||
[
|
||||
claudeLocalCLIAdapter,
|
||||
codexLocalCLIAdapter,
|
||||
openCodeLocalCLIAdapter,
|
||||
piLocalCLIAdapter,
|
||||
cursorLocalCLIAdapter,
|
||||
geminiLocalCLIAdapter,
|
||||
openclawGatewayCLIAdapter,
|
||||
processCLIAdapter,
|
||||
httpCLIAdapter,
|
||||
].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getCLIAdapter(type: string): CLIAdapterModule {
|
||||
|
||||
@@ -104,8 +104,10 @@ export class PaperclipApiClient {
|
||||
|
||||
function buildUrl(apiBase: string, path: string): string {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const [pathname, query] = normalizedPath.split("?");
|
||||
const url = new URL(apiBase);
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, "")}${normalizedPath}`;
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`;
|
||||
if (query) url.search = query;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ export async function addAllowedHostname(host: string, opts: { config?: string }
|
||||
p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`);
|
||||
} else {
|
||||
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
|
||||
p.log.message(
|
||||
pc.dim("Restart the Paperclip server for this change to take effect."),
|
||||
);
|
||||
}
|
||||
|
||||
if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
@@ -13,7 +14,8 @@ function createInviteToken() {
|
||||
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
function resolveDbUrl(configPath?: string) {
|
||||
function resolveDbUrl(configPath?: string, explicitDbUrl?: string) {
|
||||
if (explicitDbUrl) return explicitDbUrl;
|
||||
const config = readConfig(configPath);
|
||||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
||||
if (config?.database.mode === "postgres" && config.database.connectionString) {
|
||||
@@ -49,8 +51,10 @@ export async function bootstrapCeoInvite(opts: {
|
||||
force?: boolean;
|
||||
expiresHours?: number;
|
||||
baseUrl?: string;
|
||||
dbUrl?: string;
|
||||
}) {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
loadPaperclipEnvFile(configPath);
|
||||
const config = readConfig(configPath);
|
||||
if (!config) {
|
||||
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
|
||||
@@ -62,7 +66,7 @@ export async function bootstrapCeoInvite(opts: {
|
||||
return;
|
||||
}
|
||||
|
||||
const dbUrl = resolveDbUrl(configPath);
|
||||
const dbUrl = resolveDbUrl(configPath, opts.dbUrl);
|
||||
if (!dbUrl) {
|
||||
p.log.error(
|
||||
"Could not resolve database connection for bootstrap.",
|
||||
@@ -71,6 +75,11 @@ export async function bootstrapCeoInvite(opts: {
|
||||
}
|
||||
|
||||
const db = createDb(dbUrl);
|
||||
const closableDb = db as typeof db & {
|
||||
$client?: {
|
||||
end?: (options?: { timeout?: number }) => Promise<void>;
|
||||
};
|
||||
};
|
||||
try {
|
||||
const existingAdminCount = await db
|
||||
.select()
|
||||
@@ -118,5 +127,7 @@ export async function bootstrapCeoInvite(opts: {
|
||||
} catch (err) {
|
||||
p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`);
|
||||
p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
|
||||
} finally {
|
||||
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Command } from "commander";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import {
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
resolvePaperclipSkillsDir,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
formatInlineRecord,
|
||||
@@ -13,6 +21,141 @@ interface AgentListOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
interface AgentLocalCliOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
keyName?: string;
|
||||
installSkills?: boolean;
|
||||
}
|
||||
|
||||
interface CreatedAgentKey {
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface SkillsInstallSummary {
|
||||
tool: "codex" | "claude";
|
||||
target: string;
|
||||
linked: string[];
|
||||
removed: string[];
|
||||
skipped: string[];
|
||||
failed: Array<{ name: string; error: string }>;
|
||||
}
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function codexSkillsHome(): string {
|
||||
const fromEnv = process.env.CODEX_HOME?.trim();
|
||||
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex");
|
||||
return path.join(base, "skills");
|
||||
}
|
||||
|
||||
function claudeSkillsHome(): string {
|
||||
const fromEnv = process.env.CLAUDE_HOME?.trim();
|
||||
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude");
|
||||
return path.join(base, "skills");
|
||||
}
|
||||
|
||||
async function installSkillsForTarget(
|
||||
sourceSkillsDir: string,
|
||||
targetSkillsDir: string,
|
||||
tool: "codex" | "claude",
|
||||
): Promise<SkillsInstallSummary> {
|
||||
const summary: SkillsInstallSummary = {
|
||||
tool,
|
||||
target: targetSkillsDir,
|
||||
linked: [],
|
||||
removed: [],
|
||||
skipped: [],
|
||||
failed: [],
|
||||
};
|
||||
|
||||
await fs.mkdir(targetSkillsDir, { recursive: true });
|
||||
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
|
||||
summary.removed = await removeMaintainerOnlySkillSymlinks(
|
||||
targetSkillsDir,
|
||||
entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
|
||||
);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(sourceSkillsDir, entry.name);
|
||||
const target = path.join(targetSkillsDir, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) {
|
||||
if (existing.isSymbolicLink()) {
|
||||
let linkedPath: string | null = null;
|
||||
try {
|
||||
linkedPath = await fs.readlink(target);
|
||||
} catch (err) {
|
||||
await fs.unlink(target);
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
summary.linked.push(entry.name);
|
||||
continue;
|
||||
} catch (linkErr) {
|
||||
summary.failed.push({
|
||||
name: entry.name,
|
||||
error:
|
||||
err instanceof Error && linkErr instanceof Error
|
||||
? `${err.message}; then ${linkErr.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: `Failed to recover broken symlink: ${String(err)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedLinkedPath = path.isAbsolute(linkedPath)
|
||||
? linkedPath
|
||||
: path.resolve(path.dirname(target), linkedPath);
|
||||
const linkedTargetExists = await fs
|
||||
.stat(resolvedLinkedPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!linkedTargetExists) {
|
||||
await fs.unlink(target);
|
||||
} else {
|
||||
summary.skipped.push(entry.name);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
summary.skipped.push(entry.name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
summary.linked.push(entry.name);
|
||||
} catch (err) {
|
||||
summary.failed.push({
|
||||
name: entry.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function buildAgentEnvExports(input: {
|
||||
apiBase: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
apiKey: string;
|
||||
}): string {
|
||||
const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
|
||||
return [
|
||||
`export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`,
|
||||
`export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`,
|
||||
`export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`,
|
||||
`export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function registerAgentCommands(program: Command): void {
|
||||
const agent = program.command("agent").description("Agent operations");
|
||||
|
||||
@@ -71,4 +214,102 @@ export function registerAgentCommands(program: Command): void {
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
agent
|
||||
.command("local-cli")
|
||||
.description(
|
||||
"Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports",
|
||||
)
|
||||
.argument("<agentRef>", "Agent ID or shortname/url-key")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.option("--key-name <name>", "API key label", "local-cli")
|
||||
.option(
|
||||
"--no-install-skills",
|
||||
"Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills",
|
||||
)
|
||||
.action(async (agentRef: string, opts: AgentLocalCliOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const query = new URLSearchParams({ companyId: ctx.companyId ?? "" });
|
||||
const agentRow = await ctx.api.get<Agent>(
|
||||
`/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`,
|
||||
);
|
||||
if (!agentRow) {
|
||||
throw new Error(`Agent not found: ${agentRef}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString().replaceAll(":", "-");
|
||||
const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`;
|
||||
const key = await ctx.api.post<CreatedAgentKey>(`/api/agents/${agentRow.id}/keys`, { name: keyName });
|
||||
if (!key) {
|
||||
throw new Error("Failed to create API key");
|
||||
}
|
||||
|
||||
const installSummaries: SkillsInstallSummary[] = [];
|
||||
if (opts.installSkills !== false) {
|
||||
const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [path.resolve(process.cwd(), "skills")]);
|
||||
if (!skillsDir) {
|
||||
throw new Error(
|
||||
"Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.",
|
||||
);
|
||||
}
|
||||
|
||||
installSummaries.push(
|
||||
await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"),
|
||||
await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
|
||||
);
|
||||
}
|
||||
|
||||
const exportsText = buildAgentEnvExports({
|
||||
apiBase: ctx.api.apiBase,
|
||||
companyId: agentRow.companyId,
|
||||
agentId: agentRow.id,
|
||||
apiKey: key.token,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(
|
||||
{
|
||||
agent: {
|
||||
id: agentRow.id,
|
||||
name: agentRow.name,
|
||||
urlKey: agentRow.urlKey,
|
||||
companyId: agentRow.companyId,
|
||||
},
|
||||
key: {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
createdAt: key.createdAt,
|
||||
token: key.token,
|
||||
},
|
||||
skills: installSummaries,
|
||||
exports: exportsText,
|
||||
},
|
||||
{ json: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Agent: ${agentRow.name} (${agentRow.id})`);
|
||||
console.log(`API key created: ${key.name} (${key.id})`);
|
||||
if (installSummaries.length > 0) {
|
||||
for (const summary of installSummaries) {
|
||||
console.log(
|
||||
`${summary.tool}: linked=${summary.linked.length} removed=${summary.removed.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
|
||||
);
|
||||
for (const failed of summary.failed) {
|
||||
console.log(` failed ${failed.name}: ${failed.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
console.log("# Run this in your shell before launching codex/claude:");
|
||||
console.log(exportsText);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
}
|
||||
|
||||
374
cli/src/commands/client/plugin.ts
Normal file
374
cli/src/commands/client/plugin.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types mirroring server-side shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginRecord {
|
||||
id: string;
|
||||
pluginKey: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
status: string;
|
||||
displayName?: string;
|
||||
lastError?: string | null;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginListOptions extends BaseClientOptions {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface PluginInstallOptions extends BaseClientOptions {
|
||||
local?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface PluginUninstallOptions extends BaseClientOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a local path argument to an absolute path so the server can find the
|
||||
* plugin on disk regardless of where the user ran the CLI.
|
||||
*/
|
||||
function resolvePackageArg(packageArg: string, isLocal: boolean): string {
|
||||
if (!isLocal) return packageArg;
|
||||
// Already absolute
|
||||
if (path.isAbsolute(packageArg)) return packageArg;
|
||||
// Expand leading ~ to home directory
|
||||
if (packageArg.startsWith("~")) {
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
||||
}
|
||||
return path.resolve(process.cwd(), packageArg);
|
||||
}
|
||||
|
||||
function formatPlugin(p: PluginRecord): string {
|
||||
const statusColor =
|
||||
p.status === "ready"
|
||||
? pc.green(p.status)
|
||||
: p.status === "error"
|
||||
? pc.red(p.status)
|
||||
: p.status === "disabled"
|
||||
? pc.dim(p.status)
|
||||
: pc.yellow(p.status);
|
||||
|
||||
const parts = [
|
||||
`key=${pc.bold(p.pluginKey)}`,
|
||||
`status=${statusColor}`,
|
||||
`version=${p.version}`,
|
||||
`id=${pc.dim(p.id)}`,
|
||||
];
|
||||
|
||||
if (p.lastError) {
|
||||
parts.push(`error=${pc.red(p.lastError.slice(0, 80))}`);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function registerPluginCommands(program: Command): void {
|
||||
const plugin = program.command("plugin").description("Plugin lifecycle management");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin list
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("list")
|
||||
.description("List installed plugins")
|
||||
.option("--status <status>", "Filter by status (ready, error, disabled, installed, upgrade_pending)")
|
||||
.action(async (opts: PluginListOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const qs = opts.status ? `?status=${encodeURIComponent(opts.status)}` : "";
|
||||
const plugins = await ctx.api.get<PluginRecord[]>(`/api/plugins${qs}`);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(plugins, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = plugins ?? [];
|
||||
if (rows.length === 0) {
|
||||
console.log(pc.dim("No plugins installed."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const p of rows) {
|
||||
console.log(formatPlugin(p));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin install <package-or-path>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("install <package>")
|
||||
.description(
|
||||
"Install a plugin from a local path or npm package.\n" +
|
||||
" Examples:\n" +
|
||||
" paperclipai plugin install ./my-plugin # local path\n" +
|
||||
" paperclipai plugin install @acme/plugin-linear # npm package\n" +
|
||||
" paperclipai plugin install @acme/plugin-linear@1.2 # pinned version",
|
||||
)
|
||||
.option("-l, --local", "Treat <package> as a local filesystem path", false)
|
||||
.option("--version <version>", "Specific npm version to install (npm packages only)")
|
||||
.action(async (packageArg: string, opts: PluginInstallOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
|
||||
// Auto-detect local paths: starts with . or / or ~ or is an absolute path
|
||||
const isLocal =
|
||||
opts.local ||
|
||||
packageArg.startsWith("./") ||
|
||||
packageArg.startsWith("../") ||
|
||||
packageArg.startsWith("/") ||
|
||||
packageArg.startsWith("~");
|
||||
|
||||
const resolvedPackage = resolvePackageArg(packageArg, isLocal);
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
isLocal
|
||||
? `Installing plugin from local path: ${resolvedPackage}`
|
||||
: `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", {
|
||||
packageName: resolvedPackage,
|
||||
version: opts.version,
|
||||
isLocalPath: isLocal,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(installedPlugin, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!installedPlugin) {
|
||||
console.log(pc.dim("Install returned no plugin record."));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
pc.green(
|
||||
`✓ Installed ${pc.bold(installedPlugin.pluginKey)} v${installedPlugin.version} (${installedPlugin.status})`,
|
||||
),
|
||||
);
|
||||
|
||||
if (installedPlugin.lastError) {
|
||||
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin uninstall <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("uninstall <pluginKey>")
|
||||
.description(
|
||||
"Uninstall a plugin by its plugin key or database ID.\n" +
|
||||
" Use --force to hard-purge all state and config.",
|
||||
)
|
||||
.option("--force", "Purge all plugin state and config (hard delete)", false)
|
||||
.action(async (pluginKey: string, opts: PluginUninstallOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const purge = opts.force === true;
|
||||
const qs = purge ? "?purge=true" : "";
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
purge
|
||||
? `Uninstalling and purging plugin: ${pluginKey}`
|
||||
: `Uninstalling plugin: ${pluginKey}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await ctx.api.delete<PluginRecord | null>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}${qs}`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.green(`✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin enable <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("enable <pluginKey>")
|
||||
.description("Enable a disabled or errored plugin")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.post<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}/enable`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.green(`✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin disable <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("disable <pluginKey>")
|
||||
.description("Disable a running plugin without uninstalling it")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.post<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}/disable`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.dim(`Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin inspect <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("inspect <pluginKey>")
|
||||
.description("Show full details for an installed plugin")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.get<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
console.log(pc.red(`Plugin not found: ${pluginKey}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(formatPlugin(result));
|
||||
if (result.lastError) {
|
||||
console.log(`\n${pc.red("Last error:")}\n${result.lastError}`);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin examples
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("examples")
|
||||
.description("List bundled example plugins available for local install")
|
||||
.action(async (opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const examples = await ctx.api.get<
|
||||
Array<{
|
||||
packageName: string;
|
||||
pluginKey: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
localPath: string;
|
||||
tag: string;
|
||||
}>
|
||||
>("/api/plugins/examples");
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(examples, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = examples ?? [];
|
||||
if (rows.length === 0) {
|
||||
console.log(pc.dim("No bundled examples available."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ex of rows) {
|
||||
console.log(
|
||||
`${pc.bold(ex.displayName)} ${pc.dim(ex.pluginKey)}\n` +
|
||||
` ${ex.description}\n` +
|
||||
` ${pc.cyan(`paperclipai plugin install ${ex.localPath}`)}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -61,6 +61,7 @@ function defaultConfig(): PaperclipConfig {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
storageCheck,
|
||||
type CheckResult,
|
||||
} from "../checks/index.js";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
|
||||
const STATUS_ICON = {
|
||||
@@ -31,6 +32,7 @@ export async function doctor(opts: {
|
||||
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
loadPaperclipEnvFile(configPath);
|
||||
const results: CheckResult[] = [];
|
||||
|
||||
// 1. Config check (must pass before others)
|
||||
@@ -64,28 +66,40 @@ export async function doctor(opts: {
|
||||
printResult(deploymentAuthResult);
|
||||
|
||||
// 3. Agent JWT check
|
||||
const jwtResult = agentJwtSecretCheck(opts.config);
|
||||
results.push(jwtResult);
|
||||
printResult(jwtResult);
|
||||
await maybeRepair(jwtResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => agentJwtSecretCheck(opts.config),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 4. Secrets adapter check
|
||||
const secretsResult = secretsCheck(config, configPath);
|
||||
results.push(secretsResult);
|
||||
printResult(secretsResult);
|
||||
await maybeRepair(secretsResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => secretsCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 5. Storage check
|
||||
const storageResult = storageCheck(config, configPath);
|
||||
results.push(storageResult);
|
||||
printResult(storageResult);
|
||||
await maybeRepair(storageResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => storageCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 6. Database check
|
||||
const dbResult = await databaseCheck(config, configPath);
|
||||
results.push(dbResult);
|
||||
printResult(dbResult);
|
||||
await maybeRepair(dbResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => databaseCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 7. LLM check
|
||||
const llmResult = await llmCheck(config);
|
||||
@@ -93,10 +107,13 @@ export async function doctor(opts: {
|
||||
printResult(llmResult);
|
||||
|
||||
// 8. Log directory check
|
||||
const logResult = logCheck(config, configPath);
|
||||
results.push(logResult);
|
||||
printResult(logResult);
|
||||
await maybeRepair(logResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => logCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 9. Port check
|
||||
const portResult = await portCheck(config);
|
||||
@@ -118,9 +135,9 @@ function printResult(result: CheckResult): void {
|
||||
async function maybeRepair(
|
||||
result: CheckResult,
|
||||
opts: { repair?: boolean; yes?: boolean },
|
||||
): Promise<void> {
|
||||
if (result.status === "pass" || !result.canRepair || !result.repair) return;
|
||||
if (!opts.repair) return;
|
||||
): Promise<boolean> {
|
||||
if (result.status === "pass" || !result.canRepair || !result.repair) return false;
|
||||
if (!opts.repair) return false;
|
||||
|
||||
let shouldRepair = opts.yes;
|
||||
if (!shouldRepair) {
|
||||
@@ -128,7 +145,7 @@ async function maybeRepair(
|
||||
message: `Repair "${result.name}"?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (p.isCancel(answer)) return;
|
||||
if (p.isCancel(answer)) return false;
|
||||
shouldRepair = answer;
|
||||
}
|
||||
|
||||
@@ -136,10 +153,30 @@ async function maybeRepair(
|
||||
try {
|
||||
await result.repair();
|
||||
p.log.success(`Repaired: ${result.name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runRepairableCheck(input: {
|
||||
run: () => CheckResult | Promise<CheckResult>;
|
||||
configPath: string;
|
||||
opts: { repair?: boolean; yes?: boolean };
|
||||
}): Promise<CheckResult> {
|
||||
let result = await input.run();
|
||||
printResult(result);
|
||||
|
||||
const repaired = await maybeRepair(result, input.opts);
|
||||
if (!repaired) return result;
|
||||
|
||||
// Repairs may create/update the adjacent .env file or other local resources.
|
||||
loadPaperclipEnvFile(input.configPath);
|
||||
result = await input.run();
|
||||
printResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } {
|
||||
|
||||
@@ -185,6 +185,7 @@ function quickstartDefaultsFromEnv(): {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: authBaseUrlMode,
|
||||
disableSignUp: false,
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
},
|
||||
storage: {
|
||||
@@ -228,6 +229,10 @@ function quickstartDefaultsFromEnv(): {
|
||||
return { defaults, usedEnvKeys, ignoredEnvKeys };
|
||||
}
|
||||
|
||||
function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "database" | "server">): boolean {
|
||||
return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres";
|
||||
}
|
||||
|
||||
export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
|
||||
@@ -449,7 +454,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
"Next commands",
|
||||
);
|
||||
|
||||
if (server.deploymentMode === "authenticated") {
|
||||
if (canCreateBootstrapInviteImmediately({ database, server })) {
|
||||
p.log.step("Generating bootstrap CEO invite");
|
||||
await bootstrapCeoInvite({ config: configPath });
|
||||
}
|
||||
@@ -472,5 +477,15 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") {
|
||||
p.log.info(
|
||||
[
|
||||
"Bootstrap CEO invite will be created after the server starts.",
|
||||
`Next: ${pc.cyan("paperclipai run")}`,
|
||||
`Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
p.outro("You're all set!");
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
|
||||
import { onboard } from "./onboard.js";
|
||||
import { doctor } from "./doctor.js";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { configExists, resolveConfigPath } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { readConfig } from "../config/store.js";
|
||||
import {
|
||||
describeLocalInstancePaths,
|
||||
resolvePaperclipHomeDir,
|
||||
@@ -19,6 +23,13 @@ interface RunOptions {
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
interface StartedServer {
|
||||
apiUrl: string;
|
||||
databaseUrl: string;
|
||||
host: string;
|
||||
listenPort: number;
|
||||
}
|
||||
|
||||
export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
const instanceId = resolvePaperclipInstanceId(opts.instance);
|
||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
||||
@@ -31,6 +42,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
loadPaperclipEnvFile(configPath);
|
||||
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
|
||||
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
|
||||
@@ -60,8 +72,41 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = readConfig(configPath);
|
||||
if (!config) {
|
||||
p.log.error(`No config found at ${configPath}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
p.log.step("Starting Paperclip server...");
|
||||
await importServerEntry();
|
||||
const startedServer = await importServerEntry();
|
||||
|
||||
if (shouldGenerateBootstrapInviteAfterStart(config)) {
|
||||
p.log.step("Generating bootstrap CEO invite");
|
||||
await bootstrapCeoInvite({
|
||||
config: configPath,
|
||||
dbUrl: startedServer.databaseUrl,
|
||||
baseUrl: resolveBootstrapInviteBaseUrl(config, startedServer),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBootstrapInviteBaseUrl(
|
||||
config: PaperclipConfig,
|
||||
startedServer: StartedServer,
|
||||
): string {
|
||||
const explicitBaseUrl =
|
||||
process.env.PAPERCLIP_PUBLIC_URL ??
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
|
||||
process.env.BETTER_AUTH_URL ??
|
||||
process.env.BETTER_AUTH_BASE_URL ??
|
||||
(config.auth.baseUrlMode === "explicit" ? config.auth.publicBaseUrl : undefined);
|
||||
|
||||
if (typeof explicitBaseUrl === "string" && explicitBaseUrl.trim().length > 0) {
|
||||
return explicitBaseUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
return startedServer.apiUrl.replace(/\/api$/, "");
|
||||
}
|
||||
|
||||
function formatError(err: unknown): string {
|
||||
@@ -101,19 +146,20 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function importServerEntry(): Promise<void> {
|
||||
async function importServerEntry(): Promise<StartedServer> {
|
||||
// Dev mode: try local workspace path (monorepo with tsx)
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
|
||||
if (fs.existsSync(devEntry)) {
|
||||
maybeEnableUiDevMiddleware(devEntry);
|
||||
await import(pathToFileURL(devEntry).href);
|
||||
return;
|
||||
const mod = await import(pathToFileURL(devEntry).href);
|
||||
return await startServerFromModule(mod, devEntry);
|
||||
}
|
||||
|
||||
// Production mode: import the published @paperclipai/server package
|
||||
try {
|
||||
await import("@paperclipai/server");
|
||||
const mod = await import("@paperclipai/server");
|
||||
return await startServerFromModule(mod, "@paperclipai/server");
|
||||
} catch (err) {
|
||||
const missingSpecifier = getMissingModuleSpecifier(err);
|
||||
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
|
||||
@@ -130,3 +176,15 @@ async function importServerEntry(): Promise<void> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldGenerateBootstrapInviteAfterStart(config: PaperclipConfig): boolean {
|
||||
return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres";
|
||||
}
|
||||
|
||||
async function startServerFromModule(mod: unknown, label: string): Promise<StartedServer> {
|
||||
const startServer = (mod as { startServer?: () => Promise<StartedServer> }).startServer;
|
||||
if (typeof startServer !== "function") {
|
||||
throw new Error(`Paperclip server entrypoint did not export startServer(): ${label}`);
|
||||
}
|
||||
return await startServer();
|
||||
}
|
||||
|
||||
274
cli/src/commands/worktree-lib.ts
Normal file
274
cli/src/commands/worktree-lib.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { expandHomePrefix } from "../config/home.js";
|
||||
|
||||
export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees";
|
||||
export const WORKTREE_SEED_MODES = ["minimal", "full"] as const;
|
||||
|
||||
export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number];
|
||||
|
||||
export type WorktreeSeedPlan = {
|
||||
mode: WorktreeSeedMode;
|
||||
excludedTables: string[];
|
||||
nullifyColumns: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const MINIMAL_WORKTREE_EXCLUDED_TABLES = [
|
||||
"activity_log",
|
||||
"agent_runtime_state",
|
||||
"agent_task_sessions",
|
||||
"agent_wakeup_requests",
|
||||
"cost_events",
|
||||
"heartbeat_run_events",
|
||||
"heartbeat_runs",
|
||||
"workspace_runtime_services",
|
||||
];
|
||||
|
||||
const MINIMAL_WORKTREE_NULLIFIED_COLUMNS: Record<string, string[]> = {
|
||||
issues: ["checkout_run_id", "execution_run_id"],
|
||||
};
|
||||
|
||||
export type WorktreeLocalPaths = {
|
||||
cwd: string;
|
||||
repoConfigDir: string;
|
||||
configPath: string;
|
||||
envPath: string;
|
||||
homeDir: string;
|
||||
instanceId: string;
|
||||
instanceRoot: string;
|
||||
contextPath: string;
|
||||
embeddedPostgresDataDir: string;
|
||||
backupDir: string;
|
||||
logDir: string;
|
||||
secretsKeyFilePath: string;
|
||||
storageDir: string;
|
||||
};
|
||||
|
||||
export type WorktreeUiBranding = {
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
|
||||
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPlan {
|
||||
if (mode === "full") {
|
||||
return {
|
||||
mode,
|
||||
excludedTables: [],
|
||||
nullifyColumns: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
excludedTables: [...MINIMAL_WORKTREE_EXCLUDED_TABLES],
|
||||
nullifyColumns: {
|
||||
...MINIMAL_WORKTREE_NULLIFIED_COLUMNS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const value = hostname.trim().toLowerCase();
|
||||
return value === "127.0.0.1" || value === "localhost" || value === "::1";
|
||||
}
|
||||
|
||||
export function sanitizeWorktreeInstanceId(rawValue: string): string {
|
||||
const trimmed = rawValue.trim().toLowerCase();
|
||||
const normalized = trimmed
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-_]+|[-_]+$/g, "");
|
||||
return normalized || "worktree";
|
||||
}
|
||||
|
||||
export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string): string {
|
||||
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
|
||||
}
|
||||
|
||||
function hslComponentToHex(n: number): string {
|
||||
return Math.round(Math.max(0, Math.min(255, n)))
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
}
|
||||
|
||||
function hslToHex(hue: number, saturation: number, lightness: number): string {
|
||||
const s = Math.max(0, Math.min(100, saturation)) / 100;
|
||||
const l = Math.max(0, Math.min(100, lightness)) / 100;
|
||||
const c = (1 - Math.abs((2 * l) - 1)) * s;
|
||||
const h = ((hue % 360) + 360) % 360;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - (c / 2);
|
||||
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
|
||||
if (h < 60) {
|
||||
r = c;
|
||||
g = x;
|
||||
} else if (h < 120) {
|
||||
r = x;
|
||||
g = c;
|
||||
} else if (h < 180) {
|
||||
g = c;
|
||||
b = x;
|
||||
} else if (h < 240) {
|
||||
g = x;
|
||||
b = c;
|
||||
} else if (h < 300) {
|
||||
r = x;
|
||||
b = c;
|
||||
} else {
|
||||
r = c;
|
||||
b = x;
|
||||
}
|
||||
|
||||
return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`;
|
||||
}
|
||||
|
||||
export function generateWorktreeColor(): string {
|
||||
return hslToHex(randomInt(0, 360), 68, 56);
|
||||
}
|
||||
|
||||
export function resolveWorktreeLocalPaths(opts: {
|
||||
cwd: string;
|
||||
homeDir?: string;
|
||||
instanceId: string;
|
||||
}): WorktreeLocalPaths {
|
||||
const cwd = path.resolve(opts.cwd);
|
||||
const homeDir = path.resolve(expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME));
|
||||
const instanceRoot = path.resolve(homeDir, "instances", opts.instanceId);
|
||||
const repoConfigDir = path.resolve(cwd, ".paperclip");
|
||||
return {
|
||||
cwd,
|
||||
repoConfigDir,
|
||||
configPath: path.resolve(repoConfigDir, "config.json"),
|
||||
envPath: path.resolve(repoConfigDir, ".env"),
|
||||
homeDir,
|
||||
instanceId: opts.instanceId,
|
||||
instanceRoot,
|
||||
contextPath: path.resolve(homeDir, "context.json"),
|
||||
embeddedPostgresDataDir: path.resolve(instanceRoot, "db"),
|
||||
backupDir: path.resolve(instanceRoot, "data", "backups"),
|
||||
logDir: path.resolve(instanceRoot, "logs"),
|
||||
secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"),
|
||||
storageDir: path.resolve(instanceRoot, "data", "storage"),
|
||||
};
|
||||
}
|
||||
|
||||
export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
|
||||
if (!rawUrl) return undefined;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
|
||||
parsed.port = String(port);
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorktreeConfig(input: {
|
||||
sourceConfig: PaperclipConfig | null;
|
||||
paths: WorktreeLocalPaths;
|
||||
serverPort: number;
|
||||
databasePort: number;
|
||||
now?: Date;
|
||||
}): PaperclipConfig {
|
||||
const { sourceConfig, paths, serverPort, databasePort } = input;
|
||||
const nowIso = (input.now ?? new Date()).toISOString();
|
||||
|
||||
const source = sourceConfig;
|
||||
const authPublicBaseUrl = rewriteLocalUrlPort(source?.auth.publicBaseUrl, serverPort);
|
||||
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: nowIso,
|
||||
source: "configure",
|
||||
},
|
||||
...(source?.llm ? { llm: source.llm } : {}),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: paths.embeddedPostgresDataDir,
|
||||
embeddedPostgresPort: databasePort,
|
||||
backup: {
|
||||
enabled: source?.database.backup.enabled ?? true,
|
||||
intervalMinutes: source?.database.backup.intervalMinutes ?? 60,
|
||||
retentionDays: source?.database.backup.retentionDays ?? 30,
|
||||
dir: paths.backupDir,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: source?.logging.mode ?? "file",
|
||||
logDir: paths.logDir,
|
||||
},
|
||||
server: {
|
||||
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
|
||||
exposure: source?.server.exposure ?? "private",
|
||||
host: source?.server.host ?? "127.0.0.1",
|
||||
port: serverPort,
|
||||
allowedHostnames: source?.server.allowedHostnames ?? [],
|
||||
serveUi: source?.server.serveUi ?? true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: source?.auth.baseUrlMode ?? "auto",
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
disableSignUp: source?.auth.disableSignUp ?? false,
|
||||
},
|
||||
storage: {
|
||||
provider: source?.storage.provider ?? "local_disk",
|
||||
localDisk: {
|
||||
baseDir: paths.storageDir,
|
||||
},
|
||||
s3: {
|
||||
bucket: source?.storage.s3.bucket ?? "paperclip",
|
||||
region: source?.storage.s3.region ?? "us-east-1",
|
||||
endpoint: source?.storage.s3.endpoint,
|
||||
prefix: source?.storage.s3.prefix ?? "",
|
||||
forcePathStyle: source?.storage.s3.forcePathStyle ?? false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: source?.secrets.provider ?? "local_encrypted",
|
||||
strictMode: source?.secrets.strictMode ?? false,
|
||||
localEncrypted: {
|
||||
keyFilePath: paths.secretsKeyFilePath,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorktreeEnvEntries(
|
||||
paths: WorktreeLocalPaths,
|
||||
branding?: WorktreeUiBranding,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
PAPERCLIP_HOME: paths.homeDir,
|
||||
PAPERCLIP_INSTANCE_ID: paths.instanceId,
|
||||
PAPERCLIP_CONFIG: paths.configPath,
|
||||
PAPERCLIP_CONTEXT: paths.contextPath,
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}),
|
||||
...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function shellEscape(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
export function formatShellExports(entries: Record<string, string>): string {
|
||||
return Object.entries(entries)
|
||||
.filter(([, value]) => typeof value === "string" && value.trim().length > 0)
|
||||
.map(([key, value]) => `export ${key}=${shellEscape(value)}`)
|
||||
.join("\n");
|
||||
}
|
||||
1125
cli/src/commands/worktree.ts
Normal file
1125
cli/src/commands/worktree.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,20 +22,35 @@ function parseEnvFile(contents: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatEnvValue(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function renderEnvFile(entries: Record<string, string>) {
|
||||
const lines = [
|
||||
"# Paperclip environment variables",
|
||||
"# Generated by `paperclipai onboard`",
|
||||
...Object.entries(entries).map(([key, value]) => `${key}=${value}`),
|
||||
"# Generated by Paperclip CLI commands",
|
||||
...Object.entries(entries).map(([key, value]) => `${key}=${formatEnvValue(value)}`),
|
||||
"",
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function resolvePaperclipEnvFile(configPath?: string): string {
|
||||
return resolveEnvFilePath(configPath);
|
||||
}
|
||||
|
||||
export function resolveAgentJwtEnvFile(configPath?: string): string {
|
||||
return resolveEnvFilePath(configPath);
|
||||
}
|
||||
|
||||
export function loadPaperclipEnvFile(configPath?: string): void {
|
||||
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
|
||||
}
|
||||
|
||||
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
|
||||
if (loadedEnvFiles.has(filePath)) return;
|
||||
|
||||
@@ -78,13 +93,33 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre
|
||||
}
|
||||
|
||||
export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void {
|
||||
mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath);
|
||||
}
|
||||
|
||||
export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record<string, string> {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
return parseEnvFile(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
export function writePaperclipEnvEntries(entries: Record<string, string>, filePath = resolveEnvFilePath()): void {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const current = fs.existsSync(filePath) ? parseEnvFile(fs.readFileSync(filePath, "utf-8")) : {};
|
||||
current[JWT_SECRET_ENV_KEY] = secret;
|
||||
|
||||
fs.writeFileSync(filePath, renderEnvFile(current), {
|
||||
fs.writeFileSync(filePath, renderEnvFile(entries), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export function mergePaperclipEnvEntries(
|
||||
entries: Record<string, string>,
|
||||
filePath = resolveEnvFilePath(),
|
||||
): Record<string, string> {
|
||||
const current = readPaperclipEnvEntries(filePath);
|
||||
const next = {
|
||||
...current,
|
||||
...Object.fromEntries(
|
||||
Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0),
|
||||
),
|
||||
};
|
||||
writePaperclipEnvEntries(next, filePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import { registerApprovalCommands } from "./commands/client/approval.js";
|
||||
import { registerActivityCommands } from "./commands/client/activity.js";
|
||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
@@ -33,6 +36,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
hasConfigOption: optionNames.has("config"),
|
||||
hasContextOption: optionNames.has("context"),
|
||||
});
|
||||
loadPaperclipEnvFile(options.config);
|
||||
});
|
||||
|
||||
program
|
||||
@@ -132,6 +136,8 @@ registerAgentCommands(program);
|
||||
registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function promptServer(opts?: {
|
||||
}
|
||||
|
||||
const port = Number(portStr) || 3100;
|
||||
let auth: AuthConfig = { baseUrlMode: "auto" };
|
||||
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
|
||||
if (deploymentMode === "authenticated" && exposure === "public") {
|
||||
const urlInput = await p.text({
|
||||
message: "Public base URL",
|
||||
@@ -139,11 +139,13 @@ export async function promptServer(opts?: {
|
||||
}
|
||||
auth = {
|
||||
baseUrlMode: "explicit",
|
||||
disableSignUp: false,
|
||||
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
|
||||
};
|
||||
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
|
||||
auth = {
|
||||
baseUrlMode: "explicit",
|
||||
disableSignUp: false,
|
||||
publicBaseUrl: currentAuth.publicBaseUrl,
|
||||
};
|
||||
}
|
||||
@@ -160,4 +162,3 @@ export async function promptServer(opts?: {
|
||||
auth,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
Reference in New Issue
Block a user