Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ Options:
- `-t, --token <token>` - Auth token
- `--preview` - Preview only
- `--dry-run` - Show what would be pushed without sending requests
- `--force` - Intentionally overwrite server changes after confirmation, with a backup
- `--yes` - Confirm `--force` without an interactive prompt

#### Protecting client edits

Pull saves a revision for each site and the shared library in `.primo/sync-state.json`, scoped to the source server. A normal push stops if that server data changed since the last successful pull or push. An existing site without a baseline is also blocked. Update both the CMS and CLI to use this protocol; an older server cannot be bypassed with `--force`.

From a workspace root, every included site and the library are checked before the first upload. Each import checks again before writing. If a client edits a later site during the push, earlier successful imports remain saved; the CLI stops and lists completed, failed, and unattempted targets. `--only <slug>` checks and pushes only that site.

Save your local work before pulling after a conflict. Pull is **not a merge** and may replace local files. No automatic pull, retry, or content merge happens on a conflict.

To intentionally replace server data with your local files:

```bash
primo push --force # Lists targets and asks for confirmation
primo push --only my-site --force # Overwrite one site
primo push --force --yes # Explicit confirmation for scripts
```

Before each overwrite, the server saves a ZIP export under `pb_data/push_backups/`. If backup creation fails, that import is rejected. The CLI prints an authenticated download URL and saves a copy under the target's `.primo/backups/` directory. Server backups are retained until an operator removes them. A new edit after preflight/confirmation still stops a forced push. The ZIP also includes original records in `.primo/backup-records.json` for operator-assisted recovery of properties the portable importer cannot yet round-trip.

To recover content, extract the backup into a **separate directory**, set the intended `server` in its `site.yaml`, review it, then push that directory with `--force`. For a library backup, extract into a separate workspace and use `primo library push <server> --dir <workspace> --force`. Recovery creates another backup before overwriting. Push changes CMS data; publishing the website remains a separate action.

`primo library push` supports the same `--force` and `--yes` options. Local `primo dev` watcher imports retain their author-mode behavior; explicit `primo push` requests are always checked.

### `primo pull`

Expand Down
4 changes: 2 additions & 2 deletions src/commands/pull-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from 'fs/promises'
import path from 'path'
import chalk from 'chalk'
import ora from 'ora'
import extract from 'extract-zip'
import { install_library_export } from '../utils/pull-library-export.js'
import { get_auth_token } from '../utils/auth.js'
import { read_server_config, normalize_server_url } from '../utils/server-config.js'

Expand Down Expand Up @@ -84,7 +84,7 @@ export async function pull_library(options: PullLibraryOptions) {
await fs.writeFile(temp_zip, Buffer.from(zip_data))

spinner.text = 'Extracting library...'
await extract(temp_zip, { dir: output_dir })
await install_library_export(temp_zip, output_dir, server, response.headers.get('x-primo-revision'))
await fs.unlink(temp_zip)

const summary = await count_library(path.join(output_dir, 'library'))
Expand Down
13 changes: 9 additions & 4 deletions src/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import path from 'path'
import chalk from 'chalk'
import ora, { type Ora } from 'ora'
import extract from 'extract-zip'
import { save_baseline } from '../utils/push-guard.js'
import { install_library_export } from '../utils/pull-library-export.js'
import { dump as dump_yaml, load as load_yaml } from 'js-yaml'
import { get_auth_token } from '../utils/auth.js'
import { authenticate_interactively } from './login.js'
import { write_site_config } from '../utils/site-config.js'
import { read_site_config, write_site_config } from '../utils/site-config.js'
import { read_server_config, write_server_config, normalize_server_url, type ServerConfig, type SiteGroupConfig } from '../utils/server-config.js'
import { generate_agent_md } from './new.js'

Expand Down Expand Up @@ -280,6 +282,7 @@ async function pull_one_site(
const temp_zip = path.join(site_dir, '.primo-export.zip')
await fs.writeFile(temp_zip, Buffer.from(zip_data))

await save_baseline(site_dir, server, site.id, null)
spinner.text = `Extracting ${site.name}...`
const temp_dir = path.join(site_dir, '.primo', `pull-temp-${Date.now()}`)
let trashed: string[] = []
Expand All @@ -304,15 +307,17 @@ async function pull_one_site(
}
}

const exported_config = await read_site_config(site_dir).catch(() => null)
await write_site_config(site_dir, {
name: site.name || 'Imported Site',
name: exported_config?.name || site.name || 'Imported Site',
site_id: site.id,
server,
group: site.group
group: exported_config?.group ?? site.group
})

await copy_schemas(site_dir)
await add_schema_references(site_dir)
await save_baseline(site_dir, server, site.id, response.headers.get('x-primo-revision'))
}

// Move local files under MANAGED_DIRS that have no counterpart in the fresh
Expand Down Expand Up @@ -416,7 +421,7 @@ async function pull_library_into(
const zip_data = await response.arrayBuffer()
const temp_zip = path.join(root_dir, '.primo-library-export.zip')
await fs.writeFile(temp_zip, Buffer.from(zip_data))
await extract(temp_zip, { dir: root_dir })
await install_library_export(temp_zip, root_dir, server, response.headers.get('x-primo-revision'))
await fs.unlink(temp_zip)
return true
}
Expand Down
11 changes: 10 additions & 1 deletion src/commands/push-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import path from 'path'
import chalk from 'chalk'
import ora from 'ora'
import archiver from 'archiver'
import { prepare_push, append_push_guard, finish_push } from '../utils/push-guard.js'
import { get_auth_token } from '../utils/auth.js'
import { normalize_server_url } from '../utils/server-config.js'

interface PushLibraryOptions {
server?: string
dir: string
token?: string
force?: boolean
yes?: boolean
}

function is_local_server(server: string): boolean {
Expand Down Expand Up @@ -50,12 +53,15 @@ export async function push_library(options: PushLibraryOptions) {
process.exit(1)
}

spinner.text = 'Packaging library...'
spinner.stop()
const [plan] = await prepare_push([{dir: workspace_dir, server, target: 'library', token, label: 'library'}], options)
spinner.start('Packaging library...')
const zip_buffer = await create_library_zip(workspace_dir)

spinner.text = 'Pushing library...'
const form_data = new FormData()
form_data.append('file', new Blob([zip_buffer]), 'library.zip')
append_push_guard(form_data, plan)

const headers: Record<string, string> = {}
if (token) {
Expand All @@ -80,10 +86,13 @@ export async function push_library(options: PushLibraryOptions) {
}

const result = await response.json() as {
revision?: string
backup?: string
success?: boolean
summary?: { groups: number; blocks: number }
}

await finish_push(plan, result)
spinner.succeed('Library push complete')
if (result.summary) {
console.log('')
Expand Down
Loading
Loading