diff --git a/cli/src/__tests__/home-paths.test.ts b/cli/src/__tests__/home-paths.test.ts index 1d9c654eeef..53ae028a4aa 100644 --- a/cli/src/__tests__/home-paths.test.ts +++ b/cli/src/__tests__/home-paths.test.ts @@ -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", () => { diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index b66c20fa8c1..851d476e17c 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -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"; diff --git a/cli/src/__tests__/spaces.test.ts b/cli/src/__tests__/spaces.test.ts new file mode 100644 index 00000000000..b13dd557226 --- /dev/null +++ b/cli/src/__tests__/spaces.test.ts @@ -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); + }); +}); diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index b7d2dcbc07d..4b96d10a3f0 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -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.`); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 62158e05bbf..c0f409092ee 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -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 { 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 { 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, { diff --git a/cli/src/commands/spaces.ts b/cli/src/commands/spaces.ts new file mode 100644 index 00000000000..30ff2d24e41 --- /dev/null +++ b/cli/src/commands/spaces.ts @@ -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; + +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; +} + +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 = {}): 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 { + 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 { + 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 ", "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); + }); +} diff --git a/cli/src/config/home.ts b/cli/src/config/home.ts index ef4d8e09386..0b93a70ed26 100644 --- a/cli/src/config/home.ts +++ b/cli/src/config/home.ts @@ -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), diff --git a/cli/src/index.ts b/cli/src/index.ts index bbec356f342..b88f7196b13 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -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"); diff --git a/packages/adapters/acpx-local/package.json b/packages/adapters/acpx-local/package.json index b061a11364a..d502fc01423 100644 --- a/packages/adapters/acpx-local/package.json +++ b/packages/adapters/acpx-local/package.json @@ -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" diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index 3ef832b6691..efdc74983c1 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -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 { diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index b73274a98da..b41c81a4f33 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -54,6 +54,7 @@ }, "dependencies": { "@paperclipai/adapter-utils": "workspace:*", + "@paperclipai/shared": "workspace:*", "picocolors": "^1.1.1" }, "devDependencies": { diff --git a/packages/adapters/claude-local/src/server/claude-config.ts b/packages/adapters/claude-local/src/server/claude-config.ts index 5e4f22788a0..1c6730b7b47 100644 --- a/packages/adapters/claude-local/src/server/claude-config.ts +++ b/packages/adapters/claude-local/src/server/claude-config.ts @@ -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( diff --git a/packages/adapters/claude-local/src/server/prompt-cache.ts b/packages/adapters/claude-local/src/server/prompt-cache.ts index ce719799979..dc974341577 100644 --- a/packages/adapters/claude-local/src/server/prompt-cache.ts +++ b/packages/adapters/claude-local/src/server/prompt-cache.ts @@ -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", diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index 3890a2d9757..770c1657c89 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -53,6 +53,7 @@ }, "dependencies": { "@paperclipai/adapter-utils": "workspace:*", + "@paperclipai/shared": "workspace:*", "picocolors": "^1.1.1" }, "devDependencies": { diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index 10b3ae96b4b..bd0f218e40f 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -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 { diff --git a/packages/db/src/backup.ts b/packages/db/src/backup.ts index c11822c9503..40ab3e134e4 100644 --- a/packages/db/src/backup.ts +++ b/packages/db/src/backup.ts @@ -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); diff --git a/packages/db/src/runtime-config.test.ts b/packages/db/src/runtime-config.test.ts index 4627e6918d0..8e853d17709 100644 --- a/packages/db/src/runtime-config.test.ts +++ b/packages/db/src/runtime-config.test.ts @@ -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"), + }); + }); }); diff --git a/packages/db/src/runtime-config.ts b/packages/db/src/runtime-config.ts index c6c64a38fa0..6e8740ef78d 100644 --- a/packages/db/src/runtime-config.ts +++ b/packages/db/src/runtime-config.ts @@ -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 { diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index efa1bdee1d8..0264d5f6d08 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -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", }, }), }) diff --git a/packages/shared/src/space-paths.test.ts b/packages/shared/src/space-paths.test.ts new file mode 100644 index 00000000000..b21aa0d250f --- /dev/null +++ b/packages/shared/src/space-paths.test.ts @@ -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"), + ); + }); +}); diff --git a/packages/shared/src/space-paths.ts b/packages/shared/src/space-paths.ts new file mode 100644 index 00000000000..ba03ff236a7 --- /dev/null +++ b/packages/shared/src/space-paths.ts @@ -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 { + 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)); +} diff --git a/server/src/home-paths.ts b/server/src/home-paths.ts index f1174bbb484..f12464d1fe8 100644 --- a/server/src/home-paths.ts +++ b/server/src/home-paths.ts @@ -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)); -} diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index 6dcbb38f047..b6d3490e213 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -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", diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 0f5af2db4bb..b5a66a7a1f7 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -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) { diff --git a/server/src/services/feedback.ts b/server/src/services/feedback.ts index 4df0ca625b4..befbb0ec864 100644 --- a/server/src/services/feedback.ts +++ b/server/src/services/feedback.ts @@ -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", diff --git a/server/src/services/run-log-store.ts b/server/src/services/run-log-store.ts index 9c6a814287d..c00d33a418f 100644 --- a/server/src/services/run-log-store.ts +++ b/server/src/services/run-log-store.ts @@ -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; } diff --git a/server/src/services/workspace-operation-log-store.ts b/server/src/services/workspace-operation-log-store.ts index 379dab62d2c..4695014fe52 100644 --- a/server/src/services/workspace-operation-log-store.ts +++ b/server/src/services/workspace-operation-log-store.ts @@ -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; }