diff --git a/cli/README.md b/cli/README.md index b23c32a69cf..8aa2cc16ac1 100644 --- a/cli/README.md +++ b/cli/README.md @@ -226,6 +226,21 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as
+## Paperclip Cloud Sync + +Cloud upstream sync is behind the `Cloud Sync` experimental setting. Enable it in Instance Settings before pushing. + +```bash +paperclipai cloud connect https://your-stack.paperclip.app +paperclipai cloud connect https://your-stack.paperclip.app --no-browser +paperclipai cloud push --company --dry-run +paperclipai cloud push --company +``` + +`cloud connect` authorizes the local instance against the target stack and stores the upstream token in the local instance secret store. The default path opens a browser for consent; `--no-browser` uses the device-code flow and prints the verification URL and user code. + +`cloud push --dry-run` exports the selected local company, sends a preview bundle to the connected Cloud stack, and exits with code `2` when conflicts need user resolution. A schema mismatch exits with code `3`. Running without `--dry-run` stages chunks idempotently, applies the run, and prints the final summary and recent progress events. + ## Development ```bash diff --git a/cli/src/__tests__/cloud.test.ts b/cli/src/__tests__/cloud.test.ts new file mode 100644 index 00000000000..0c5aeae8b6d --- /dev/null +++ b/cli/src/__tests__/cloud.test.ts @@ -0,0 +1,233 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CompanyPortabilityExportResult } from "@paperclipai/shared"; +import { + assertDiscoveryCompatible, + buildBundleFromLocalCompany, + cloudCommandExitCodes, + connectCloud, +} from "../commands/client/cloud.js"; +import { + LocalUpstreamPushCoordinator, + normalizedContentHash, + type LocalUpstreamExportBundle, +} from "../commands/client/cloud-transfer.js"; +import { getCloudConnection } from "../commands/client/cloud-store.js"; + +const originalEnv = { ...process.env }; +const originalFetch = globalThis.fetch; + +describe("cloud CLI helpers", () => { + let tempHome: string; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-cli-")); + process.env = { ...originalEnv, PAPERCLIP_HOME: tempHome }; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it("connects with the device-code flow and stores the resulting cloud connection", async () => { + globalThis.fetch = vi.fn(async (url, init) => { + const requestUrl = String(url); + if (requestUrl.endsWith("/.well-known/paperclip-upstream")) { + return jsonResponse(discovery()); + } + if (requestUrl.endsWith("/api/upstream-sync/device-code")) { + expect(JSON.parse(String(init?.body))).toMatchObject({ + stackId: "stack-1", + scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"], + }); + return jsonResponse({ + deviceCode: "device-1", + userCode: "ABCD-EFGH", + verificationUri: "https://cloud.example.test/api/upstream-sync/device-code/approve", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + intervalSeconds: 0, + }); + } + if (requestUrl.endsWith("/api/upstream-sync/token")) { + return jsonResponse({ + accessToken: "upt_test", + scopes: ["upstream_import:preview"], + token: { + id: "token-1", + companyStackId: "stack-1", + targetOrigin: "https://cloud.example.test", + sourceInstanceId: "paperclip-local-default", + sourceInstanceFingerprint: "sha256:test", + scopes: ["upstream_import:preview"], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }); + } + return jsonResponse({ error: "not_found" }, 404); + }) as typeof fetch; + + const connection = await connectCloud("https://cloud.example.test", { noBrowser: true, json: true }); + + expect(connection.accessToken).toBe("upt_test"); + expect(getCloudConnection("https://cloud.example.test")?.token.id).toBe("token-1"); + }); + + it("hard-blocks incompatible transfer schema versions with the stable schema exit code", () => { + expect(() => assertDiscoveryCompatible(discovery({ supportedSchemaMajor: 99 }))).toThrow(/schema mismatch/i); + expect(cloudCommandExitCodes.schemaMismatch).toBe(3); + }); + + it("builds deterministic chunks with validated payload hashes", async () => { + const bundle = await buildTestBundle(); + + expect(bundle.chunks).toHaveLength(2); + expect(bundle.chunks[0]?.sha256).toBe(normalizedContentHash(bundle.chunks[0]?.payload)); + expect(bundle.manifest.chunks[0]?.manifestHash).toBe(bundle.manifest.manifestHash); + expect(bundle.manifest.idempotencyKey).toBe((await buildTestBundle()).manifest.idempotencyKey); + }); + + it("reuses the same manifest and chunk identity when an interrupted apply is retried", async () => { + const bundle = await buildTestBundle(); + const calls: Array<{ path: string; body: unknown }> = []; + const coordinator = new LocalUpstreamPushCoordinator({ + targetOrigin: "https://cloud.example.test", + paperclipCompanyId: "target-company-1", + fetch: async (url, init) => { + const parsed = new URL(String(url)); + const body = init?.body ? JSON.parse(String(init.body)) as unknown : {}; + calls.push({ path: parsed.pathname, body }); + if (parsed.pathname.endsWith("/runs")) return jsonResponse({ run: { id: "run-1" } }); + return jsonResponse({ run: { id: "run-1" }, summary: { create: 0, update: 0, adopt: 0, skip: 2, conflict: 0, staleMapping: 0 } }); + }, + }); + + await coordinator.apply(bundle); + await coordinator.apply(bundle); + + const runBodies = calls.filter((call) => call.path.endsWith("/runs")).map((call) => call.body as { manifest: { idempotencyKey: string } }); + const chunkBodies = calls.filter((call) => call.path.endsWith("/chunks")).map((call) => call.body as { chunkIndex: number; sha256: string }); + expect(runBodies).toHaveLength(2); + expect(runBodies[0]?.manifest.idempotencyKey).toBe(runBodies[1]?.manifest.idempotencyKey); + expect(chunkBodies[0]).toEqual(chunkBodies[2]); + expect(chunkBodies[1]).toEqual(chunkBodies[3]); + }); +}); + +async function buildTestBundle(): Promise { + return buildBundleFromLocalCompany({ + localCompanyId: "local-company-1", + connection: { + id: "conn-1", + remoteUrl: "https://cloud.example.test", + targetOrigin: "https://cloud.example.test", + targetHost: "cloud.example.test", + stackId: "stack-1", + targetCompanyId: "target-company-1", + accessToken: "upt_test", + token: { + id: "token-1", + companyStackId: "stack-1", + targetOrigin: "https://cloud.example.test", + sourceInstanceId: "paperclip-local-default", + sourceInstanceFingerprint: "sha256:test", + scopes: ["upstream_import:preview"], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + privateKeyPem: "unused", + sourcePublicKey: "unused", + sourceInstanceId: "paperclip-local-default", + sourceInstanceFingerprint: "sha256:test", + scopes: ["upstream_import:preview"], + createdAt: "2026-05-18T00:00:00.000Z", + updatedAt: "2026-05-18T00:00:00.000Z", + }, + discovery: discovery(), + localApi: { + post: async () => portabilityExport() as T, + }, + maxEntitiesPerChunk: 1, + mode: "apply", + }); +} + +function discovery(overrides: Partial<{ supportedSchemaMajor: number }> = {}) { + return { + schema: "paperclip-upstream-discovery-v1", + stack: { + id: "stack-1", + slug: "cloud-test", + displayName: "Cloud Test", + companyId: "target-company-1", + origin: "https://cloud.example.test", + }, + auth: { + deviceCode: { + deviceCodeUrl: "https://cloud.example.test/api/upstream-sync/device-code", + verificationUrl: "https://cloud.example.test/api/upstream-sync/device-code/approve", + tokenUrl: "https://cloud.example.test/api/upstream-sync/token", + }, + scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"], + }, + transfer: { + supportedSchemaMajor: overrides.supportedSchemaMajor ?? 1, + featureFlags: ["cloud_sync"], + }, + }; +} + +function portabilityExport(): CompanyPortabilityExportResult { + return { + rootPath: ".", + paperclipExtensionPath: ".paperclip.yaml", + manifest: { + schemaVersion: 1, + generatedAt: "2026-05-18T00:00:00.000Z", + source: { + companyId: "local-company-1", + companyName: "Local Company", + }, + includes: { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, + }, + company: { + path: "company.json", + name: "Local Company", + description: null, + brandColor: null, + logoPath: null, + attachmentMaxBytes: null, + requireBoardApprovalForNewAgents: false, + feedbackDataSharingEnabled: false, + feedbackDataSharingConsentAt: null, + feedbackDataSharingConsentByUserId: null, + feedbackDataSharingTermsVersion: null, + }, + sidebar: null, + agents: [], + skills: [], + projects: [], + issues: [], + envInputs: [], + }, + files: { + "README.md": "Local Company", + }, + warnings: [], + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/cli/src/commands/client/cloud-store.ts b/cli/src/commands/client/cloud-store.ts new file mode 100644 index 00000000000..fa63c7133b0 --- /dev/null +++ b/cli/src/commands/client/cloud-store.ts @@ -0,0 +1,177 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolvePaperclipInstanceRoot } from "../../config/home.js"; + +export interface CloudConnectionTokenRecord { + id: string; + companyStackId: string; + targetOrigin: string; + sourceInstanceId: string; + sourceInstanceFingerprint: string; + scopes: string[]; + expiresAt: string; + [key: string]: unknown; +} + +export interface CloudConnection { + id: string; + remoteUrl: string; + targetOrigin: string; + targetHost: string; + stackId: string; + stackSlug?: string | null; + stackDisplayName?: string | null; + targetCompanyId: string; + accessToken: string; + token: CloudConnectionTokenRecord; + privateKeyPem: string; + sourcePublicKey: string; + sourceInstanceId: string; + sourceInstanceFingerprint: string; + scopes: string[]; + createdAt: string; + updatedAt: string; +} + +interface CloudConnectionStore { + version: 1; + connections: Record; + currentConnectionId?: string; +} + +function defaultStore(): CloudConnectionStore { + return { + version: 1, + connections: {}, + }; +} + +export function resolveCloudConnectionStorePath(): string { + return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "cloud-upstream-connections.json"); +} + +export function readCloudConnectionStore(storePath = resolveCloudConnectionStorePath()): CloudConnectionStore { + if (!fs.existsSync(storePath)) return defaultStore(); + const raw = JSON.parse(fs.readFileSync(storePath, "utf8")) as Partial | null; + const connections: Record = {}; + if (raw?.connections && typeof raw.connections === "object") { + for (const [id, value] of Object.entries(raw.connections)) { + const normalized = normalizeConnection(value); + if (normalized) connections[id] = normalized; + } + } + const currentConnectionId = + typeof raw?.currentConnectionId === "string" && connections[raw.currentConnectionId] + ? raw.currentConnectionId + : Object.values(connections).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.id; + return { + version: 1, + connections, + currentConnectionId, + }; +} + +export function writeCloudConnectionStore( + store: CloudConnectionStore, + storePath = resolveCloudConnectionStorePath(), +): void { + fs.mkdirSync(path.dirname(storePath), { recursive: true }); + fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 }); +} + +export function upsertCloudConnection( + connection: CloudConnection, + storePath = resolveCloudConnectionStorePath(), +): CloudConnection { + const store = readCloudConnectionStore(storePath); + const existing = store.connections[connection.id]; + const now = new Date().toISOString(); + const next = { + ...connection, + createdAt: existing?.createdAt ?? connection.createdAt ?? now, + updatedAt: now, + }; + store.connections[next.id] = next; + store.currentConnectionId = next.id; + writeCloudConnectionStore(store, storePath); + return next; +} + +export function getCloudConnection( + remoteUrlOrOrigin?: string, + storePath = resolveCloudConnectionStorePath(), +): CloudConnection | null { + const store = readCloudConnectionStore(storePath); + if (remoteUrlOrOrigin?.trim()) { + const needle = normalizeRemoteLookup(remoteUrlOrOrigin); + return Object.values(store.connections).find((connection) => + normalizeRemoteLookup(connection.remoteUrl) === needle || + normalizeRemoteLookup(connection.targetOrigin) === needle + ) ?? null; + } + return store.currentConnectionId ? store.connections[store.currentConnectionId] ?? null : null; +} + +function normalizeRemoteLookup(value: string): string { + try { + const url = new URL(value); + return url.origin.replace(/\/+$/u, ""); + } catch { + return value.trim().replace(/\/+$/u, ""); + } +} + +function normalizeConnection(value: unknown): CloudConnection | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const record = value as Record; + const id = stringValue(record.id); + const remoteUrl = stringValue(record.remoteUrl); + const targetOrigin = stringValue(record.targetOrigin); + const targetHost = stringValue(record.targetHost); + const stackId = stringValue(record.stackId); + const targetCompanyId = stringValue(record.targetCompanyId); + const accessToken = stringValue(record.accessToken); + const token = typeof record.token === "object" && record.token !== null && !Array.isArray(record.token) + ? record.token as CloudConnectionTokenRecord + : null; + const privateKeyPem = stringValue(record.privateKeyPem); + const sourcePublicKey = stringValue(record.sourcePublicKey); + const sourceInstanceId = stringValue(record.sourceInstanceId); + const sourceInstanceFingerprint = stringValue(record.sourceInstanceFingerprint); + const createdAt = stringValue(record.createdAt); + const updatedAt = stringValue(record.updatedAt); + if ( + !id || !remoteUrl || !targetOrigin || !targetHost || !stackId || !targetCompanyId || + !accessToken || !token || !privateKeyPem || !sourcePublicKey || !sourceInstanceId || + !sourceInstanceFingerprint || !createdAt || !updatedAt + ) { + return null; + } + return { + id, + remoteUrl, + targetOrigin, + targetHost, + stackId, + stackSlug: stringValue(record.stackSlug), + stackDisplayName: stringValue(record.stackDisplayName), + targetCompanyId, + accessToken, + token, + privateKeyPem, + sourcePublicKey, + sourceInstanceId, + sourceInstanceFingerprint, + scopes: stringArray(record.scopes), + createdAt, + updatedAt, + }; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; +} diff --git a/cli/src/commands/client/cloud-transfer.ts b/cli/src/commands/client/cloud-transfer.ts new file mode 100644 index 00000000000..9cd1ccbe74a --- /dev/null +++ b/cli/src/commands/client/cloud-transfer.ts @@ -0,0 +1,297 @@ +import { createHash } from "node:crypto"; + +export const upstreamTransferSchema = { + family: "paperclip-upstream-transfer", + version: "1.0.0", + major: 1, + minor: 0, +} as const; + +export type NormalizedSha256 = `sha256:${string}`; + +export interface SourceEntityKey { + sourceInstanceId: string; + sourceCompanyId: string; + sourceEntityType: string; + sourceEntityId: string; + sourceNaturalKey?: string; +} + +export interface UpstreamTransferWarning { + code: string; + severity: "info" | "warning" | "blocker"; + message: string; + entity?: SourceEntityKey; +} + +export interface UpstreamTransferEntityRecord { + key: SourceEntityKey; + contentHash: NormalizedSha256; + dependencies: SourceEntityKey[]; + warnings: UpstreamTransferWarning[]; +} + +export interface UpstreamTransferManifestSource { + sourceInstanceId: string; + sourceCompanyId: string; + sourceInstanceKeyFingerprint: string; + exporterVersion: string; + sourceSchemaVersion: string; +} + +export interface UpstreamTransferManifestTarget { + targetStackId: string; + targetCompanyId: string; + targetOrigin: string; + supportedSchemaMajor: number; +} + +export interface UpstreamTransferChunk { + chunkIndex: number; + totalChunks: number; + byteLength: number; + sha256: NormalizedSha256; + manifestHash: NormalizedSha256; +} + +export interface UpstreamTransferManifest { + schema: typeof upstreamTransferSchema; + source: UpstreamTransferManifestSource; + target: UpstreamTransferManifestTarget; + runId: string; + idempotencyKey: string; + generatedAt: string; + entityCount: number; + entities: UpstreamTransferEntityRecord[]; + chunks: UpstreamTransferChunk[]; + warnings: UpstreamTransferWarning[]; + featureFlags: string[]; + manifestHash: NormalizedSha256; +} + +export interface LocalUpstreamExportEntityInput { + key: SourceEntityKey; + body: Record; + dependencies?: SourceEntityKey[]; + warnings?: UpstreamTransferWarning[]; + conflictKeys?: string[]; +} + +export interface LocalUpstreamExportEntity { + record: UpstreamTransferEntityRecord; + body: Record; + conflictKeys?: string[]; +} + +export interface LocalUpstreamExportChunk { + chunkIndex: number; + totalChunks: number; + byteLength: number; + sha256: NormalizedSha256; + payload: { + entityKeys: SourceEntityKey[]; + }; +} + +export interface LocalUpstreamExportBundle { + manifest: UpstreamTransferManifest; + entities: LocalUpstreamExportEntity[]; + chunks: LocalUpstreamExportChunk[]; +} + +export interface BuildLocalUpstreamExportBundleInput { + source: UpstreamTransferManifestSource; + target: UpstreamTransferManifestTarget; + runId: string; + idempotencyKey: string; + entities: LocalUpstreamExportEntityInput[]; + warnings?: UpstreamTransferWarning[]; + featureFlags?: string[]; + maxEntitiesPerChunk?: number; +} + +export interface LocalUpstreamPushCoordinatorOptions { + targetOrigin: string; + paperclipCompanyId: string; + fetch?: typeof fetch; + headers?: (input: { method: string; path: string }) => HeadersInit | Promise; +} + +export class UpstreamImportRequestError extends Error { + readonly status: number; + readonly body: unknown; + + constructor(status: number, message: string, body: unknown) { + super(message); + this.status = status; + this.body = body; + } +} + +export class LocalUpstreamPushCoordinator { + readonly #targetOrigin: string; + readonly #paperclipCompanyId: string; + readonly #fetch: typeof fetch; + readonly #headers: NonNullable; + + constructor(options: LocalUpstreamPushCoordinatorOptions) { + this.#targetOrigin = options.targetOrigin.replace(/\/+$/u, ""); + this.#paperclipCompanyId = options.paperclipCompanyId; + this.#fetch = options.fetch ?? fetch; + this.#headers = options.headers ?? (() => ({})); + } + + async preview(bundle: LocalUpstreamExportBundle): Promise { + return this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/preview`, { + manifest: bundle.manifest, + entities: bundle.entities, + }); + } + + async apply(bundle: LocalUpstreamExportBundle): Promise { + const run = await this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/runs`, { + mode: "apply", + manifest: bundle.manifest, + entities: bundle.entities, + }) as { run?: { id?: unknown } }; + const runId = typeof run.run?.id === "string" ? run.run.id : undefined; + if (!runId) { + throw new Error("Remote upstream importer did not return a run id"); + } + + for (const chunk of bundle.chunks) { + await this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/chunks`, chunk); + } + + return this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/apply`, {}); + } + + async events(runId: string): Promise { + return this.get(`/api/upstream-import-runs/${encodeURIComponent(runId)}/events`); + } + + private async get(path: string): Promise { + const response = await this.#fetch(`${this.#targetOrigin}${path}`, { + method: "GET", + headers: await this.#headers({ method: "GET", path }), + }); + return parseCoordinatorResponse(response); + } + + private async post(path: string, body: unknown): Promise { + const response = await this.#fetch(`${this.#targetOrigin}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(await this.#headers({ method: "POST", path })), + }, + body: JSON.stringify(body), + }); + return parseCoordinatorResponse(response); + } +} + +export function buildLocalUpstreamExportBundle( + input: BuildLocalUpstreamExportBundleInput, +): LocalUpstreamExportBundle { + const entities = input.entities.map((entity) => ({ + record: { + key: entity.key, + contentHash: normalizedContentHash(entity.body), + dependencies: entity.dependencies ?? [], + warnings: entity.warnings ?? [], + }, + body: entity.body, + conflictKeys: entity.conflictKeys, + })); + const chunks = buildLocalChunks(entities, input.maxEntitiesPerChunk ?? 100); + const manifestWithoutHash = { + schema: upstreamTransferSchema, + source: input.source, + target: input.target, + runId: input.runId, + idempotencyKey: input.idempotencyKey, + generatedAt: new Date(0).toISOString(), + entityCount: entities.length, + entities: entities.map((entity) => entity.record), + chunks: chunks.map(({ payload: _payload, ...chunk }) => chunk), + warnings: input.warnings ?? [], + featureFlags: (input.featureFlags ?? ["cloud_sync"]).slice().sort(), + }; + const manifestHash = normalizedContentHash(manifestWithoutHash); + return { + manifest: { + ...manifestWithoutHash, + chunks: manifestWithoutHash.chunks.map((chunk) => ({ ...chunk, manifestHash })), + manifestHash, + }, + entities, + chunks, + }; +} + +export function normalizedContentHash(value: unknown): NormalizedSha256 { + return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +} + +export function canonicalJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function buildLocalChunks( + entities: LocalUpstreamExportEntity[], + maxEntitiesPerChunk: number, +): LocalUpstreamExportChunk[] { + if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) { + throw new Error("maxEntitiesPerChunk must be a positive integer"); + } + if (entities.length === 0) return []; + + const groups: LocalUpstreamExportEntity[][] = []; + for (let index = 0; index < entities.length; index += maxEntitiesPerChunk) { + groups.push(entities.slice(index, index + maxEntitiesPerChunk)); + } + + return groups.map((group, index) => { + const payload = { + entityKeys: group.map((entity) => entity.record.key), + }; + return { + chunkIndex: index, + totalChunks: groups.length, + byteLength: Buffer.byteLength(canonicalJson(payload)), + sha256: normalizedContentHash(payload), + payload, + }; + }); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJson(entry)]), + ); +} + +async function parseCoordinatorResponse(response: Response): Promise { + const text = await response.text(); + const parsed = text.trim() ? safeParseJson(text) : {}; + if (!response.ok) { + const message = typeof parsed === "object" && parsed !== null && "error" in parsed + ? String((parsed as { error: unknown }).error) + : `Upstream importer request failed with ${response.status}`; + throw new UpstreamImportRequestError(response.status, message, parsed); + } + return parsed; +} + +function safeParseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/cli/src/commands/client/cloud.ts b/cli/src/commands/client/cloud.ts new file mode 100644 index 00000000000..891e44b1031 --- /dev/null +++ b/cli/src/commands/client/cloud.ts @@ -0,0 +1,715 @@ +import { createHash, generateKeyPairSync, randomBytes, randomUUID, sign } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { URL } from "node:url"; +import { Command } from "commander"; +import pc from "picocolors"; +import type { + CompanyPortabilityExportResult, + CompanyPortabilityFileEntry, + InstanceExperimentalSettings, +} from "@paperclipai/shared"; +import { openUrl } from "../../client/board-auth.js"; +import { resolvePaperclipInstanceId } from "../../config/home.js"; +import { + addCommonClientOptions, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; +import { + buildLocalUpstreamExportBundle, + LocalUpstreamPushCoordinator, + normalizedContentHash, + upstreamTransferSchema, + UpstreamImportRequestError, + type LocalUpstreamExportBundle, + type LocalUpstreamExportEntityInput, + type SourceEntityKey, + type UpstreamTransferManifestSource, + type UpstreamTransferManifestTarget, + type UpstreamTransferWarning, +} from "./cloud-transfer.js"; +import { + getCloudConnection, + upsertCloudConnection, + type CloudConnection, + type CloudConnectionTokenRecord, +} from "./cloud-store.js"; + +const CLOUD_SYNC_CONFLICT_EXIT_CODE = 2; +const CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE = 3; +const CLOUD_SYNC_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"]; + +interface CloudConnectOptions extends BaseClientOptions { + noBrowser?: boolean; +} + +interface CloudPushOptions extends BaseClientOptions { + company?: string; + remoteUrl?: string; + dryRun?: boolean; + maxEntitiesPerChunk?: number; +} + +interface UpstreamDiscovery { + schema: string; + stack: { + id: string; + slug?: string; + displayName?: string; + companyId: string; + origin: string; + }; + auth: { + pkce?: { + authorizeUrl: string; + tokenUrl: string; + codeChallengeMethod: string; + }; + deviceCode?: { + deviceCodeUrl: string; + verificationUrl: string; + tokenUrl: string; + }; + scopes?: string[]; + }; + transfer: { + supportedSchemaMajor: number; + featureFlags?: string[]; + }; +} + +interface TokenResponse { + accessToken: string; + token: CloudConnectionTokenRecord; + scopes?: string[]; + expiresAt?: string; +} + +class CloudAuthRequestError extends Error { + readonly status: number; + readonly body: unknown; + + constructor(status: number, message: string, body: unknown) { + super(message); + this.status = status; + this.body = body; + } +} + +export function registerCloudCommands(program: Command): void { + const cloud = program.command("cloud").description("Paperclip Cloud upstream sync commands"); + + addCommonClientOptions( + cloud + .command("connect") + .description("Authorize this local instance to push into a Paperclip Cloud stack") + .argument("", "Paperclip Cloud stack URL") + .option("--no-browser", "Use the device-code flow instead of opening a browser", false) + .action(async (remoteUrl: string, opts: CloudConnectOptions) => { + try { + await connectCloud(remoteUrl, opts); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + cloud + .command("push") + .description("Preview or apply a local company push into the connected Paperclip Cloud stack") + .requiredOption("--company ", "Local company ID to export") + .option("--remote-url ", "Use a specific stored cloud connection") + .option("--dry-run", "Preview without applying", false) + .option("--max-entities-per-chunk ", "Chunk size for upstream uploads", (value) => Number(value), 100) + .action(async (opts: CloudPushOptions) => { + try { + await pushCloud(opts); + } catch (err) { + if (isSchemaMismatchError(err)) { + console.error(pc.red(err instanceof Error ? err.message : String(err))); + process.exitCode = CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE; + return; + } + handleCommandError(err); + } + }), + ); +} + +export async function connectCloud(remoteUrl: string, opts: CloudConnectOptions = {}): Promise { + const ctx = resolveCommandContext(opts); + const discovery = await discoverUpstream(remoteUrl); + assertDiscoveryCompatible(discovery); + const source = createSourceIdentity(); + const token = await authorizeConnection(discovery, source, { + noBrowser: Boolean(opts.noBrowser), + }); + const targetOrigin = discovery.stack.origin.replace(/\/+$/u, ""); + const targetHost = new URL(targetOrigin).host; + const now = new Date().toISOString(); + const connection = upsertCloudConnection({ + id: connectionId(targetOrigin), + remoteUrl, + targetOrigin, + targetHost, + stackId: discovery.stack.id, + stackSlug: discovery.stack.slug ?? null, + stackDisplayName: discovery.stack.displayName ?? null, + targetCompanyId: discovery.stack.companyId, + accessToken: token.accessToken, + token: token.token, + privateKeyPem: source.privateKeyPem, + sourcePublicKey: source.sourcePublicKey, + sourceInstanceId: source.sourceInstanceId, + sourceInstanceFingerprint: source.sourceInstanceFingerprint, + scopes: token.scopes ?? token.token.scopes ?? CLOUD_SYNC_SCOPES, + createdAt: now, + updatedAt: now, + }); + + if (ctx.json) { + printOutput(redactConnection(connection), { json: true }); + } else { + console.log(pc.bold("Connected to Paperclip Cloud")); + console.log(`stack=${connection.stackDisplayName ?? connection.stackSlug ?? connection.stackId}`); + console.log(`origin=${connection.targetOrigin}`); + console.log(`company=${connection.targetCompanyId}`); + } + return connection; +} + +export async function pushCloud(opts: CloudPushOptions): Promise { + const ctx = resolveCommandContext(opts, { requireCompany: false }); + const localCompanyId = requiredString(opts.company, "--company"); + await assertCloudSyncEnabled(ctx.api.get("/api/instance/settings/experimental")); + const connection = getCloudConnection(opts.remoteUrl); + if (!connection) { + throw new Error("No cloud connection found. Run `paperclipai cloud connect ` first."); + } + + const discovery = await discoverUpstream(connection.targetOrigin); + assertDiscoveryCompatible(discovery); + const bundle = await buildBundleFromLocalCompany({ + localCompanyId, + connection, + discovery, + localApi: ctx.api, + maxEntitiesPerChunk: opts.maxEntitiesPerChunk, + mode: opts.dryRun ? "preview" : "apply", + }); + const coordinator = new LocalUpstreamPushCoordinator({ + targetOrigin: connection.targetOrigin, + paperclipCompanyId: connection.targetCompanyId, + headers: ({ method, path }) => cloudProofHeaders(connection, method, path), + }); + + const result = opts.dryRun ? await coordinator.preview(bundle) : await coordinator.apply(bundle); + const runId = getRunId(result); + const events = !opts.dryRun && runId ? await coordinator.events(runId).catch(() => null) : null; + const summary = summarizeResult(result); + const conflictCount = summary.conflict + summary.staleMapping; + + if (ctx.json) { + printOutput({ result, events }, { json: true }); + } else { + console.log(pc.bold(opts.dryRun ? "Cloud Push Preview" : "Cloud Push Applied")); + console.log(`run=${runId ?? "-"}`); + console.log(`manifest=${bundle.manifest.manifestHash}`); + console.log( + `create=${summary.create} update=${summary.update} adopt=${summary.adopt} ` + + `skip=${summary.skip} conflict=${summary.conflict} staleMapping=${summary.staleMapping}`, + ); + printWarnings(result); + printConflicts(result); + printEvents(events); + } + + if (conflictCount > 0) { + process.exitCode = CLOUD_SYNC_CONFLICT_EXIT_CODE; + } + return result; +} + +export async function discoverUpstream(remoteUrl: string): Promise { + const base = new URL(remoteUrl); + const discoveryUrl = new URL("/.well-known/paperclip-upstream", base); + return requestCloudJson(discoveryUrl.toString(), { method: "GET" }); +} + +export function assertDiscoveryCompatible(discovery: UpstreamDiscovery): void { + if (discovery.schema !== "paperclip-upstream-discovery-v1") { + throw new Error("Remote URL is not a Paperclip Cloud upstream target."); + } + if (discovery.transfer.supportedSchemaMajor !== upstreamTransferSchema.major) { + throw new Error( + `Cloud upstream schema mismatch: local major ${upstreamTransferSchema.major}, remote supports ${discovery.transfer.supportedSchemaMajor}.`, + ); + } + if (!discovery.transfer.featureFlags?.includes("cloud_sync")) { + throw new Error("Remote Paperclip Cloud stack does not advertise the cloud_sync transfer flag."); + } +} + +export async function buildBundleFromLocalCompany(input: { + localCompanyId: string; + connection: CloudConnection; + discovery: UpstreamDiscovery; + localApi: { + post(path: string, body?: unknown): Promise; + }; + maxEntitiesPerChunk?: number; + mode: "preview" | "apply"; +}): Promise { + const exported = await input.localApi.post( + `/api/companies/${input.localCompanyId}/export`, + { + include: { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, + }, + expandReferencedSkills: true, + }, + ); + if (!exported) throw new Error("Local company export returned no data."); + + const sourceHash = normalizedContentHash({ + manifest: exported.manifest, + files: exported.files, + }); + const source: UpstreamTransferManifestSource = { + sourceInstanceId: input.connection.sourceInstanceId, + sourceCompanyId: input.localCompanyId, + sourceInstanceKeyFingerprint: input.connection.sourceInstanceFingerprint, + exporterVersion: "paperclipai-cli-cloud-v1", + sourceSchemaVersion: "paperclip-local-portability-v1", + }; + const target: UpstreamTransferManifestTarget = { + targetStackId: input.discovery.stack.id, + targetCompanyId: input.discovery.stack.companyId, + targetOrigin: input.discovery.stack.origin, + supportedSchemaMajor: input.discovery.transfer.supportedSchemaMajor, + }; + const entities = buildEntitiesFromPortableExport(input.localCompanyId, input.connection.sourceInstanceId, exported); + const idempotencyKey = [ + input.mode, + input.connection.sourceInstanceId, + input.localCompanyId, + input.discovery.stack.id, + sourceHash, + ].join(":"); + return buildLocalUpstreamExportBundle({ + source, + target, + runId: `local-${input.mode}-${shortHash(idempotencyKey)}`, + idempotencyKey, + entities, + warnings: exported.warnings.map((message): UpstreamTransferWarning => ({ + code: "local_company_export_warning", + severity: "warning", + message, + })), + featureFlags: ["cloud_sync"], + maxEntitiesPerChunk: input.maxEntitiesPerChunk, + }); +} + +async function authorizeConnection( + discovery: UpstreamDiscovery, + source: ReturnType, + opts: { noBrowser: boolean }, +): Promise { + if (!opts.noBrowser && canOpenBrowser() && discovery.auth.pkce) { + try { + return await authorizeWithBrowser(discovery, source); + } catch (error) { + console.error(pc.yellow(`Browser authorization failed; falling back to device-code flow. ${errorMessage(error)}`)); + } + } + if (!discovery.auth.deviceCode) { + throw new Error("Remote Paperclip Cloud stack does not support device-code authorization."); + } + return authorizeWithDeviceCode(discovery, source, { openBrowser: !opts.noBrowser && canOpenBrowser() }); +} + +async function authorizeWithBrowser( + discovery: UpstreamDiscovery, + source: ReturnType, +): Promise { + const pkce = discovery.auth.pkce; + if (!pkce) throw new Error("Remote did not advertise PKCE authorization."); + const callback = await startPkceCallbackServer(); + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const state = randomUUID(); + const authorizeUrl = new URL(pkce.authorizeUrl); + authorizeUrl.searchParams.set("redirectUri", callback.redirectUri); + authorizeUrl.searchParams.set("state", state); + authorizeUrl.searchParams.set("codeChallenge", challenge); + authorizeUrl.searchParams.set("codeChallengeMethod", "S256"); + authorizeUrl.searchParams.set("sourceInstanceId", source.sourceInstanceId); + authorizeUrl.searchParams.set("sourceInstanceFingerprint", source.sourceInstanceFingerprint); + authorizeUrl.searchParams.set("sourcePublicKey", source.sourcePublicKey); + authorizeUrl.searchParams.set("scopes", CLOUD_SYNC_SCOPES.join(" ")); + + try { + console.error(`Open this URL to approve cloud sync:\n${authorizeUrl.toString()}`); + if (!openUrl(authorizeUrl.toString())) { + throw new Error("Could not open a browser."); + } + const code = await callback.waitForCode(state); + return requestCloudJson(pkce.tokenUrl, { + method: "POST", + body: JSON.stringify({ + grantType: "authorization_code", + code, + redirectUri: callback.redirectUri, + codeVerifier: verifier, + }), + }); + } finally { + await callback.close(); + } +} + +async function authorizeWithDeviceCode( + discovery: UpstreamDiscovery, + source: ReturnType, + opts: { openBrowser: boolean }, +): Promise { + const device = discovery.auth.deviceCode; + if (!device) throw new Error("Remote did not advertise device-code authorization."); + const response = await requestCloudJson<{ + deviceCode: string; + userCode: string; + verificationUri: string; + expiresAt: string; + intervalSeconds?: number; + }>(device.deviceCodeUrl, { + method: "POST", + body: JSON.stringify({ + stackId: discovery.stack.id, + sourceInstanceId: source.sourceInstanceId, + sourceInstanceFingerprint: source.sourceInstanceFingerprint, + sourcePublicKey: source.sourcePublicKey, + scopes: CLOUD_SYNC_SCOPES, + }), + }); + console.error(pc.bold("Cloud device authorization required")); + console.error(`Open: ${response.verificationUri}`); + console.error(`Code: ${response.userCode}`); + if (opts.openBrowser) openUrl(response.verificationUri); + + const expiresAt = Date.parse(response.expiresAt); + const intervalMs = Math.max(500, (response.intervalSeconds ?? 5) * 1000); + while (!Number.isFinite(expiresAt) || Date.now() < expiresAt) { + await sleep(intervalMs); + try { + return await requestCloudJson(device.tokenUrl, { + method: "POST", + body: JSON.stringify({ + grantType: "device_code", + deviceCode: response.deviceCode, + }), + }); + } catch (error) { + if (error instanceof CloudAuthRequestError && error.body && typeof error.body === "object") { + const code = (error.body as { error?: unknown }).error; + if (code === "authorization_pending") continue; + } + throw error; + } + } + throw new Error("Device-code authorization expired before it was approved."); +} + +function buildEntitiesFromPortableExport( + localCompanyId: string, + sourceInstanceId: string, + exported: CompanyPortabilityExportResult, +): LocalUpstreamExportEntityInput[] { + const companyKey: SourceEntityKey = { + sourceInstanceId, + sourceCompanyId: localCompanyId, + sourceEntityType: "company", + sourceEntityId: localCompanyId, + sourceNaturalKey: exported.manifest.company?.name ?? localCompanyId, + }; + const entities: LocalUpstreamExportEntityInput[] = [ + { + key: companyKey, + body: { + kind: "paperclip_company_portability_manifest", + manifest: exported.manifest, + rootPath: exported.rootPath, + paperclipExtensionPath: exported.paperclipExtensionPath, + fileCount: Object.keys(exported.files).length, + }, + conflictKeys: [`company:${companyKey.sourceNaturalKey ?? localCompanyId}`], + }, + ]; + + for (const [filePath, entry] of Object.entries(exported.files).sort(([left], [right]) => left.localeCompare(right))) { + entities.push({ + key: { + sourceInstanceId, + sourceCompanyId: localCompanyId, + sourceEntityType: "company_setting", + sourceEntityId: shortHash(filePath), + sourceNaturalKey: filePath, + }, + body: { + kind: "paperclip_portable_file", + path: filePath, + entry: normalizePortableFileEntry(entry), + }, + dependencies: [companyKey], + conflictKeys: [`portable_file:${filePath}`], + }); + } + return entities; +} + +function normalizePortableFileEntry(entry: CompanyPortabilityFileEntry): Record { + if (typeof entry === "string") { + return { encoding: "utf8", data: entry }; + } + return { ...entry }; +} + +async function assertCloudSyncEnabled(settingsPromise: Promise): Promise { + const settings = await settingsPromise; + if (settings?.enableCloudSync !== true) { + throw new Error( + "Cloud sync is disabled. Enable the cloud sync experimental setting before running `paperclipai cloud push`.", + ); + } +} + +function cloudProofHeaders(connection: CloudConnection, method: string, pathAndSearch: string): Record { + const timestamp = new Date().toISOString(); + const nonce = randomUUID(); + const payload = [ + method, + connection.targetHost.toLowerCase(), + pathAndSearch, + connection.token.id, + connection.sourceInstanceId, + timestamp, + nonce, + ].join("\n"); + return { + Authorization: `Bearer ${connection.accessToken}`, + "X-Paperclip-Upstream-Source-Instance-Id": connection.sourceInstanceId, + "X-Paperclip-Upstream-Proof-Timestamp": timestamp, + "X-Paperclip-Upstream-Proof-Nonce": nonce, + "X-Paperclip-Upstream-Proof-Signature": sign( + null, + Buffer.from(payload, "utf8"), + connection.privateKeyPem, + ).toString("base64url"), + }; +} + +async function requestCloudJson(url: string, init: RequestInit): Promise { + const headers = new Headers(init.headers); + headers.set("accept", "application/json"); + if (init.body !== undefined && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + const response = await fetch(url, { ...init, headers }); + const text = await response.text(); + const parsed = text.trim() ? JSON.parse(text) as unknown : {}; + if (!response.ok) { + const message = typeof parsed === "object" && parsed !== null && "error" in parsed + ? String((parsed as { error: unknown }).error) + : `Cloud request failed with ${response.status}`; + throw new CloudAuthRequestError(response.status, message, parsed); + } + return parsed as T; +} + +function createSourceIdentity() { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString(); + const sourceInstanceFingerprint = `sha256:${createHash("sha256") + .update(publicKey.export({ type: "spki", format: "der" })) + .digest("hex")}`; + return { + sourceInstanceId: `paperclip-local-${resolvePaperclipInstanceId()}`, + sourceInstanceFingerprint, + sourcePublicKey, + privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; +} + +async function startPkceCallbackServer(): Promise<{ + redirectUri: string; + waitForCode: (state: string) => Promise; + close: () => Promise; +}> { + let resolveCode: ((code: string) => void) | null = null; + let rejectCode: ((error: Error) => void) | null = null; + let expectedState = ""; + const codePromise = new Promise((resolve, reject) => { + resolveCode = resolve; + rejectCode = reject; + }); + const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || state !== expectedState) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("Paperclip Cloud authorization failed. You can close this tab."); + rejectCode?.(new Error("Authorization callback was missing a valid code or state.")); + return; + } + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("Paperclip Cloud authorization complete. You can close this tab."); + resolveCode?.(code); + }); + await listenOnLoopback(server); + const address = server.address(); + if (typeof address !== "object" || !address?.port) { + throw new Error("Failed to start local authorization callback server."); + } + return { + redirectUri: `http://127.0.0.1:${address.port}/cloud/callback`, + waitForCode: (state: string) => { + expectedState = state; + return codePromise; + }, + close: () => closeServer(server), + }; +} + +function listenOnLoopback(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); +} + +function canOpenBrowser(): boolean { + if (process.platform === "darwin" || process.platform === "win32") return true; + return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); +} + +function summarizeResult(result: unknown): { + create: number; + update: number; + adopt: number; + skip: number; + conflict: number; + staleMapping: number; +} { + const summary = asRecord(asRecord(result)?.summary); + return { + create: numberValue(summary?.create), + update: numberValue(summary?.update), + adopt: numberValue(summary?.adopt), + skip: numberValue(summary?.skip), + conflict: numberValue(summary?.conflict), + staleMapping: numberValue(summary?.staleMapping), + }; +} + +function printWarnings(result: unknown): void { + const warnings = Array.isArray(asRecord(result)?.warnings) ? asRecord(result)?.warnings as unknown[] : []; + for (const warning of warnings) { + const record = asRecord(warning); + console.log(pc.yellow(`warning=${record?.code ?? "warning"} ${record?.message ?? ""}`.trim())); + } +} + +function printConflicts(result: unknown): void { + const conflicts = Array.isArray(asRecord(result)?.conflicts) ? asRecord(result)?.conflicts as unknown[] : []; + for (const conflict of conflicts.slice(0, 10)) { + const record = asRecord(conflict); + console.log(pc.red(`conflict=${record?.conflictKind ?? "target_conflict"} target=${record?.targetEntityId ?? "-"}`)); + } + if (conflicts.length > 10) console.log(pc.red(`conflicts_truncated=${conflicts.length - 10}`)); +} + +function printEvents(events: unknown): void { + const rows = Array.isArray(asRecord(events)?.events) ? asRecord(events)?.events as unknown[] : []; + for (const row of rows.slice(-10)) { + const event = asRecord(row); + console.log(pc.dim(`event=${event?.action ?? "-"} target=${event?.targetEntityId ?? "-"}`)); + } +} + +function getRunId(result: unknown): string | null { + const run = asRecord(asRecord(result)?.run); + return typeof run?.id === "string" ? run.id : null; +} + +function redactConnection(connection: CloudConnection): Record { + return { + id: connection.id, + remoteUrl: connection.remoteUrl, + targetOrigin: connection.targetOrigin, + stackId: connection.stackId, + targetCompanyId: connection.targetCompanyId, + scopes: connection.scopes, + expiresAt: connection.token.expiresAt, + }; +} + +function connectionId(targetOrigin: string): string { + return `cloud-${shortHash(targetOrigin)}`; +} + +function shortHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 16); +} + +function requiredString(value: unknown, label: string): string { + if (typeof value === "string" && value.trim()) return value.trim(); + throw new Error(`${label} is required.`); +} + +function numberValue(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : null; +} + +function isSchemaMismatchError(error: unknown): boolean { + if (error instanceof UpstreamImportRequestError) { + return JSON.stringify(error.body).toLowerCase().includes("schema"); + } + return error instanceof Error && error.message.toLowerCase().includes("schema mismatch"); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const cloudCommandExitCodes = { + conflict: CLOUD_SYNC_CONFLICT_EXIT_CODE, + schemaMismatch: CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE, +} as const; diff --git a/cli/src/index.ts b/cli/src/index.ts index f1a2084a727..b9fd115972c 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -19,6 +19,7 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js"; import { registerRoutineCommands } from "./commands/routines.js"; import { registerFeedbackCommands } from "./commands/client/feedback.js"; import { registerSecretCommands } from "./commands/client/secrets.js"; +import { registerCloudCommands } from "./commands/client/cloud.js"; import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js"; import { loadPaperclipEnvFile } from "./config/env.js"; import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js"; @@ -149,6 +150,7 @@ registerDashboardCommands(program); registerRoutineCommands(program); registerFeedbackCommands(program); registerSecretCommands(program); +registerCloudCommands(program); registerWorktreeCommands(program); registerEnvLabCommands(program); registerPluginCommands(program);