.agents/designs/project-replace-cli.md
Status: design (not yet implemented). Driver: Nedap. Replaces a Git-based GHA release pipeline with direct cross-instance API calls.
Move a project's content from one Activepieces instance to another (e.g. staging → prod) without using Git as a storage mechanism, and fail GitHub Actions cleanly on validation errors before any destructive write occurs.
project-releases feature for users who want diffsreplace only)Mirrored: flows + table schemas + folders + piece validation.
Source and destination are separate Activepieces deployments, each with its own DB, platform, and API keys. There is no shared projectId/platformId namespace. Cross-instance is the design constraint; same-instance multi-project happens to work as a special case.
| Resource | Match key |
|---|---|
| Project | Explicit DB id passed via --source-project / --dest-project |
| Flow | externalId (auto-set to apId() if unset on create) |
| Table | externalId |
| Folder | externalId (new column — DB migration in v1, backfill externalId = id) |
| Connection | externalId (preflight read-only on both sides) |
"Full replace, no diff" is a user-facing semantic — no merge UI, end state on dest equals source. Internally the server walks externalId sets to compute CREATE/UPDATE/DELETE ops. This is not a contradiction: externalId mirroring exists to keep webhook URLs stable, preserve run history, and make idempotent retry cheap.
POST /v1/projects/:projectId/replace
PrincipalType.SERVICE only (platform API key). No USER path. Project must belong to the platform owning the API key.platform.plan.projectReplaceEnabled){ error: "REPLACE_IN_PROGRESS", retryAfter } on contentionPROJECT_REPLACED on every attempt (success or failure){
"schemaVersion": 1,
"sourceActivepiecesVersion": "0.45.0",
"flows": [ /* full flow states with externalId */ ],
"tables": [ /* schema only — name, externalId, fields[], status, trigger */ ],
"folders": [ /* externalId, displayName, displayOrder */ ],
"requiredPieces": [ { "name": "@activepieces/piece-slack", "version": "1.2.3" } ]
}
dest >= source on same major. No override flag. Source version comes from sourceActivepiecesVersion.requiredPieces must match a piece on dest's registry exactly. No flag.requiredPieces entry with pieceType: 'CUSTOM' missing on dest → hard fail.pieceName. If missing → hard fail.Failure → 4xx with structured { errors: [{ kind, ... }] }. No writes. CLI exits with code 2.
Order (dependencies before dependents on creates; reversed on deletes):
For each item:
unchanged++); else writeNo-op detection uses a typed FlowFingerprint / TableFingerprint / FolderFingerprint struct (extracted comparable fields) plus deep-equality. No hashing, no canonical JSON.
Error semantics: continue on per-item errors (4xx-class), abort on systemic errors (5xx-class). All per-item errors collected and returned.
{
"applied": {
"flowsCreated": 1, "flowsUpdated": 2, "flowsDeleted": 0, "flowsUnchanged": 47,
"tablesCreated": 0, "tablesUpdated": 0, "tablesDeleted": 0, "tablesUnchanged": 5,
"foldersCreated": 0, "foldersUpdated": 1, "foldersDeleted": 0, "foldersUnchanged": 3
},
"failed": [
{ "kind": "flow", "externalId": "...", "op": "UPDATE", "error": "..." }
],
"durationMs": 1200
}
HTTP status: 200 if failed empty; 207 if any item failures; 5xx if aborted; 409 if lock held.
Single command. No config file. No env-var auto-resolution. No dry-run. Per-call flags only.
ap project replace \
--source-url https://staging.activepieces.com \
--source-token "$STAGING_TOKEN" \
--source-project "$STAGING_PROJECT_ID" \
--dest-url https://prod.activepieces.com \
--dest-token "$PROD_TOKEN" \
--dest-project "$PROD_PROJECT_ID"
--json: structured (matches server response shape)0 — apply succeeded, failed empty1 — apply succeeded, failed non-empty2 — preflight failed (4xx, no writes)3 — server abort (5xx) or lock conflict (409)4 — CLI/transport error (unreachable, bad token)CLI itself is thin: GET source state, POST dest endpoint, render response. No diff computation client-side.
externalId (string, unique per project) column to flow_folder entity. Backfill externalId = id for existing rows.ProjectState schema (extend with folders array)projectStateService.apply primitivesprojectDiffService.diff for mirror computationapplicationEvents for audit loggingNot reused:
selectedFlowsIds filterProjectRelease records and snapshot files (no rollback table; flow-version history covers it)failed[]; second run picks them upagentIds is auto-derived from flow content; treat as covered until it isn't)--allow-piece-version-skew (only if exact-match proves too strict in practice)