mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Add default space compatibility migration
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9b130d4f78
commit
11dc32fd29
27 files changed
+984
-185
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -16,13 +17,15 @@ describe("home path resolution", () => {
|
||||
});
|
||||
|
||||
it("defaults to ~/.paperclip and default instance", () => {
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-home-paths-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
|
||||
const paths = describeLocalInstancePaths();
|
||||
expect(paths.homeDir).toBe(path.resolve(os.homedir(), ".paperclip"));
|
||||
expect(paths.homeDir).toBe(home);
|
||||
expect(paths.instanceId).toBe("default");
|
||||
expect(paths.configPath).toBe(path.resolve(os.homedir(), ".paperclip", "instances", "default", "config.json"));
|
||||
expect(paths.activeSpaceId).toBe("default");
|
||||
expect(paths.configPath).toBe(path.resolve(home, "instances", "default", "spaces", "default", "config.json"));
|
||||
});
|
||||
|
||||
it("supports PAPERCLIP_HOME and explicit instance ids", () => {
|
||||
@@ -34,7 +37,7 @@ describe("home path resolution", () => {
|
||||
});
|
||||
|
||||
it("rejects invalid instance ids", () => {
|
||||
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid instance id/);
|
||||
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid PAPERCLIP_INSTANCE_ID/);
|
||||
});
|
||||
|
||||
it("expands ~ prefixes", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { onboard } from "../commands/onboard.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
|
||||
function createExistingConfigFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
|
||||
@@ -85,10 +86,19 @@ describe("onboard", () => {
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_SPACE_ID;
|
||||
delete process.env.PAPERCLIP_BIND;
|
||||
delete process.env.PAPERCLIP_BIND_HOST;
|
||||
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
||||
delete process.env.HOST;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
});
|
||||
|
||||
it("preserves an existing config when rerun without flags", async () => {
|
||||
@@ -125,6 +135,35 @@ describe("onboard", () => {
|
||||
expect(raw.server.host).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("creates default-space config and data paths for a fresh PAPERCLIP_HOME", async () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-space-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-cwd-"));
|
||||
process.chdir(cwd);
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
|
||||
await onboard({ yes: true, invokedByRun: true });
|
||||
|
||||
const instanceRoot = path.join(home, "instances", "default");
|
||||
const spaceRoot = path.join(instanceRoot, "spaces", "default");
|
||||
const configPath = path.join(spaceRoot, "config.json");
|
||||
const registryPath = path.join(instanceRoot, "config.json");
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||
const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")) as { activeSpaceId?: string };
|
||||
|
||||
expect(registry.activeSpaceId).toBe("default");
|
||||
expect(raw.database.embeddedPostgresDataDir).toBe(path.join(spaceRoot, "db"));
|
||||
expect(raw.database.backup.dir).toBe(path.join(spaceRoot, "data", "backups"));
|
||||
expect(raw.logging.logDir).toBe(path.join(spaceRoot, "logs"));
|
||||
expect(raw.storage.localDisk.baseDir).toBe(path.join(spaceRoot, "data", "storage"));
|
||||
expect(raw.secrets.localEncrypted.keyFilePath).toBe(path.join(spaceRoot, "secrets", "master.key"));
|
||||
expect(fs.existsSync(path.join(spaceRoot, ".env"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(spaceRoot, "secrets", "master.key"))).toBe(true);
|
||||
|
||||
for (const rootName of ["db", "data", "secrets", "logs", "workspaces", "projects", "companies"]) {
|
||||
expect(fs.existsSync(path.join(instanceRoot, rootName))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("supports authenticated/private quickstart bind presets", async () => {
|
||||
const configPath = createFreshConfigPath();
|
||||
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { migrateDefaultSpaceInstall } from "../commands/spaces.js";
|
||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
|
||||
function writeJson(filePath: string, value: unknown): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
function writeText(filePath: string, value: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, value);
|
||||
}
|
||||
|
||||
function createLegacyInstallFixture() {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-space-migrate-home-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-space-migrate-cwd-"));
|
||||
process.chdir(cwd);
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_SPACE_ID;
|
||||
|
||||
const instanceRoot = path.join(home, "instances", "default");
|
||||
const spaceRoot = path.join(instanceRoot, "spaces", "default");
|
||||
const config: PaperclipConfig = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-05-09T00:00:00.000Z",
|
||||
source: "onboard",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(instanceRoot, "data", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(instanceRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(instanceRoot, "data", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(instanceRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
writeJson(path.join(instanceRoot, "config.json"), config);
|
||||
writeText(path.join(instanceRoot, ".env"), "PAPERCLIP_AGENT_JWT_SECRET=test-secret\n");
|
||||
writeText(path.join(instanceRoot, "db", "PG_VERSION"), "17\n");
|
||||
writeText(path.join(instanceRoot, "data", "storage", "hello.txt"), "hello\n");
|
||||
writeText(path.join(instanceRoot, "logs", "server.log"), "log\n");
|
||||
writeText(path.join(instanceRoot, "secrets", "master.key"), "01234567890123456789012345678901");
|
||||
writeText(path.join(instanceRoot, "workspaces", "agent-1", "README.md"), "workspace\n");
|
||||
writeText(path.join(instanceRoot, "projects", "company", "project", "repo", "README.md"), "project\n");
|
||||
writeText(path.join(instanceRoot, "companies", "company-1", "codex-home", "config.toml"), "model=\"x\"\n");
|
||||
writeText(path.join(instanceRoot, "codex-home", "config.toml"), "model=\"shared\"\n");
|
||||
|
||||
return { instanceRoot, spaceRoot };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
});
|
||||
|
||||
describe("spaces migrate-default", () => {
|
||||
it("moves a legacy root-shaped install into spaces/default and rewrites config paths", async () => {
|
||||
const fixture = createLegacyInstallFixture();
|
||||
|
||||
const result = await migrateDefaultSpaceInstall({
|
||||
serverRunningCheck: async () => false,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("migrated");
|
||||
expect(result.movedPaths).toEqual([
|
||||
"config.json",
|
||||
".env",
|
||||
"db",
|
||||
"data",
|
||||
"logs",
|
||||
"secrets",
|
||||
"workspaces",
|
||||
"projects",
|
||||
"companies",
|
||||
"codex-home",
|
||||
]);
|
||||
|
||||
expect(fs.existsSync(path.join(fixture.instanceRoot, "db"))).toBe(false);
|
||||
expect(fs.readFileSync(path.join(fixture.spaceRoot, ".env"), "utf8")).toContain("PAPERCLIP_AGENT_JWT_SECRET");
|
||||
expect(fs.readFileSync(path.join(fixture.spaceRoot, "db", "PG_VERSION"), "utf8")).toBe("17\n");
|
||||
expect(fs.readFileSync(path.join(fixture.spaceRoot, "data", "storage", "hello.txt"), "utf8")).toBe("hello\n");
|
||||
expect(fs.readFileSync(path.join(fixture.spaceRoot, "companies", "company-1", "codex-home", "config.toml"), "utf8")).toContain("model");
|
||||
expect(fs.readFileSync(path.join(fixture.spaceRoot, "codex-home", "config.toml"), "utf8")).toContain("shared");
|
||||
|
||||
const migratedConfig = JSON.parse(fs.readFileSync(path.join(fixture.spaceRoot, "config.json"), "utf8")) as PaperclipConfig;
|
||||
expect(migratedConfig.database.embeddedPostgresDataDir).toBe(path.join(fixture.spaceRoot, "db"));
|
||||
expect(migratedConfig.database.backup.dir).toBe(path.join(fixture.spaceRoot, "data", "backups"));
|
||||
expect(migratedConfig.logging.logDir).toBe(path.join(fixture.spaceRoot, "logs"));
|
||||
expect(migratedConfig.storage.localDisk.baseDir).toBe(path.join(fixture.spaceRoot, "data", "storage"));
|
||||
expect(migratedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(fixture.spaceRoot, "secrets", "master.key"));
|
||||
|
||||
const marker = JSON.parse(fs.readFileSync(path.join(fixture.instanceRoot, "config.json"), "utf8")) as {
|
||||
activeSpaceId?: string;
|
||||
defaultSpaceMigration?: { sourceRoot?: string; destinationRoot?: string; movedPaths?: string[] };
|
||||
};
|
||||
expect(marker.activeSpaceId).toBe("default");
|
||||
expect(marker.defaultSpaceMigration).toMatchObject({
|
||||
sourceRoot: fixture.instanceRoot,
|
||||
destinationRoot: fixture.spaceRoot,
|
||||
movedPaths: result.movedPaths,
|
||||
});
|
||||
|
||||
expect(resolveConfigPath()).toBe(path.join(fixture.spaceRoot, "config.json"));
|
||||
expect(readConfig()?.database.embeddedPostgresDataDir).toBe(path.join(fixture.spaceRoot, "db"));
|
||||
});
|
||||
|
||||
it("refuses to merge when destination data already exists", async () => {
|
||||
const fixture = createLegacyInstallFixture();
|
||||
writeText(path.join(fixture.spaceRoot, "db", "PG_VERSION"), "existing\n");
|
||||
|
||||
await expect(migrateDefaultSpaceInstall({
|
||||
serverRunningCheck: async () => false,
|
||||
})).rejects.toThrow(/destination paths already exist:[\s\S]*db/);
|
||||
|
||||
expect(fs.readFileSync(path.join(fixture.instanceRoot, "db", "PG_VERSION"), "utf8")).toBe("17\n");
|
||||
expect(fs.readFileSync(path.join(fixture.spaceRoot, "db", "PG_VERSION"), "utf8")).toBe("existing\n");
|
||||
});
|
||||
|
||||
it("refuses to migrate when the server appears to be running", async () => {
|
||||
const fixture = createLegacyInstallFixture();
|
||||
|
||||
await expect(migrateDefaultSpaceInstall({
|
||||
serverRunningCheck: async () => true,
|
||||
})).rejects.toThrow(/server appears to be running/);
|
||||
|
||||
expect(fs.existsSync(path.join(fixture.instanceRoot, "config.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(fixture.spaceRoot, "config.json"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveDefaultLogsDir,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
import { ensureDefaultSpaceRegistry } from "@paperclipai/shared/space-paths";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
|
||||
type Section = "llm" | "database" | "logging" | "server" | "storage" | "secrets";
|
||||
@@ -177,6 +178,9 @@ export async function configure(opts: {
|
||||
config.$meta.updatedAt = new Date().toISOString();
|
||||
config.$meta.source = "configure";
|
||||
|
||||
if (configPath === resolveConfigPath()) {
|
||||
ensureDefaultSpaceRegistry({ source: "configure" });
|
||||
}
|
||||
writeConfig(config, opts.config);
|
||||
p.log.success(`${SECTION_LABELS[section]} configuration updated.`);
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
resolveDefaultLogsDir,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
import { ensureDefaultSpaceRegistry } from "@paperclipai/shared/space-paths";
|
||||
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
import {
|
||||
@@ -333,7 +334,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
const instance = describeLocalInstancePaths(resolvePaperclipInstanceId());
|
||||
p.log.message(
|
||||
pc.dim(
|
||||
`Local home: ${instance.homeDir} | instance: ${instance.instanceId} | config: ${configPath}`,
|
||||
`Local home: ${instance.homeDir} | instance: ${instance.instanceId} | space: ${instance.activeSpaceId} | config: ${configPath}`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -625,6 +626,10 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`));
|
||||
}
|
||||
|
||||
if (configPath === resolveConfigPath()) {
|
||||
ensureDefaultSpaceRegistry({ source: "onboard" });
|
||||
}
|
||||
|
||||
writeConfig(config, opts.config);
|
||||
|
||||
if (tc) trackInstallCompleted(tc, {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
DEFAULT_PAPERCLIP_SPACE_ID,
|
||||
DEFAULT_SPACE_ADAPTER_LOCAL_PATH_NAMES,
|
||||
DEFAULT_SPACE_OWNED_PATH_NAMES,
|
||||
createDefaultSpaceRegistry,
|
||||
expandHomePrefix,
|
||||
isPaperclipRuntimeConfig,
|
||||
resolvePaperclipInstanceConfigPath,
|
||||
resolvePaperclipInstanceId,
|
||||
resolvePaperclipInstanceRoot,
|
||||
resolvePaperclipSpacesRoot,
|
||||
} from "@paperclipai/shared/space-paths";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export interface DefaultSpaceMigrationPlan {
|
||||
instanceId: string;
|
||||
sourceRoot: string;
|
||||
destinationRoot: string;
|
||||
legacyRuntimeConfig: boolean;
|
||||
sourcePathNames: string[];
|
||||
conflicts: Array<{ pathName: string; sourcePath: string; destinationPath: string }>;
|
||||
}
|
||||
|
||||
export interface DefaultSpaceMigrationResult {
|
||||
status: "migrated" | "noop" | "dry_run";
|
||||
plan: DefaultSpaceMigrationPlan;
|
||||
movedPaths: string[];
|
||||
markerPath: string | null;
|
||||
}
|
||||
|
||||
export interface MigrateDefaultSpaceOptions {
|
||||
instanceId?: string;
|
||||
dryRun?: boolean;
|
||||
skipServerCheck?: boolean;
|
||||
serverRunningCheck?: (plan: DefaultSpaceMigrationPlan) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const MIGRATION_PATH_NAMES = [
|
||||
...DEFAULT_SPACE_OWNED_PATH_NAMES,
|
||||
...DEFAULT_SPACE_ADAPTER_LOCAL_PATH_NAMES,
|
||||
] as const;
|
||||
|
||||
function readJsonIfPresent(filePath: string): unknown | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isPathWithin(rootPath: string, candidatePath: string): boolean {
|
||||
const root = path.resolve(rootPath);
|
||||
const candidate = path.resolve(candidatePath);
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function rewriteSpaceRootString(value: string, sourceRoot: string, destinationRoot: string): string {
|
||||
const resolved = path.resolve(expandHomePrefix(value));
|
||||
if (!isPathWithin(sourceRoot, resolved)) return value;
|
||||
const relative = path.relative(path.resolve(sourceRoot), resolved);
|
||||
return path.resolve(destinationRoot, relative);
|
||||
}
|
||||
|
||||
function rewriteConfigPaths(value: unknown, sourceRoot: string, destinationRoot: string): unknown {
|
||||
if (typeof value === "string") return rewriteSpaceRootString(value, sourceRoot, destinationRoot);
|
||||
if (Array.isArray(value)) return value.map((entry) => rewriteConfigPaths(entry, sourceRoot, destinationRoot));
|
||||
if (!isObject(value)) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, rewriteConfigPaths(entry, sourceRoot, destinationRoot)]),
|
||||
);
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, value: unknown, mode = 0o600): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode });
|
||||
}
|
||||
|
||||
function atomicWriteJson(filePath: string, value: unknown, mode = 0o600): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tmpPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.tmp`);
|
||||
writeJson(tmpPath, value, mode);
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
}
|
||||
|
||||
export function buildDefaultSpaceMigrationPlan(options: Pick<MigrateDefaultSpaceOptions, "instanceId"> = {}): DefaultSpaceMigrationPlan {
|
||||
const instanceId = resolvePaperclipInstanceId(options.instanceId);
|
||||
const sourceRoot = resolvePaperclipInstanceRoot({ instanceId });
|
||||
const destinationRoot = path.resolve(resolvePaperclipSpacesRoot({ instanceId }), DEFAULT_PAPERCLIP_SPACE_ID);
|
||||
const instanceConfigPath = resolvePaperclipInstanceConfigPath({ instanceId });
|
||||
const legacyRuntimeConfig = isPaperclipRuntimeConfig(readJsonIfPresent(instanceConfigPath));
|
||||
|
||||
const sourcePathNames = MIGRATION_PATH_NAMES.filter((pathName) => {
|
||||
if (pathName === "config.json" && !legacyRuntimeConfig) return false;
|
||||
return fs.existsSync(path.join(sourceRoot, pathName));
|
||||
});
|
||||
|
||||
const conflicts = sourcePathNames
|
||||
.map((pathName) => ({
|
||||
pathName,
|
||||
sourcePath: path.join(sourceRoot, pathName),
|
||||
destinationPath: path.join(destinationRoot, pathName),
|
||||
}))
|
||||
.filter((entry) => fs.existsSync(entry.destinationPath));
|
||||
|
||||
return {
|
||||
instanceId,
|
||||
sourceRoot,
|
||||
destinationRoot,
|
||||
legacyRuntimeConfig,
|
||||
sourcePathNames,
|
||||
conflicts,
|
||||
};
|
||||
}
|
||||
|
||||
function readServerEndpointFromConfig(configPath: string): { host: string; port: number } | null {
|
||||
const parsed = readJsonIfPresent(configPath);
|
||||
if (!isObject(parsed)) return null;
|
||||
const server = parsed.server;
|
||||
if (!isObject(server)) return null;
|
||||
const rawHost = typeof server.host === "string" && server.host.trim().length > 0
|
||||
? server.host.trim()
|
||||
: "127.0.0.1";
|
||||
const host = rawHost === "0.0.0.0" || rawHost === "::" ? "127.0.0.1" : rawHost;
|
||||
const port = typeof server.port === "number" && Number.isInteger(server.port) ? server.port : 3100;
|
||||
if (port <= 0 || port > 65535) return null;
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
export async function defaultServerRunningCheck(plan: DefaultSpaceMigrationPlan): Promise<boolean> {
|
||||
const endpoint = readServerEndpointFromConfig(path.join(plan.sourceRoot, "config.json"));
|
||||
if (!endpoint) return false;
|
||||
|
||||
const host = endpoint.host.includes(":") ? `[${endpoint.host}]` : endpoint.host;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 500);
|
||||
try {
|
||||
const response = await fetch(`http://${host}:${endpoint.port}/api/health`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response.status >= 200 && response.status < 500;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function assertMigrationPreflight(plan: DefaultSpaceMigrationPlan): void {
|
||||
if (plan.conflicts.length === 0) return;
|
||||
const details = plan.conflicts
|
||||
.map((conflict) => `- ${conflict.pathName}: ${conflict.sourcePath} -> ${conflict.destinationPath}`)
|
||||
.join("\n");
|
||||
throw new Error(`Cannot migrate default space because destination paths already exist:\n${details}`);
|
||||
}
|
||||
|
||||
function moveConfigWithRewrittenPaths(sourcePath: string, destinationPath: string, sourceRoot: string, destinationRoot: string): void {
|
||||
const parsed = JSON.parse(fs.readFileSync(sourcePath, "utf8")) as unknown;
|
||||
const rewritten = rewriteConfigPaths(parsed, sourceRoot, destinationRoot);
|
||||
atomicWriteJson(destinationPath, rewritten);
|
||||
fs.unlinkSync(sourcePath);
|
||||
}
|
||||
|
||||
function writeMigrationMarker(plan: DefaultSpaceMigrationPlan, movedPaths: string[]): string {
|
||||
const migratedAt = new Date().toISOString();
|
||||
const registry = createDefaultSpaceRegistry("system");
|
||||
registry.$meta.updatedAt = migratedAt;
|
||||
registry.defaultSpaceMigration = {
|
||||
migratedAt,
|
||||
sourceRoot: plan.sourceRoot,
|
||||
destinationRoot: plan.destinationRoot,
|
||||
movedPaths,
|
||||
};
|
||||
|
||||
const markerPath = path.join(plan.sourceRoot, "config.json");
|
||||
writeJson(markerPath, registry);
|
||||
return markerPath;
|
||||
}
|
||||
|
||||
export async function migrateDefaultSpaceInstall(
|
||||
options: MigrateDefaultSpaceOptions = {},
|
||||
): Promise<DefaultSpaceMigrationResult> {
|
||||
const plan = buildDefaultSpaceMigrationPlan(options);
|
||||
|
||||
if (plan.sourcePathNames.length === 0) {
|
||||
return { status: "noop", plan, movedPaths: [], markerPath: null };
|
||||
}
|
||||
|
||||
assertMigrationPreflight(plan);
|
||||
|
||||
const serverRunningCheck = options.serverRunningCheck ?? defaultServerRunningCheck;
|
||||
if (!options.skipServerCheck && await serverRunningCheck(plan)) {
|
||||
throw new Error(
|
||||
`Cannot migrate default space while a Paperclip server appears to be running for ${plan.sourceRoot}. Stop Paperclip and retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
return { status: "dry_run", plan, movedPaths: plan.sourcePathNames, markerPath: null };
|
||||
}
|
||||
|
||||
fs.mkdirSync(plan.destinationRoot, { recursive: true });
|
||||
const movedPaths: string[] = [];
|
||||
|
||||
for (const pathName of plan.sourcePathNames) {
|
||||
const sourcePath = path.join(plan.sourceRoot, pathName);
|
||||
const destinationPath = path.join(plan.destinationRoot, pathName);
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
|
||||
if (pathName === "config.json") {
|
||||
moveConfigWithRewrittenPaths(sourcePath, destinationPath, plan.sourceRoot, plan.destinationRoot);
|
||||
} else {
|
||||
fs.renameSync(sourcePath, destinationPath);
|
||||
}
|
||||
movedPaths.push(pathName);
|
||||
}
|
||||
|
||||
const markerPath = writeMigrationMarker(plan, movedPaths);
|
||||
return { status: "migrated", plan, movedPaths, markerPath };
|
||||
}
|
||||
|
||||
function printMigrationResult(result: DefaultSpaceMigrationResult): void {
|
||||
if (result.status === "noop") {
|
||||
console.log(pc.dim(`No legacy default-space data found under ${result.plan.sourceRoot}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const verb = result.status === "dry_run" ? "Would migrate" : "Migrated";
|
||||
console.log(`${pc.green(verb)} default space:`);
|
||||
console.log(` ${pc.dim("from")} ${result.plan.sourceRoot}`);
|
||||
console.log(` ${pc.dim("to")} ${result.plan.destinationRoot}`);
|
||||
for (const pathName of result.movedPaths) {
|
||||
console.log(` ${pc.dim("-")} ${pathName}`);
|
||||
}
|
||||
if (result.markerPath) {
|
||||
console.log(` ${pc.dim("marker")} ${result.markerPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSpacesCommands(program: Command): void {
|
||||
const spaces = program.command("spaces").description("Manage local Paperclip spaces");
|
||||
|
||||
spaces
|
||||
.command("migrate-default")
|
||||
.description("Offline migration from legacy root-shaped instance data into spaces/default")
|
||||
.option("-i, --instance <id>", "Local instance id (default: default)")
|
||||
.option("--dry-run", "Show the migration plan without moving files", false)
|
||||
.option("--skip-server-check", "Skip the local /api/health preflight", false)
|
||||
.action(async (opts: { instance?: string; dryRun?: boolean; skipServerCheck?: boolean }) => {
|
||||
const result = await migrateDefaultSpaceInstall({
|
||||
instanceId: opts.instance,
|
||||
dryRun: opts.dryRun,
|
||||
skipServerCheck: opts.skipServerCheck,
|
||||
});
|
||||
printMigrationResult(result);
|
||||
});
|
||||
}
|
||||
+40
-33
@@ -1,32 +1,38 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
expandHomePrefix,
|
||||
resolveDefaultBackupDir as resolveSharedDefaultBackupDir,
|
||||
resolveDefaultEmbeddedPostgresDir as resolveSharedDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultLogsDir as resolveSharedDefaultLogsDir,
|
||||
resolveDefaultSecretsKeyFilePath as resolveSharedDefaultSecretsKeyFilePath,
|
||||
resolveDefaultStorageDir as resolveSharedDefaultStorageDir,
|
||||
resolveHomeAwarePath,
|
||||
resolvePaperclipConfigPathForInstance,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
resolvePaperclipInstanceRoot as resolveSharedPaperclipInstanceRoot,
|
||||
resolvePaperclipSpaceId,
|
||||
resolvePaperclipSpaceRoot as resolveSharedPaperclipSpaceRoot,
|
||||
} from "@paperclipai/shared/space-paths";
|
||||
|
||||
const DEFAULT_INSTANCE_ID = "default";
|
||||
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export function resolvePaperclipHomeDir(): string {
|
||||
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||
return path.resolve(os.homedir(), ".paperclip");
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceId(override?: string): string {
|
||||
const raw = override?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
|
||||
if (!INSTANCE_ID_RE.test(raw)) {
|
||||
throw new Error(
|
||||
`Invalid instance id '${raw}'. Allowed characters: letters, numbers, '_' and '-'.`,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
export {
|
||||
expandHomePrefix,
|
||||
resolveHomeAwarePath,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
resolvePaperclipSpaceId,
|
||||
};
|
||||
|
||||
export function resolvePaperclipInstanceRoot(instanceId?: string): string {
|
||||
const id = resolvePaperclipInstanceId(instanceId);
|
||||
return path.resolve(resolvePaperclipHomeDir(), "instances", id);
|
||||
return resolveSharedPaperclipInstanceRoot({ instanceId });
|
||||
}
|
||||
|
||||
export function resolvePaperclipSpaceRoot(instanceId?: string): string {
|
||||
return resolveSharedPaperclipSpaceRoot({ instanceId });
|
||||
}
|
||||
|
||||
export function resolveDefaultConfigPath(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "config.json");
|
||||
return resolvePaperclipConfigPathForInstance({ instanceId });
|
||||
}
|
||||
|
||||
export function resolveDefaultContextPath(): string {
|
||||
@@ -38,38 +44,39 @@ export function resolveDefaultCliAuthPath(): string {
|
||||
}
|
||||
|
||||
export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "db");
|
||||
return resolveSharedDefaultEmbeddedPostgresDir({ instanceId });
|
||||
}
|
||||
|
||||
export function resolveDefaultLogsDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "logs");
|
||||
return resolveSharedDefaultLogsDir({ instanceId });
|
||||
}
|
||||
|
||||
export function resolveDefaultSecretsKeyFilePath(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "secrets", "master.key");
|
||||
return resolveSharedDefaultSecretsKeyFilePath({ instanceId });
|
||||
}
|
||||
|
||||
export function resolveDefaultStorageDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "storage");
|
||||
return resolveSharedDefaultStorageDir({ instanceId });
|
||||
}
|
||||
|
||||
export function resolveDefaultBackupDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "backups");
|
||||
}
|
||||
|
||||
export function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
return resolveSharedDefaultBackupDir({ instanceId });
|
||||
}
|
||||
|
||||
export function describeLocalInstancePaths(instanceId?: string) {
|
||||
const resolvedInstanceId = resolvePaperclipInstanceId(instanceId);
|
||||
const instanceRoot = resolvePaperclipInstanceRoot(resolvedInstanceId);
|
||||
const activeSpaceId = resolvePaperclipSpaceId({ instanceId: resolvedInstanceId });
|
||||
const activeSpaceRoot = resolveSharedPaperclipSpaceRoot({
|
||||
instanceId: resolvedInstanceId,
|
||||
spaceId: activeSpaceId,
|
||||
});
|
||||
return {
|
||||
homeDir: resolvePaperclipHomeDir(),
|
||||
instanceId: resolvedInstanceId,
|
||||
instanceRoot,
|
||||
activeSpaceId,
|
||||
activeSpaceRoot,
|
||||
configPath: resolveDefaultConfigPath(resolvedInstanceId),
|
||||
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(resolvedInstanceId),
|
||||
backupDir: resolveDefaultBackupDir(resolvedInstanceId),
|
||||
|
||||
@@ -24,6 +24,7 @@ import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
import { registerClientAuthCommands } from "./commands/client/auth.js";
|
||||
import { registerSpacesCommands } from "./commands/spaces.js";
|
||||
import { cliVersion } from "./version.js";
|
||||
|
||||
const program = new Command();
|
||||
@@ -150,6 +151,7 @@ registerFeedbackCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerEnvLabCommands(program);
|
||||
registerPluginCommands(program);
|
||||
registerSpacesCommands(program);
|
||||
|
||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.31.4",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"@zed-industries/codex-acp": "^0.12.0",
|
||||
"acpx": "^0.6.1",
|
||||
"picocolors": "^1.1.1"
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import { resolvePaperclipSpaceRoot } from "@paperclipai/shared/space-paths";
|
||||
import { readAdapterExecutionTarget, adapterExecutionTargetSessionIdentity } from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
@@ -114,7 +115,11 @@ function shortHash(value: unknown): string {
|
||||
function defaultPaperclipInstanceDir(): string {
|
||||
const home = process.env.PAPERCLIP_HOME?.trim() || path.join(os.homedir(), ".paperclip");
|
||||
const instanceId = process.env.PAPERCLIP_INSTANCE_ID?.trim() || "default";
|
||||
return path.join(home, "instances", instanceId);
|
||||
return resolvePaperclipSpaceRoot({
|
||||
homeDir: home,
|
||||
instanceId,
|
||||
spaceId: process.env.PAPERCLIP_SPACE_ID?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function defaultStateDir(companyId: string, agentId: string): string {
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,8 +3,8 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import { resolvePaperclipSpaceRoot } from "@paperclipai/shared/space-paths";
|
||||
|
||||
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
|
||||
const SEEDED_SHARED_FILES = [
|
||||
".credentials.json",
|
||||
"credentials.json",
|
||||
@@ -92,11 +92,14 @@ export function resolveManagedClaudeConfigSeedDir(
|
||||
env: NodeJS.ProcessEnv,
|
||||
companyId?: string,
|
||||
): string {
|
||||
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip");
|
||||
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
|
||||
const spaceRoot = resolvePaperclipSpaceRoot({
|
||||
homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
|
||||
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
|
||||
spaceId: nonEmpty(env.PAPERCLIP_SPACE_ID) ?? undefined,
|
||||
});
|
||||
return companyId
|
||||
? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-config-seed")
|
||||
: path.resolve(paperclipHome, "instances", instanceId, "claude-config-seed");
|
||||
? path.resolve(spaceRoot, "companies", companyId, "claude-config-seed")
|
||||
: path.resolve(spaceRoot, "claude-config-seed");
|
||||
}
|
||||
|
||||
export async function prepareClaudeConfigSeed(
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createHash, type Hash } from "node:crypto";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import { ensurePaperclipSkillSymlink, type PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
|
||||
import { resolvePaperclipSpaceRoot } from "@paperclipai/shared/space-paths";
|
||||
|
||||
type SkillEntry = PaperclipSkillEntry;
|
||||
|
||||
@@ -25,12 +23,13 @@ function resolveManagedClaudePromptCacheRoot(
|
||||
env: NodeJS.ProcessEnv,
|
||||
companyId: string,
|
||||
): string {
|
||||
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip");
|
||||
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
|
||||
const spaceRoot = resolvePaperclipSpaceRoot({
|
||||
homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
|
||||
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
|
||||
spaceId: nonEmpty(env.PAPERCLIP_SPACE_ID) ?? undefined,
|
||||
});
|
||||
return path.resolve(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
instanceId,
|
||||
spaceRoot,
|
||||
"companies",
|
||||
companyId,
|
||||
"claude-prompt-cache",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,11 +2,11 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import { resolvePaperclipSpaceRoot } from "@paperclipai/shared/space-paths";
|
||||
|
||||
const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;
|
||||
const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const;
|
||||
const SYMLINKED_SHARED_FILES = ["auth.json"] as const;
|
||||
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
|
||||
|
||||
function nonEmpty(value: string | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
@@ -31,11 +31,14 @@ export function resolveManagedCodexHomeDir(
|
||||
env: NodeJS.ProcessEnv,
|
||||
companyId?: string,
|
||||
): string {
|
||||
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip");
|
||||
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
|
||||
const spaceRoot = resolvePaperclipSpaceRoot({
|
||||
homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
|
||||
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
|
||||
spaceId: nonEmpty(env.PAPERCLIP_SPACE_ID) ?? undefined,
|
||||
});
|
||||
return companyId
|
||||
? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "codex-home")
|
||||
: path.resolve(paperclipHome, "instances", instanceId, "codex-home");
|
||||
? path.resolve(spaceRoot, "companies", companyId, "codex-home")
|
||||
: path.resolve(spaceRoot, "codex-home");
|
||||
}
|
||||
|
||||
async function ensureParentDir(target: string): Promise<void> {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { formatDatabaseBackupResult, runDatabaseBackup } from "./backup-lib.js";
|
||||
import {
|
||||
expandHomePrefix,
|
||||
resolveDefaultBackupDir,
|
||||
resolvePaperclipConfigPathForInstance,
|
||||
} from "@paperclipai/shared/space-paths";
|
||||
|
||||
type PartialConfig = {
|
||||
database?: {
|
||||
@@ -15,30 +19,6 @@ type PartialConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolvePaperclipHomeDir(): string {
|
||||
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||
return path.resolve(os.homedir(), ".paperclip");
|
||||
}
|
||||
|
||||
function resolvePaperclipInstanceId(): string {
|
||||
const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || "default";
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(raw)) {
|
||||
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveDefaultConfigPath(): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "config.json");
|
||||
}
|
||||
|
||||
function readConfig(configPath: string): PartialConfig | null {
|
||||
if (!existsSync(configPath)) return null;
|
||||
try {
|
||||
@@ -72,10 +52,6 @@ function resolveConnectionString(config: PartialConfig | null): string {
|
||||
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
}
|
||||
|
||||
function resolveDefaultBackupDir(): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "data", "backups");
|
||||
}
|
||||
|
||||
function resolveBackupDir(config: PartialConfig | null): string {
|
||||
const raw = config?.database?.backup?.dir;
|
||||
if (typeof raw === "string" && raw.trim().length > 0) {
|
||||
@@ -89,7 +65,7 @@ function resolveRetentionDays(config: PartialConfig | null): number {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const configPath = resolveDefaultConfigPath();
|
||||
const configPath = resolvePaperclipConfigPathForInstance();
|
||||
const config = readConfig(configPath);
|
||||
const connectionString = resolveConnectionString(config);
|
||||
const backupDir = resolveBackupDir(config);
|
||||
|
||||
@@ -105,4 +105,24 @@ describe("resolveDatabaseTarget", () => {
|
||||
source: "embedded-postgres@55444",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses spaces/default for a fresh default embedded postgres target", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-space-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-cwd-"));
|
||||
process.chdir(cwd);
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
const target = resolveDatabaseTarget();
|
||||
|
||||
expect(target).toMatchObject({
|
||||
mode: "embedded-postgres",
|
||||
dataDir: path.join(home, "instances", "default", "spaces", "default", "db"),
|
||||
port: 54329,
|
||||
source: "embedded-postgres@54329",
|
||||
configPath: path.join(home, "instances", "default", "spaces", "default", "config.json"),
|
||||
envPath: path.join(home, "instances", "default", "spaces", "default", ".env"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
expandHomePrefix,
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolvePaperclipConfigPathForInstance,
|
||||
resolvePaperclipEnvPathForConfig,
|
||||
} from "@paperclipai/shared/space-paths";
|
||||
|
||||
const DEFAULT_INSTANCE_ID = "default";
|
||||
const CONFIG_BASENAME = "config.json";
|
||||
const ENV_BASENAME = ".env";
|
||||
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
type PartialConfig = {
|
||||
database?: {
|
||||
@@ -35,39 +37,6 @@ export type ResolvedDatabaseTarget =
|
||||
envPath: string;
|
||||
};
|
||||
|
||||
function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolvePaperclipHomeDir(): string {
|
||||
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||
return path.resolve(os.homedir(), ".paperclip");
|
||||
}
|
||||
|
||||
function resolvePaperclipInstanceId(): string {
|
||||
const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
|
||||
if (!INSTANCE_ID_RE.test(raw)) {
|
||||
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveDefaultConfigPath(): string {
|
||||
return path.resolve(
|
||||
resolvePaperclipHomeDir(),
|
||||
"instances",
|
||||
resolvePaperclipInstanceId(),
|
||||
CONFIG_BASENAME,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDefaultEmbeddedPostgresDir(): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "db");
|
||||
}
|
||||
|
||||
function resolveHomeAwarePath(value: string): string {
|
||||
return path.resolve(expandHomePrefix(value));
|
||||
}
|
||||
@@ -89,11 +58,11 @@ function resolvePaperclipConfigPath(): string {
|
||||
if (process.env.PAPERCLIP_CONFIG?.trim()) {
|
||||
return path.resolve(process.env.PAPERCLIP_CONFIG.trim());
|
||||
}
|
||||
return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath();
|
||||
return findConfigFileFromAncestors(process.cwd()) ?? resolvePaperclipConfigPathForInstance();
|
||||
}
|
||||
|
||||
function resolvePaperclipEnvPath(configPath: string): string {
|
||||
return path.resolve(path.dirname(configPath), ENV_BASENAME);
|
||||
return resolvePaperclipEnvPathForConfig(configPath);
|
||||
}
|
||||
|
||||
function parseEnvFile(contents: string): Record<string, string> {
|
||||
|
||||
@@ -24,25 +24,25 @@ export const databaseBackupConfigSchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
intervalMinutes: z.number().int().min(1).max(7 * 24 * 60).default(60),
|
||||
retentionDays: z.number().int().min(1).max(3650).default(7),
|
||||
dir: z.string().default("~/.paperclip/instances/default/data/backups"),
|
||||
dir: z.string().default("~/.paperclip/instances/default/spaces/default/data/backups"),
|
||||
});
|
||||
|
||||
export const databaseConfigSchema = z.object({
|
||||
mode: z.enum(["embedded-postgres", "postgres"]).default("embedded-postgres"),
|
||||
connectionString: z.string().optional(),
|
||||
embeddedPostgresDataDir: z.string().default("~/.paperclip/instances/default/db"),
|
||||
embeddedPostgresDataDir: z.string().default("~/.paperclip/instances/default/spaces/default/db"),
|
||||
embeddedPostgresPort: z.number().int().min(1).max(65535).default(54329),
|
||||
backup: databaseBackupConfigSchema.default({
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 7,
|
||||
dir: "~/.paperclip/instances/default/data/backups",
|
||||
dir: "~/.paperclip/instances/default/spaces/default/data/backups",
|
||||
}),
|
||||
});
|
||||
|
||||
export const loggingConfigSchema = z.object({
|
||||
mode: z.enum(["file", "cloud"]),
|
||||
logDir: z.string().default("~/.paperclip/instances/default/logs"),
|
||||
logDir: z.string().default("~/.paperclip/instances/default/spaces/default/logs"),
|
||||
});
|
||||
|
||||
export const serverConfigSchema = z.object({
|
||||
@@ -63,7 +63,7 @@ export const authConfigSchema = z.object({
|
||||
});
|
||||
|
||||
export const storageLocalDiskConfigSchema = z.object({
|
||||
baseDir: z.string().default("~/.paperclip/instances/default/data/storage"),
|
||||
baseDir: z.string().default("~/.paperclip/instances/default/spaces/default/data/storage"),
|
||||
});
|
||||
|
||||
export const storageS3ConfigSchema = z.object({
|
||||
@@ -77,7 +77,7 @@ export const storageS3ConfigSchema = z.object({
|
||||
export const storageConfigSchema = z.object({
|
||||
provider: z.enum(STORAGE_PROVIDERS).default("local_disk"),
|
||||
localDisk: storageLocalDiskConfigSchema.default({
|
||||
baseDir: "~/.paperclip/instances/default/data/storage",
|
||||
baseDir: "~/.paperclip/instances/default/spaces/default/data/storage",
|
||||
}),
|
||||
s3: storageS3ConfigSchema.default({
|
||||
bucket: "paperclip",
|
||||
@@ -88,14 +88,14 @@ export const storageConfigSchema = z.object({
|
||||
});
|
||||
|
||||
export const secretsLocalEncryptedConfigSchema = z.object({
|
||||
keyFilePath: z.string().default("~/.paperclip/instances/default/secrets/master.key"),
|
||||
keyFilePath: z.string().default("~/.paperclip/instances/default/spaces/default/secrets/master.key"),
|
||||
});
|
||||
|
||||
export const secretsConfigSchema = z.object({
|
||||
provider: z.enum(SECRET_PROVIDERS).default("local_encrypted"),
|
||||
strictMode: z.boolean().default(false),
|
||||
localEncrypted: secretsLocalEncryptedConfigSchema.default({
|
||||
keyFilePath: "~/.paperclip/instances/default/secrets/master.key",
|
||||
keyFilePath: "~/.paperclip/instances/default/spaces/default/secrets/master.key",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -118,7 +118,7 @@ export const paperclipConfigSchema = z
|
||||
storage: storageConfigSchema.default({
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: "~/.paperclip/instances/default/data/storage",
|
||||
baseDir: "~/.paperclip/instances/default/spaces/default/data/storage",
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
@@ -131,7 +131,7 @@ export const paperclipConfigSchema = z
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: "~/.paperclip/instances/default/secrets/master.key",
|
||||
keyFilePath: "~/.paperclip/instances/default/spaces/default/secrets/master.key",
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDefaultSpaceRegistry,
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolvePaperclipConfigPathForInstance,
|
||||
resolvePaperclipInstanceConfigPath,
|
||||
resolvePaperclipInstanceRoot,
|
||||
resolvePaperclipSpaceRoot,
|
||||
} from "./space-paths.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function writeJson(filePath: string, value: unknown) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
describe("space path resolution", () => {
|
||||
it("resolves default-space config and data under spaces/default for fresh installs", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-space-paths-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_SPACE_ID;
|
||||
|
||||
expect(resolvePaperclipConfigPathForInstance()).toBe(
|
||||
path.join(home, "instances", "default", "spaces", "default", "config.json"),
|
||||
);
|
||||
expect(resolveDefaultEmbeddedPostgresDir()).toBe(
|
||||
path.join(home, "instances", "default", "spaces", "default", "db"),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the active space pointer from the instance registry", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-space-registry-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
writeJson(resolvePaperclipInstanceConfigPath(), {
|
||||
...createDefaultSpaceRegistry("system"),
|
||||
activeSpaceId: "dev",
|
||||
spaces: [{ id: "dev", root: "spaces/dev", createdAt: "2026-05-09T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
expect(resolvePaperclipSpaceRoot()).toBe(path.join(home, "instances", "default", "spaces", "dev"));
|
||||
expect(resolvePaperclipConfigPathForInstance()).toBe(
|
||||
path.join(home, "instances", "default", "spaces", "dev", "config.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats legacy root-shaped default installs as the active default space", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-space-legacy-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
const legacyConfigPath = path.join(home, "instances", "default", "config.json");
|
||||
writeJson(legacyConfigPath, {
|
||||
database: {},
|
||||
server: {},
|
||||
});
|
||||
|
||||
expect(resolvePaperclipSpaceRoot()).toBe(resolvePaperclipInstanceRoot());
|
||||
expect(resolvePaperclipConfigPathForInstance()).toBe(legacyConfigPath);
|
||||
expect(resolveDefaultEmbeddedPostgresDir()).toBe(
|
||||
path.join(home, "instances", "default", "db"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
|
||||
export const DEFAULT_PAPERCLIP_SPACE_ID = "default";
|
||||
export const PAPERCLIP_CONFIG_BASENAME = "config.json";
|
||||
export const PAPERCLIP_ENV_FILENAME = ".env";
|
||||
export const DEFAULT_SPACE_OWNED_PATH_NAMES = [
|
||||
PAPERCLIP_CONFIG_BASENAME,
|
||||
PAPERCLIP_ENV_FILENAME,
|
||||
"db",
|
||||
"data",
|
||||
"logs",
|
||||
"secrets",
|
||||
"workspaces",
|
||||
"projects",
|
||||
"companies",
|
||||
] as const;
|
||||
export const DEFAULT_SPACE_ADAPTER_LOCAL_PATH_NAMES = ["codex-home"] as const;
|
||||
|
||||
const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export interface PaperclipSpaceRegistry {
|
||||
$meta: {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
source: "onboard" | "configure" | "doctor" | "system";
|
||||
};
|
||||
activeSpaceId: string;
|
||||
spaces: Array<{
|
||||
id: string;
|
||||
root: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
defaultSpaceMigration?: {
|
||||
migratedAt: string;
|
||||
sourceRoot: string;
|
||||
destinationRoot: string;
|
||||
movedPaths: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolvePaperclipHomeDir(homeOverride?: string): string {
|
||||
const raw = homeOverride?.trim() || process.env.PAPERCLIP_HOME?.trim();
|
||||
if (raw) return path.resolve(expandHomePrefix(raw));
|
||||
return path.resolve(os.homedir(), ".paperclip");
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceId(instanceIdOverride?: string): string {
|
||||
const raw = instanceIdOverride?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID;
|
||||
if (!PATH_SEGMENT_RE.test(raw)) {
|
||||
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceRoot(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
} = {}): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(input.homeDir), "instances", resolvePaperclipInstanceId(input.instanceId));
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceConfigPath(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
} = {}): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(input), PAPERCLIP_CONFIG_BASENAME);
|
||||
}
|
||||
|
||||
function readJsonIfPresent(filePath: string): unknown | null {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isPaperclipRuntimeConfig(value: unknown): boolean {
|
||||
return isObject(value) && isObject(value.database) && isObject(value.server);
|
||||
}
|
||||
|
||||
function readActiveSpaceIdFromRegistry(instanceConfigPath: string): string | null {
|
||||
const parsed = readJsonIfPresent(instanceConfigPath);
|
||||
if (!isObject(parsed) || isPaperclipRuntimeConfig(parsed)) return null;
|
||||
const activeSpaceId = parsed.activeSpaceId;
|
||||
return typeof activeSpaceId === "string" && PATH_SEGMENT_RE.test(activeSpaceId)
|
||||
? activeSpaceId
|
||||
: null;
|
||||
}
|
||||
|
||||
export function hasLegacyDefaultSpaceInstall(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
} = {}): boolean {
|
||||
return isPaperclipRuntimeConfig(readJsonIfPresent(resolvePaperclipInstanceConfigPath(input)));
|
||||
}
|
||||
|
||||
export function resolvePaperclipSpaceId(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
spaceId?: string;
|
||||
} = {}): string {
|
||||
const raw =
|
||||
input.spaceId?.trim() ||
|
||||
process.env.PAPERCLIP_SPACE_ID?.trim() ||
|
||||
readActiveSpaceIdFromRegistry(resolvePaperclipInstanceConfigPath(input)) ||
|
||||
DEFAULT_PAPERCLIP_SPACE_ID;
|
||||
if (!PATH_SEGMENT_RE.test(raw)) {
|
||||
throw new Error(`Invalid PAPERCLIP_SPACE_ID '${raw}'.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function resolvePaperclipSpacesRoot(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
} = {}): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(input), "spaces");
|
||||
}
|
||||
|
||||
export function resolvePaperclipSpaceRoot(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
spaceId?: string;
|
||||
} = {}): string {
|
||||
const spaceId = resolvePaperclipSpaceId(input);
|
||||
if (spaceId === DEFAULT_PAPERCLIP_SPACE_ID && hasLegacyDefaultSpaceInstall(input)) {
|
||||
return resolvePaperclipInstanceRoot(input);
|
||||
}
|
||||
return path.resolve(resolvePaperclipSpacesRoot(input), spaceId);
|
||||
}
|
||||
|
||||
export function resolvePaperclipSpaceConfigPath(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
spaceId?: string;
|
||||
} = {}): string {
|
||||
return path.resolve(resolvePaperclipSpaceRoot(input), PAPERCLIP_CONFIG_BASENAME);
|
||||
}
|
||||
|
||||
export function resolvePaperclipConfigPathForInstance(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
spaceId?: string;
|
||||
} = {}): string {
|
||||
const spaceConfigPath = resolvePaperclipSpaceConfigPath(input);
|
||||
if (fs.existsSync(spaceConfigPath)) return spaceConfigPath;
|
||||
|
||||
const instanceConfigPath = resolvePaperclipInstanceConfigPath(input);
|
||||
if (isPaperclipRuntimeConfig(readJsonIfPresent(instanceConfigPath))) return instanceConfigPath;
|
||||
|
||||
return spaceConfigPath;
|
||||
}
|
||||
|
||||
export function resolvePaperclipEnvPathForConfig(configPath: string): string {
|
||||
return path.resolve(path.dirname(configPath), PAPERCLIP_ENV_FILENAME);
|
||||
}
|
||||
|
||||
export function resolveDefaultEmbeddedPostgresDir(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
spaceId?: string;
|
||||
} = {}): string {
|
||||
return path.resolve(resolvePaperclipSpaceRoot(input), "db");
|
||||
}
|
||||
|
||||
export function resolveDefaultLogsDir(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
spaceId?: string;
|
||||
} = {}): string {
|
||||
return path.resolve(resolvePaperclipSpaceRoot(input), "logs");
|
||||
}
|
||||
|
||||
export function resolveDefaultSecretsKeyFilePath(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
spaceId?: string;
|
||||
} = {}): string {
|
||||
return path.resolve(resolvePaperclipSpaceRoot(input), "secrets", "master.key");
|
||||
}
|
||||
|
||||
export function resolveDefaultStorageDir(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
spaceId?: string;
|
||||
} = {}): string {
|
||||
return path.resolve(resolvePaperclipSpaceRoot(input), "data", "storage");
|
||||
}
|
||||
|
||||
export function resolveDefaultBackupDir(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
spaceId?: string;
|
||||
} = {}): string {
|
||||
return path.resolve(resolvePaperclipSpaceRoot(input), "data", "backups");
|
||||
}
|
||||
|
||||
export function createDefaultSpaceRegistry(source: PaperclipSpaceRegistry["$meta"]["source"]): PaperclipSpaceRegistry {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: now,
|
||||
source,
|
||||
},
|
||||
activeSpaceId: DEFAULT_PAPERCLIP_SPACE_ID,
|
||||
spaces: [
|
||||
{
|
||||
id: DEFAULT_PAPERCLIP_SPACE_ID,
|
||||
root: path.join("spaces", DEFAULT_PAPERCLIP_SPACE_ID),
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureDefaultSpaceRegistry(input: {
|
||||
source: PaperclipSpaceRegistry["$meta"]["source"];
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
}): string {
|
||||
const registryPath = resolvePaperclipInstanceConfigPath(input);
|
||||
if (fs.existsSync(registryPath)) return registryPath;
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(registryPath, `${JSON.stringify(createDefaultSpaceRegistry(input.source), null, 2)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
return registryPath;
|
||||
}
|
||||
|
||||
export function resolveHomeAwarePath(value: string): string {
|
||||
return path.resolve(expandHomePrefix(value));
|
||||
}
|
||||
+30
-39
@@ -1,57 +1,52 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_INSTANCE_ID = "default";
|
||||
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
const FRIENDLY_PATH_SEGMENT_RE = /[^a-zA-Z0-9._-]+/g;
|
||||
import {
|
||||
expandHomePrefix,
|
||||
resolveDefaultBackupDir as resolveSharedDefaultBackupDir,
|
||||
resolveDefaultEmbeddedPostgresDir as resolveSharedDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultLogsDir as resolveSharedDefaultLogsDir,
|
||||
resolveDefaultSecretsKeyFilePath as resolveSharedDefaultSecretsKeyFilePath,
|
||||
resolveDefaultStorageDir as resolveSharedDefaultStorageDir,
|
||||
resolveHomeAwarePath,
|
||||
resolvePaperclipConfigPathForInstance,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
resolvePaperclipInstanceRoot,
|
||||
resolvePaperclipSpaceRoot,
|
||||
} from "@paperclipai/shared/space-paths";
|
||||
|
||||
function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolvePaperclipHomeDir(): string {
|
||||
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||
return path.resolve(os.homedir(), ".paperclip");
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceId(): string {
|
||||
const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
|
||||
if (!INSTANCE_ID_RE.test(raw)) {
|
||||
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceRoot(): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId());
|
||||
}
|
||||
export {
|
||||
expandHomePrefix,
|
||||
resolveHomeAwarePath,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
resolvePaperclipInstanceRoot,
|
||||
resolvePaperclipSpaceRoot,
|
||||
};
|
||||
|
||||
export function resolveDefaultConfigPath(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "config.json");
|
||||
return resolvePaperclipConfigPathForInstance();
|
||||
}
|
||||
|
||||
export function resolveDefaultEmbeddedPostgresDir(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "db");
|
||||
return resolveSharedDefaultEmbeddedPostgresDir();
|
||||
}
|
||||
|
||||
export function resolveDefaultLogsDir(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "logs");
|
||||
return resolveSharedDefaultLogsDir();
|
||||
}
|
||||
|
||||
export function resolveDefaultSecretsKeyFilePath(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "master.key");
|
||||
return resolveSharedDefaultSecretsKeyFilePath();
|
||||
}
|
||||
|
||||
export function resolveDefaultStorageDir(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "data", "storage");
|
||||
return resolveSharedDefaultStorageDir();
|
||||
}
|
||||
|
||||
export function resolveDefaultBackupDir(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "data", "backups");
|
||||
return resolveSharedDefaultBackupDir();
|
||||
}
|
||||
|
||||
export function resolveDefaultAgentWorkspaceDir(agentId: string): string {
|
||||
@@ -59,7 +54,7 @@ export function resolveDefaultAgentWorkspaceDir(agentId: string): string {
|
||||
if (!PATH_SEGMENT_RE.test(trimmed)) {
|
||||
throw new Error(`Invalid agent id for workspace path '${agentId}'.`);
|
||||
}
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "workspaces", trimmed);
|
||||
return path.resolve(resolvePaperclipSpaceRoot(), "workspaces", trimmed);
|
||||
}
|
||||
|
||||
function sanitizeFriendlyPathSegment(value: string | null | undefined, fallback = "_default"): string {
|
||||
@@ -82,14 +77,10 @@ export function resolveManagedProjectWorkspaceDir(input: {
|
||||
throw new Error("Managed project workspace path requires companyId and projectId.");
|
||||
}
|
||||
return path.resolve(
|
||||
resolvePaperclipInstanceRoot(),
|
||||
resolvePaperclipSpaceRoot(),
|
||||
"projects",
|
||||
sanitizeFriendlyPathSegment(companyId, "company"),
|
||||
sanitizeFriendlyPathSegment(projectId, "project"),
|
||||
sanitizeFriendlyPathSegment(input.repoName, "_default"),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveHomeAwarePath(value: string): string {
|
||||
return path.resolve(expandHomePrefix(value));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { resolveHomeAwarePath, resolvePaperclipSpaceRoot } from "../home-paths.js";
|
||||
|
||||
const ENTRY_FILE_DEFAULT = "AGENTS.md";
|
||||
const MODE_KEY = "instructionsBundleMode";
|
||||
@@ -132,7 +132,7 @@ function resolvePathWithinRoot(rootPath: string, relativePath: string): string {
|
||||
|
||||
function resolveManagedInstructionsRoot(agent: AgentLike): string {
|
||||
return path.resolve(
|
||||
resolvePaperclipInstanceRoot(),
|
||||
resolvePaperclipSpaceRoot(),
|
||||
"companies",
|
||||
agent.companyId,
|
||||
"agents",
|
||||
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
CompanySkillUsageAgent,
|
||||
} from "@paperclipai/shared";
|
||||
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { resolvePaperclipSpaceRoot } from "../home-paths.js";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
||||
import { agentService } from "./agents.js";
|
||||
@@ -1383,7 +1383,7 @@ export async function findMissingLocalSkillIds(
|
||||
}
|
||||
|
||||
function resolveManagedSkillsRoot(companyId: string) {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "skills", companyId);
|
||||
return path.resolve(resolvePaperclipSpaceRoot(), "skills", companyId);
|
||||
}
|
||||
|
||||
function resolveLocalSkillFilePath(skill: CompanySkill, relativePath: string) {
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
type FeedbackTraceTargetSummary,
|
||||
type FeedbackVoteValue,
|
||||
} from "@paperclipai/shared";
|
||||
import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { resolveHomeAwarePath, resolvePaperclipSpaceRoot } from "../home-paths.js";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { agentInstructionsService } from "./agent-instructions.js";
|
||||
import {
|
||||
@@ -391,7 +391,7 @@ async function buildCodexTraceFiles(input: {
|
||||
}
|
||||
|
||||
const managedRoot = path.join(
|
||||
resolvePaperclipInstanceRoot(),
|
||||
resolvePaperclipSpaceRoot(),
|
||||
"companies",
|
||||
input.companyId,
|
||||
"codex-home",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createReadStream, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { notFound } from "../errors.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { resolvePaperclipSpaceRoot } from "../home-paths.js";
|
||||
|
||||
export type RunLogStoreType = "local_file";
|
||||
|
||||
@@ -151,7 +151,7 @@ let cachedStore: RunLogStore | null = null;
|
||||
|
||||
export function getRunLogStore() {
|
||||
if (cachedStore) return cachedStore;
|
||||
const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(resolvePaperclipInstanceRoot(), "data", "run-logs");
|
||||
const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(resolvePaperclipSpaceRoot(), "data", "run-logs");
|
||||
cachedStore = createLocalFileRunLogStore(basePath);
|
||||
return cachedStore;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { createReadStream, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { notFound } from "../errors.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { resolvePaperclipSpaceRoot } from "../home-paths.js";
|
||||
|
||||
export type WorkspaceOperationLogStoreType = "local_file";
|
||||
|
||||
@@ -150,7 +150,7 @@ let cachedStore: WorkspaceOperationLogStore | null = null;
|
||||
export function getWorkspaceOperationLogStore() {
|
||||
if (cachedStore) return cachedStore;
|
||||
const basePath = process.env.WORKSPACE_OPERATION_LOG_BASE_PATH
|
||||
?? path.resolve(resolvePaperclipInstanceRoot(), "data", "workspace-operation-logs");
|
||||
?? path.resolve(resolvePaperclipSpaceRoot(), "data", "workspace-operation-logs");
|
||||
cachedStore = createLocalFileWorkspaceOperationLogStore(basePath);
|
||||
return cachedStore;
|
||||
}
|
||||
Reference in new issue
Block a user