Add default space compatibility migration

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-09 15:08:05 -05:00
1 parent 9b130d4f78
commit 11dc32fd29
27 files changed
+984 -185

No files matched your search

+7 -4
View File
@@ -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", () => {
+39
View File
@@ -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";
+186
View File
@@ -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);
});
});
+4
View File
@@ -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.`);
+6 -1
View File
@@ -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, {
+267
View File
@@ -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
View File
@@ -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),
+2
View File
@@ -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");