diff --git a/README.md b/README.md index 126d09d..735db5f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,30 @@ Options: - `-t, --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 ` 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 --dir --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` diff --git a/src/commands/pull-library.ts b/src/commands/pull-library.ts index 239d06b..45b3cff 100644 --- a/src/commands/pull-library.ts +++ b/src/commands/pull-library.ts @@ -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' @@ -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')) diff --git a/src/commands/pull.ts b/src/commands/pull.ts index e418342..9592c55 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -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' @@ -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[] = [] @@ -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 @@ -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 } diff --git a/src/commands/push-library.ts b/src/commands/push-library.ts index 9fed884..7e26a65 100644 --- a/src/commands/push-library.ts +++ b/src/commands/push-library.ts @@ -3,6 +3,7 @@ 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' @@ -10,6 +11,8 @@ interface PushLibraryOptions { server?: string dir: string token?: string + force?: boolean + yes?: boolean } function is_local_server(server: string): boolean { @@ -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 = {} if (token) { @@ -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('') diff --git a/src/commands/push.ts b/src/commands/push.ts index 2ea866f..78a2efe 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -3,6 +3,7 @@ import path from 'path' import chalk from 'chalk' import ora, { type Ora } from 'ora' import archiver from 'archiver' +import { prepare_push, append_push_guard, finish_push, response_error, type PushPlan, type PushTarget } from '../utils/push-guard.js' import { dump as dump_yaml, load as load_yaml } from 'js-yaml' import { get_auth_token } from '../utils/auth.js' import { read_site_config, get_site_config_path, type SiteConfig, SITE_CONFIG_FILE } from '../utils/site-config.js' @@ -17,6 +18,8 @@ interface PushOptions { token?: string preview?: boolean dryRun?: boolean + force?: boolean + yes?: boolean } async function path_exists(p: string): Promise { @@ -399,83 +402,75 @@ async function print_push_dry_run(root_dir: string, has_site_yaml: boolean, has_ console.log('') } -// Pushes every site folder plus the library, continuing past individual -// failures. Returns the labels that failed — the caller decides how loudly a -// partial push should fail. +async function site_target(site_dir: string, options: PushOptions): Promise { + const config = await read_site_config(site_dir) + const server_raw = options.server || config.server + if (!server_raw) throw new Error(`Server URL required for ${path.basename(site_dir)}.`) + const server = normalize_server_url(server_raw) + const target = options.site || config.site_id + if (!target) throw new Error(`Site ID required for ${path.basename(site_dir)}.`) + return { dir: site_dir, server, target, token: options.token || await get_auth_token(server), label: path.basename(site_dir) } +} + +// Preflight every target, including the library, before the first upload. Each +// import rechecks its revision; a late conflict stops the remaining uploads. async function push_server(root_dir: string, options: PushOptions): Promise { - // Sites live under sites// const sites_root = path.join(root_dir, 'sites') const site_dirs: string[] = [] if (await path_exists(sites_root)) { - const entries = await fs.readdir(sites_root, { withFileTypes: true }) - for (const entry of entries) { + for (const entry of await fs.readdir(sites_root, { withFileTypes: true })) { if (!entry.isDirectory() || entry.name.startsWith('.')) continue const candidate = path.join(sites_root, entry.name) - if (await path_exists(get_site_config_path(candidate))) { - site_dirs.push(candidate) - } + if (await path_exists(get_site_config_path(candidate))) site_dirs.push(candidate) } } - - if (site_dirs.length === 0) { - console.log(chalk.yellow(' No site folders found in this server directory.')) - process.exit(1) + site_dirs.sort() + const selected = options.only ? site_dirs.filter(dir => path.basename(dir) === options.only) : site_dirs + if (!selected.length) { + console.error(options.only ? `No site folder named "${options.only}" under sites/.` : 'No site folders found in this server directory.') + return [options.only || 'sites'] } - - // --only : push just one site folder, skip the library - if (options.only) { - const match = site_dirs.find((d) => path.basename(d) === options.only) - if (!match) { - const available = site_dirs.map((d) => path.basename(d)).join(', ') - console.log(chalk.red(` No site folder named "${options.only}" under sites/.`)) - console.log(chalk.dim(` Available: ${available}`)) - process.exit(1) - } - const spinner = ora(`Pushing ${chalk.cyan(path.basename(match))}...`).start() - try { - await push_single_site(match, { ...options, dir: match }, spinner) - } catch (error) { - spinner.fail(`${path.basename(match)}: ${error instanceof Error ? error.message : error}`) - if (is_auth_error(error)) print_auth_hint() - process.exit(1) - } - return [] - } - - let saw_auth_error = false - const failed: string[] = [] - - // Push each site - for (const site_dir of site_dirs) { - const spinner = ora(`Pushing ${chalk.cyan(path.basename(site_dir))}...`).start() - try { - await push_single_site(site_dir, { ...options, dir: site_dir }, spinner) - } catch (error) { - spinner.fail(`${path.basename(site_dir)}: ${error instanceof Error ? error.message : error}`) - if (is_auth_error(error)) saw_auth_error = true - failed.push(path.basename(site_dir)) - // Continue to remaining sites rather than abort the whole push + let plans: PushPlan[] + try { + const targets = await Promise.all(selected.map(dir => site_target(dir, options))) + if (!options.only && await path_exists(path.join(root_dir, 'library'))) { + const server = options.server ? normalize_server_url(options.server) : targets[0].server + targets.push({ dir: root_dir, server, target: 'library', token: options.token || await get_auth_token(server), label: 'library' }) } + plans = await prepare_push(targets, options) + } catch (error) { + console.error(error instanceof Error ? error.message : error) + return selected.map(dir => path.basename(dir)) } - - // Push library if present - const library_dir = path.join(root_dir, 'library') - if (await path_exists(library_dir)) { - const spinner = ora('Pushing library...').start() + const completed: string[] = [] + for (let index = 0; index < plans.length; index++) { + const plan = plans[index] + const spinner = ora(`Pushing ${plan.label}...`).start() try { - await push_library_dir(root_dir, options, spinner) + if (plan.target === 'library') { + if (options.preview) { spinner.info('Library preview is not supported; no library upload sent.'); continue } + await push_library_dir(root_dir, options, spinner, plan) + } else { + await push_single_site(plan.dir, options, spinner, plan) + } + completed.push(plan.label) } catch (error) { - spinner.fail(`library: ${error instanceof Error ? error.message : error}`) - if (is_auth_error(error)) saw_auth_error = true - failed.push('library') + spinner.fail(`${plan.label}: ${error instanceof Error ? error.message : error}`) + if (is_auth_error(error)) print_auth_hint() + console.log(`Completed: ${completed.join(', ') || 'none'}`) + console.log(`Failed: ${plan.label}`) + console.log(`Not attempted: ${plans.slice(index + 1).map(p => p.label).join(', ') || 'none'}`) + console.log('Earlier successful imports remain saved. The failed request may need verification if its response was lost.') + return plans.slice(index).map(p => p.label) } } - - if (saw_auth_error) print_auth_hint() - return failed + return [] } -async function push_single_site(site_dir: string, options: PushOptions, spinner: Ora) { +async function push_single_site(site_dir: string, options: PushOptions, spinner: Ora, prepared?: PushPlan) { + spinner.stop() + const plan = prepared || (await prepare_push([await site_target(site_dir, options)], options))[0] + spinner.start() let config: SiteConfig | null = null try { config = await read_site_config(site_dir) @@ -483,18 +478,7 @@ async function push_single_site(site_dir: string, options: PushOptions, spinner: // No config file, must provide options } - const server_raw = options.server || config?.server - const server = server_raw ? normalize_server_url(server_raw) : undefined - const site_id = options.site || config?.site_id - - if (!server) { - throw new Error(`Server URL required. Use --server or add server field to ${SITE_CONFIG_FILE}.`) - } - if (!site_id) { - throw new Error(`Site ID required. Use --site or add site_id field to ${SITE_CONFIG_FILE}.`) - } - - const token = options.token || await get_auth_token(server) + const { server, token, target: site_id } = plan spinner.text = 'Packaging files...' const zip_buffer = await create_zip(site_dir) @@ -505,12 +489,14 @@ async function push_single_site(site_dir: string, options: PushOptions, spinner: // the server has zero sites), so it's the right path for first-time // setup against a fresh deployment. if (!token) { + if (plan.exists) throw new Error('Authentication required to update an existing site. Run `primo login` first.') if (options.preview) { throw new Error('Authentication required for --preview. Run `primo login` first.') } spinner.text = 'No auth token — attempting bootstrap...' - const bootstrap_result = await try_bootstrap_site(server, undefined, zip_buffer, config, site_id, group_name) + const bootstrap_result = await try_bootstrap_site(server, undefined, zip_buffer, config, site_id, group_name, plan) if (bootstrap_result.ok) { + await finish_push(plan, bootstrap_result) spinner.succeed(`Bootstrapped ${config?.name || path.basename(site_dir)}`) console.log('') console.log(chalk.dim(' Site created on server and content uploaded.')) @@ -532,39 +518,20 @@ async function push_single_site(site_dir: string, options: PushOptions, spinner: const form_data = new FormData() form_data.append('file', new Blob([zip_buffer]), 'site.zip') if (group_name) form_data.append('group_name', group_name) + append_push_guard(form_data, plan) const response = await fetch(endpoint, { method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, + headers: token ? { 'Authorization': `Bearer ${token}` } : {}, body: form_data }) - // 404 from import means the site doesn't exist on the server yet. On a - // freshly-deployed server we can fall back to /api/primo/bootstrap, - // which creates the site and ingests the zip in one shot. Bootstrap is - // only available when the server has zero sites — past the first site, - // new sites must be created via the dashboard UI. - if (response.status === 404 && !options.preview) { - spinner.text = 'Site not found on server — bootstrapping...' - const bootstrap_result = await try_bootstrap_site(server, token, zip_buffer, config, site_id, group_name) - if (bootstrap_result.ok) { - spinner.succeed(`Bootstrapped ${config?.name || path.basename(site_dir)}`) - console.log('') - console.log(chalk.dim(' Site created on server and content uploaded.')) - console.log(chalk.dim(' Subsequent pushes will use the import endpoint.')) - // Converge local upload refs/filenames with the ids the server minted - // (see the import path below for why). - await apply_upload_writeback(site_dir, await root_dir_for(site_dir), bootstrap_result.created_ids) - return - } - throw new Error(bootstrap_result.error) - } if (!response.ok) { - throw new Error(await response.text()) + throw new Error(await response_error(response)) } - const result = await response.json() as { preview?: boolean; success?: boolean; diff: PushDiff; created_ids?: CreatedIDs } + const result = await response.json() as { preview?: boolean; success?: boolean; diff: PushDiff; created_ids?: CreatedIDs; revision?: string; backup?: string } const label = config?.name || path.basename(site_dir) if (options.preview) { @@ -574,6 +541,7 @@ async function push_single_site(site_dir: string, options: PushOptions, spinner: console.log('') console.log(chalk.dim(' Run without --preview to apply these changes')) } else { + await finish_push(plan, result) spinner.succeed(`Pushed ${label}`) console.log('') print_diff(result.diff) @@ -597,10 +565,12 @@ async function try_bootstrap_site( zip_buffer: Buffer, config: SiteConfig | null, site_id: string, - group_name?: string -): Promise<{ ok: true; created_ids?: CreatedIDs } | { ok: false; error: string }> { + group_name?: string, + plan?: PushPlan +): Promise<{ ok: true; created_ids?: CreatedIDs; revision?: string } | { ok: false; error: string }> { const form = new FormData() form.append('site_id', site_id) + if (plan) append_push_guard(form, plan) if (config?.name) form.append('name', config.name) if (config?.group) form.append('group', config.group) if (group_name) form.append('group_name', group_name) @@ -622,8 +592,8 @@ async function try_bootstrap_site( }) if (response.ok) { - const body = await response.json().catch(() => ({})) as { created_ids?: CreatedIDs } - return { ok: true, created_ids: body.created_ids } + const body = await response.json().catch(() => ({})) as { created_ids?: CreatedIDs; revision?: string } + return { ok: true, created_ids: body.created_ids, revision: body.revision } } if (response.status === 403) { @@ -639,29 +609,8 @@ async function try_bootstrap_site( return { ok: false, error: await response.text() } } -async function push_library_dir(root_dir: string, options: PushOptions, spinner: Ora) { - // Resolve server: --server > any site.yaml's server (they all point at the same server) - let server = options.server ? normalize_server_url(options.server) : undefined - if (!server) { - const sites_root = path.join(root_dir, 'sites') - if (await path_exists(sites_root)) { - const entries = await fs.readdir(sites_root, { withFileTypes: true }) - for (const entry of entries) { - if (!entry.isDirectory()) continue - try { - const config = await read_site_config(path.join(sites_root, entry.name)) - if (config.server) { - server = config.server.replace(/\/+$/, '') - break - } - } catch {} - } - } - } - if (!server) throw new Error('Server URL required for library push.') - - const token = options.token || await get_auth_token(server) - if (!token) throw new Error('Authentication required. Run `primo login` first.') +async function push_library_dir(root_dir: string, options: PushOptions, spinner: Ora, plan: PushPlan) { + const { server, token } = plan spinner.text = 'Packaging library...' const archive = archiver('zip', { zlib: { level: 9 } }) @@ -677,22 +626,23 @@ async function push_library_dir(root_dir: string, options: PushOptions, spinner: 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 response = await fetch(`${server}/api/primo/import-library`, { method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, + headers: token ? { 'Authorization': `Bearer ${token}` } : {}, body: form_data }) if (response.status === 404) { - spinner.warn('Library push not supported by this server — skipping') - return + throw new Error('Library push endpoint disappeared after preflight; no fallback attempted.') } if (!response.ok) { - throw new Error(await response.text()) + throw new Error(await response_error(response)) } - const result = await response.json() as { summary?: { groups: number; blocks: number } } + const result = await response.json() as { summary?: { groups: number; blocks: number }; revision?: string; backup?: string } + await finish_push(plan, result) spinner.succeed('Pushed library') if (result.summary) { console.log(chalk.dim(` groups/ ${result.summary.groups}, blocks/ ${result.summary.blocks}`)) diff --git a/src/index.ts b/src/index.ts index 038cf85..c9c7eb3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -126,6 +126,8 @@ program .option('--only ', 'Push only the named site folder under sites/ (skips library)') .option('-d, --dir ', 'Directory', '.') .option('-t, --token ', 'Auth token') + .option('--force', 'Overwrite server changes after confirmation, saving a backup first') + .option('--yes', 'Confirm --force without an interactive prompt') .option('--preview', 'Preview only') .option('--dry-run', 'Show what would be pushed without sending requests') .addHelpText('after', ` @@ -168,6 +170,8 @@ library library .command('push [server]') .description('Push local shared library to hosted CMS') + .option('--force', 'Overwrite server library changes after confirmation, saving a backup first') + .option('--yes', 'Confirm --force without an interactive prompt') .option('-s, --server ', 'Server URL') .option('-d, --dir ', 'Workspace directory', '.') .option('-t, --token ', 'Auth token') diff --git a/src/utils/pull-library-export.ts b/src/utils/pull-library-export.ts new file mode 100644 index 0000000..ac6fcb1 --- /dev/null +++ b/src/utils/pull-library-export.ts @@ -0,0 +1,32 @@ +import fs from 'fs/promises' +import path from 'path' +import extract from 'extract-zip' +import { save_baseline } from './push-guard.js' + +// Install the exact exported library before recording its revision. Overlaying +// a ZIP leaves locally stale blocks behind after a server-side deletion, which +// would let the next push resurrect data under an incorrectly fresh baseline. +export async function install_library_export(archive: string, root: string, server: string, revision: string | null) { + const state_dir = path.join(root, '.primo') + await fs.mkdir(state_dir, { recursive: true }) + const temp = await fs.mkdtemp(path.join(state_dir, 'library-pull-')) + try { + await extract(archive, { dir: temp }) + await save_baseline(root, server, 'library', null) + const destination = path.join(root, 'library') + const trash = path.join(state_dir, 'trash', path.basename(temp)) + await fs.mkdir(path.dirname(trash), { recursive: true }) + try { + await fs.rename(destination, trash) + console.log(` Previous local library saved to ${trash}`) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + // An empty server library can legitimately export no library/ entries. + await fs.mkdir(path.join(temp, 'library'), { recursive: true }) + await fs.rename(path.join(temp, 'library'), destination) + await save_baseline(root, server, 'library', revision) + } finally { + await fs.rm(temp, { recursive: true, force: true }) + } +} diff --git a/src/utils/push-guard.ts b/src/utils/push-guard.ts new file mode 100644 index 0000000..524e90b --- /dev/null +++ b/src/utils/push-guard.ts @@ -0,0 +1,130 @@ +import fs from 'fs/promises' +import path from 'path' +import inquirer from 'inquirer' +import { normalize_server_url } from './server-config.js' + +export interface PushTarget { + dir: string + server: string + target: string + token?: string | null + label: string +} + +export interface PushPlan extends PushTarget { + revision: string + exists: boolean + force: boolean +} + +interface Baseline { revision: string } +const revision_valid = (value: unknown): value is string => typeof value === 'string' && /^(absent|v1:[a-f0-9]{64})$/.test(value) +const state_path = (dir: string) => path.join(dir, '.primo', 'sync-state.json') +const state_key = (server: string, target: string) => JSON.stringify([normalize_server_url(server), target]) + +async function read_baselines(dir: string): Promise> { + try { + const value = JSON.parse(await fs.readFile(state_path(dir), 'utf8')) + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid sync-state.json') + return value + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw error + } +} + +// Save only the revision of the exact export/import response, never one fetched +// afterward: that could bless a client edit our local files have never seen. +export async function save_baseline(dir: string, server: string, target: string, revision: string | null) { + const baselines = await read_baselines(dir) + const key = state_key(server, target) + if (revision_valid(revision)) baselines[key] = { revision } + else delete baselines[key] // A legacy/failed pull must not retain an old baseline. + await fs.mkdir(path.dirname(state_path(dir)), { recursive: true }) + const temp = `${state_path(dir)}.${process.pid}.tmp` + await fs.writeFile(temp, JSON.stringify(baselines, null, 2) + '\n', { mode: 0o600 }) + await fs.rename(temp, state_path(dir)) +} + +export async function prepare_push(targets: PushTarget[], options: { force?: boolean; yes?: boolean; preview?: boolean }): Promise { + const identities = targets.map(target => state_key(target.server, target.target)) + if (new Set(identities).size !== identities.length) throw new Error('Multiple local folders target the same server site. Push stopped before any uploads.') + const plans: PushPlan[] = [] + const errors: string[] = [] + for (const target of targets) { + try { + const response = await fetch(`${target.server}/api/primo/push-state/${encodeURIComponent(target.target)}`, { + headers: target.token ? { Authorization: `Bearer ${target.token}` } : {}, + signal: AbortSignal.timeout(30000) + }) + if (response.status === 404) throw new Error('Server does not support safe pushes. Update the CMS first; no upload was attempted.') + if (!response.ok) throw new Error(await response_error(response)) + const state = await response.json() as { protocol?: number; exists?: boolean; revision?: string } + if (state.protocol !== 1 || typeof state.exists !== 'boolean' || !revision_valid(state.revision)) { + throw new Error('Server returned an invalid push revision; cannot verify that pushing is safe.') + } + const baseline = (await read_baselines(target.dir))[state_key(target.server, target.target)] + const stale = baseline ? baseline.revision !== state.revision : state.exists + if (stale && !options.force && !options.preview) { + throw new Error(baseline + ? 'changed on the server since the last sync (or was deleted).' + : 'has no saved baseline for this server. Pull first, or explicitly overwrite with --force.') + } + if (stale && options.preview) console.log(` ${target.label}: server changes detected; a normal push would be blocked.`) + plans.push({ ...target, revision: state.revision, exists: state.exists, force: !!options.force && !options.preview }) + } catch (error) { + errors.push(`${target.label}: ${error instanceof Error ? error.message : error}`) + } + } + if (errors.length) throw new Error(`Push stopped before any uploads:\n ${errors.join('\n ')}\nNothing was changed. Save your local work before pulling. An intentional overwrite requires --force.`) + const overwrites = plans.filter(plan => plan.force && plan.exists) + if (overwrites.length) { + console.log('The following server data will be replaced with your local files:') + for (const plan of overwrites) console.log(` ${plan.label} (${plan.server})`) + console.log('A server backup will be created before each overwrite. Edits after this check will still stop the push.') + if (!options.yes) { + if (!process.stdin.isTTY) throw new Error('Overwrite requires confirmation. For non-interactive use, explicitly pass --force --yes.') + const { proceed } = await inquirer.prompt([{ type: 'confirm', name: 'proceed', message: 'Replace this server data with your local files?', default: false }]) + if (!proceed) throw new Error('Push cancelled. Nothing was changed.') + } + } + return plans +} + +export function append_push_guard(form: FormData, plan: PushPlan) { + form.append('expected_revision', plan.revision) + if (plan.force) form.append('force', 'true') +} + +export async function response_error(response: Response): Promise { + const text = await response.text() + try { return JSON.parse(text).message || text } catch { return text } +} + +export async function finish_push(plan: PushPlan, result: { revision?: string; backup?: string }) { + if (!revision_valid(result.revision)) throw new Error('Server applied the push but did not return a valid revision. Pull before pushing again.') + try { + await save_baseline(plan.dir, plan.server, plan.target, result.revision) + } catch (error) { + throw new Error(`Server applied the push, but saving its local baseline failed: ${error instanceof Error ? error.message : error}. Pull before pushing again.`) + } + if (plan.force && plan.exists && !result.backup) throw new Error('Server applied the overwrite but did not return a backup reference. Check server backups before proceeding.') + if (!result.backup) return + if (!/^backup-[0-9]+\.zip$/.test(result.backup)) throw new Error('Server returned an invalid backup reference.') + const url = `${plan.server}/api/primo/push-backups/${encodeURIComponent(plan.target)}/${result.backup}` + console.log(` Server backup: ${url}`) + try { + const response = await fetch(url, { + headers: plan.token ? { Authorization: `Bearer ${plan.token}` } : {}, + signal: AbortSignal.timeout(60000) + }) + if (!response.ok) throw new Error(await response_error(response)) + const backup_dir = path.join(plan.dir, '.primo', 'backups', plan.target) + await fs.mkdir(backup_dir, { recursive: true }) + const file = path.join(backup_dir, result.backup) + await fs.writeFile(file, Buffer.from(await response.arrayBuffer()), { mode: 0o600 }) + console.log(` Local backup: ${file}`) + } catch (error) { + console.warn(` Could not download the backup; the server copy is retained at the URL above: ${error instanceof Error ? error.message : error}`) + } +} diff --git a/tests/helpers/mock-server.mjs b/tests/helpers/mock-server.mjs index 42ffe51..ee3df5c 100644 --- a/tests/helpers/mock-server.mjs +++ b/tests/helpers/mock-server.mjs @@ -10,8 +10,12 @@ import archiver from 'archiver' * 404'd against every real server; nothing in this repo would have noticed. * Recording requests here is what makes that class of break visible. */ -export async function start_mock_server({ sites = [], export_files = {}, site_groups = [] } = {}) { +export async function start_mock_server({ sites = [], export_files = {}, site_groups = [], revisions = {}, on_request, unsupported_guard = false, legacy_export = false } = {}) { const requests = [] + const initial_revision = 'v1:' + 'a'.repeat(64) + for (const site of sites) revisions[site.id] ??= initial_revision + revisions.library ??= initial_revision + let sequence = 1 const server = http.createServer(async (req, res) => { const url = new URL(req.url, 'http://127.0.0.1') @@ -22,9 +26,21 @@ export async function start_mock_server({ sites = [], export_files = {}, site_gr query: Object.fromEntries(url.searchParams), authorization: req.headers.authorization ?? null, content_type: req.headers['content-type'] ?? null, - body_length: body.length + body_length: body.length, body }) + if (on_request) await on_request({ req, url, body, revisions, requests }) + const state_match = url.pathname.match(/^\/api\/primo\/push-state\/([^/]+)$/) + if (state_match && !unsupported_guard) { + const target = state_match[1] + return json(res, 200, { protocol: 1, exists: revisions[target] !== 'absent', revision: revisions[target] || 'absent' }) + } + if (url.pathname.includes('/api/primo/push-backups/')) { + const zip = await make_zip(export_files) + res.writeHead(200, { 'Content-Type': 'application/zip' }) + return res.end(zip) + } + if (url.pathname === '/api/health') { return json(res, 200, { status: 'ok' }) } @@ -55,24 +71,26 @@ export async function start_mock_server({ sites = [], export_files = {}, site_gr return json(res, 404, { message: 'no such site' }) } const zip = await make_zip(export_files) - res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Length': zip.length }) + res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Length': zip.length, ...(!legacy_export ? {'X-Primo-Revision':revisions[site_id]} : {}) }) return res.end(zip) } if (url.pathname === '/api/primo/export-library' && req.method === 'GET') { const zip = await make_zip({ 'blocks/.keep': '' }) - res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Length': zip.length }) + res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Length': zip.length, ...(!legacy_export ? {'X-Primo-Revision':revisions.library} : {}) }) return res.end(zip) } - if (/^\/api\/primo\/import\/[^/]+$/.test(url.pathname) && req.method === 'POST') { - // `diff` is required, not optional: push pipes it straight into - // print_diff, which Object.entries() it. Omitting it fails with a - // bare "Cannot convert undefined or null to object". + const import_match = url.pathname.match(/^\/api\/primo\/import\/([^/]+)(\/preview)?$/) + if ((import_match || url.pathname === '/api/primo/import-library') && req.method === 'POST') { + const target = import_match ? import_match[1] : 'library' + const form = await new Response(body, {headers: {'Content-Type':req.headers['content-type']}}).formData() + if (!import_match?.[2] && form.get('expected_revision') !== revisions[target]) return json(res, 409, {message:'Server changed after preflight'}) + if (!import_match?.[2]) revisions[target] = 'v1:' + (++sequence).toString(16).padStart(64, '0') return json(res, 200, { - success: true, - diff: { pages: { added: [], modified: ['index'], deleted: [] } }, - created_ids: {} + success: true, revision: revisions[target], + backup: form.get('force') === 'true' ? 'backup-1234.zip' : '', + diff: {pages:{added:[],modified:['index'],deleted:[]}}, summary:{groups:1,blocks:1}, created_ids:{} }) } @@ -85,6 +103,7 @@ export async function start_mock_server({ sites = [], export_files = {}, site_gr return { url: `http://127.0.0.1:${port}`, requests, + revisions, /** Every recorded request whose path matches, for order-independent assertions. */ matching: (pattern) => requests.filter((r) => (typeof pattern === 'string' ? r.path === pattern : pattern.test(r.path))), close: () => new Promise((resolve) => server.close(resolve)) diff --git a/tests/push-cms.test.mjs b/tests/push-cms.test.mjs new file mode 100644 index 0000000..bac2a02 --- /dev/null +++ b/tests/push-cms.test.mjs @@ -0,0 +1,110 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { setTimeout as delay } from 'node:timers/promises' +import net from 'node:net' +import fs from 'node:fs/promises' +import path from 'node:path' +import { make_workspace, run_cli } from './helpers/run-cli.mjs' + +// Coordinated CMS/CLI contract test. Point at a freshly built CMS binary: +// PRIMO_TEST_CMS_BINARY=/path/to/primo node --test tests/push-cms.test.mjs +test('real CMS: fresh push, pull, client edit, rejected push, backed-up force, repeat push', { + skip: !process.env.PRIMO_TEST_CMS_BINARY, timeout: 90000 +}, async t => { + const workspace = await make_workspace() + const reservation = net.createServer() + await new Promise(resolve => reservation.listen(0, '127.0.0.1', resolve)) + const port = reservation.address().port + await new Promise(resolve => reservation.close(resolve)) + const server = `http://127.0.0.1:${port}` + let logs = '' + const process = spawn(globalThis.process.env.PRIMO_TEST_CMS_BINARY, ['serve','--http',`127.0.0.1:${port}`,'--dir',path.join(workspace.root,'pb_data')], { + env: {...globalThis.process.env, PRIMO_DEV_MODE:'1', PRIMO_AUTHOR_MODE:'both', PRIMO_ENABLE_USAGE_STATS:'false'}, + stdio:['ignore','pipe','pipe'] + }) + process.stdout.on('data', data => { logs += data }) + process.stderr.on('data', data => { logs += data }) + t.after(async () => { + if (process.exitCode === null) { + const stopped = new Promise(resolve => process.once('close',resolve)) + process.kill('SIGTERM') + await stopped + } + await workspace.cleanup() + }) + let ready = false + for (let attempt = 0; attempt < 100; attempt++) { + try { if ((await fetch(`${server}/api/health`)).ok) { ready = true; break } } catch {} + if (process.exitCode !== null) break + await delay(100) + } + assert.ok(ready,`CMS failed to start:\n${logs}`) + let id = 'safepushtest001' + let dir = path.join(workspace.work,'sites/demo') + const files = { + 'server.yaml':`server: ${server}\n`, + 'sites/demo/site.yaml':`name: Demo\nsite_id: ${id}\nserver: ${server}\n`, + 'sites/demo/blocks/hero/config.yaml':'name: Hero\n', + 'sites/demo/blocks/hero/component.svelte':'

{heading}

\n\n', + 'sites/demo/blocks/hero/fields.yaml':'- name: heading\n type: text\n', + 'sites/demo/page-types/default/config.yaml':'name: Default\nallowed_blocks: [hero]\n', + 'sites/demo/pages/index.yaml':'name: Home\npage_type: Default\nsections:\n - block: hero\n content:\n heading: Original headline\n' + } + for (const [name,contents] of Object.entries(files)) { + await fs.mkdir(path.dirname(path.join(workspace.work,name)),{recursive:true}) + await fs.writeFile(path.join(workspace.work,name),contents) + } + const cli = args => run_cli(args,{cwd:workspace.work,home:workspace.home,timeout_ms:30000}) + const initial = await cli(['push']) + assert.equal(initial.code,0,initial.output) + const auth = await fetch(`${server}/api/primo/dev-auth`,{method:'POST'}).then(response => response.json()) + assert.ok(auth.token) + const headers = {Authorization:`Bearer ${auth.token}`,'Content-Type':'application/json'} + // Exercise a whole-server workspace with two sites and a shared library. + const firstID = id + id = 'safepushtest002' + dir = path.join(workspace.work,'sites/second') + for (const [name,contents] of Object.entries(files)) { + if (!name.startsWith('sites/demo/')) continue + const destination = path.join(workspace.work,name.replace('sites/demo/','sites/second/')) + await fs.mkdir(path.dirname(destination),{recursive:true}) + await fs.writeFile(destination,contents.replaceAll(firstID,id).replace('name: Demo','name: Second')) + } + await fs.mkdir(path.join(workspace.work,'library/shared/banner'),{recursive:true}) + await fs.writeFile(path.join(workspace.work,'library/groups.yaml'),'- name: Shared\n folder: shared\n') + await fs.writeFile(path.join(workspace.work,'library/shared/banner/config.yaml'),'name: Banner\n') + await fs.writeFile(path.join(workspace.work,'library/shared/banner/component.svelte'),'

Shared banner

') + const seeded = await cli(['push','--token',auth.token]) + assert.equal(seeded.code,0,seeded.output) + const pull = await cli(['pull',server,workspace.work,'--token',auth.token]) + assert.equal(pull.code,0,pull.output) + const entries = await fetch(`${server}/api/collections/page_section_entries/records?filter=${encodeURIComponent(`section.page.site = '${id}'`)}`,{headers}).then(response => response.json()) + assert.equal(entries.items.length,1) + const entryID = entries.items[0].id + const edit = await fetch(`${server}/api/collections/page_section_entries/records/${entryID}`,{method:'PATCH',headers,body:JSON.stringify({value:'Client edit after pull'})}) + assert.equal(edit.status,200,await edit.text()) + const component = path.join(dir,'blocks/hero/component.svelte') + const original = await fs.readFile(component,'utf8') + const local = original.replace('red','hotpink') + assert.notEqual(original,local) + await fs.writeFile(component,local) + const firstBefore = await fetch(`${server}/api/primo/push-state/${firstID}`,{headers}).then(response => response.json()) + const blocked = await cli(['push','--token',auth.token]) + assert.equal(blocked.code,1,blocked.output) + assert.match(blocked.output,/changed on the server/) + const firstAfter = await fetch(`${server}/api/primo/push-state/${firstID}`,{headers}).then(response => response.json()) + assert.equal(firstAfter.revision,firstBefore.revision,'preflight must prevent uploading the earlier, unaffected site') + const latest = await fetch(`${server}/api/collections/page_section_entries/records/${entryID}`,{headers}).then(response => response.json()) + assert.equal(latest.value,'Client edit after pull') + assert.equal(await fs.readFile(component,'utf8'),local) + const forced = await cli(['push','--token',auth.token,'--force','--yes']) + assert.equal(forced.code,0,forced.output) + const backups = await fs.readdir(path.join(dir,'.primo/backups',id)) + assert.equal(backups.length,1) + assert.equal((await fs.readdir(path.join(workspace.work,'.primo/backups/library'))).length,1) + const symbols = await fetch(`${server}/api/collections/site_symbols/records?filter=${encodeURIComponent(`site = '${id}'`)}`,{headers}).then(response => response.json()) + assert.match(symbols.items[0].css,/hotpink/) + const repeat = await cli(['push','--token',auth.token]) + assert.equal(repeat.code,0,repeat.output) +}) diff --git a/tests/push-guard.test.mjs b/tests/push-guard.test.mjs new file mode 100644 index 0000000..b332bf7 --- /dev/null +++ b/tests/push-guard.test.mjs @@ -0,0 +1,143 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import path from 'node:path' +import { start_mock_server } from './helpers/mock-server.mjs' +import { make_workspace, run_cli } from './helpers/run-cli.mjs' +import { save_baseline } from '../dist/utils/push-guard.js' + +const revision = digit => 'v1:' + digit.repeat(64) +const sites = ['alpha', 'beta', 'gamma'].map(name => ({id:name.padEnd(15, '0'), name, group:'', host:name})) +const files = {'site.yaml':'name: Example\n', 'pages/index.yaml':'name: Home\n', 'blocks/hero/component.svelte':'

Original

'} + +async function fixture(t, options = {}) { + const workspace = await make_workspace() + const server = await start_mock_server({sites, export_files:files, ...options}) + t.after(async () => { await server.close(); await workspace.cleanup() }) + await fs.writeFile(path.join(workspace.work, 'server.yaml'), `server: ${server.url}\n`) + for (const site of sites) { + const dir = path.join(workspace.work, 'sites', site.name) + await fs.mkdir(path.join(dir, 'pages'), {recursive:true}) + await fs.writeFile(path.join(dir, 'site.yaml'), `name: ${site.name}\nsite_id: ${site.id}\nserver: ${server.url}\n`) + await fs.writeFile(path.join(dir, 'pages/index.yaml'), 'name: Developer local changes\n') + await save_baseline(dir, server.url, site.id, revision('a')) + } + await fs.mkdir(path.join(workspace.work, 'library'), {recursive:true}) + await fs.writeFile(path.join(workspace.work, 'library/groups.yaml'), '[]\n') + await save_baseline(workspace.work, server.url, 'library', revision('a')) + return {workspace, server, run: (...args) => run_cli(['push','--token','test-token',...args],{cwd:workspace.work,home:workspace.home})} +} + +test('whole-server preflight rejects a later stale site before any upload, including library', async t => { + const {server,run,workspace} = await fixture(t) + server.revisions[sites[1].id] = revision('b') + const result = await run() + assert.equal(result.code,1,result.output) + assert.match(result.output,/beta: changed on the server/) + assert.equal(server.requests.filter(r => r.method === 'POST').length,0) + assert.equal(server.matching('/api/primo/push-state/library').length,1) + assert.equal(await fs.readFile(path.join(workspace.work,'sites/alpha/pages/index.yaml'),'utf8'),'name: Developer local changes\n') +}) + +test('a stale shared library blocks all sites, and --only excludes it', async t => { + const {server,run} = await fixture(t) + server.revisions.library = revision('b') + const blocked = await run() + assert.equal(blocked.code,1,blocked.output) + assert.match(blocked.output,/library: changed on the server/) + assert.equal(server.requests.filter(r => r.method === 'POST').length,0) + const allowed = await run('--only','alpha') + assert.equal(allowed.code,0,allowed.output) + assert.equal(server.matching('/api/primo/import-library').length,0) +}) + +test('force requires explicit confirmation, downloads backups and advances each baseline', async t => { + const {server,run,workspace} = await fixture(t) + server.revisions[sites[1].id] = revision('b') + const declined = await run('--force') + assert.equal(declined.code,1,declined.output) + assert.match(declined.output,/--force --yes/) + assert.equal(server.requests.filter(r => r.method === 'POST').length,0) + const forced = await run('--force','--yes') + assert.equal(forced.code,0,forced.output) + assert.equal(server.requests.filter(r => r.method === 'POST').length,4) + await fs.access(path.join(workspace.work,'sites/beta/.primo/backups',sites[1].id,'backup-1234.zip')) + await fs.access(path.join(workspace.work,'.primo/backups/library/backup-1234.zip')) + const normal = await run() + assert.equal(normal.code,0,normal.output) +}) + +test('late conflict stops remaining uploads and reports completed and skipped targets', async t => { + const {server,run} = await fixture(t,{on_request:({req,url,revisions}) => { + if (req.method === 'POST' && url.pathname === `/api/primo/import/${sites[1].id}`) revisions[sites[1].id] = revision('f') + }}) + const result = await run('--force','--yes') + assert.equal(result.code,1,result.output) + assert.match(result.output,/Completed: alpha/) + assert.match(result.output,/Failed: beta/) + assert.match(result.output,/Not attempted: gamma, library/) + assert.equal(server.matching(`/api/primo/import/${sites[2].id}`).length,0) + assert.equal(server.matching('/api/primo/import-library').length,0) +}) + +test('missing or wrong-server baseline refuses existing sites', async t => { + const {server,run,workspace} = await fixture(t) + const dir = path.join(workspace.work,'sites/alpha') + await save_baseline(dir,server.url,sites[0].id,null) + await save_baseline(dir,'https://different-server.example',sites[0].id,revision('a')) + const result = await run('--only','alpha') + assert.equal(result.code,1,result.output) + assert.match(result.output,/no saved baseline/) + assert.equal(server.requests.filter(r => r.method === 'POST').length,0) +}) + +test('unsupported server cannot be bypassed with force', async t => { + const {server,run} = await fixture(t,{unsupported_guard:true}) + const result = await run('--force','--yes') + assert.equal(result.code,1,result.output) + assert.match(result.output,/Update the CMS first/) + assert.equal(server.requests.filter(r => r.method === 'POST').length,0) +}) + +test('an existing site without auth never falls back to bootstrap, even with force', async t => { + const {server,workspace} = await fixture(t) + const result = await run_cli(['push','--only','alpha','--force','--yes'],{cwd:workspace.work,home:workspace.home}) + assert.equal(result.code,1,result.output) + assert.match(result.output,/Authentication required to update an existing site/) + assert.equal(server.requests.filter(r => r.method === 'POST').length,0) +}) + +test('pull records export revisions and legacy pull invalidates an old baseline', async t => { + for (const legacy_export of [false,true]) { + const {server,workspace,run} = await fixture(t,{legacy_export}) + const pulled = await run_cli(['pull',server.url,workspace.work,'--token','test-token'],{cwd:workspace.work,home:workspace.home}) + assert.equal(pulled.code,0,pulled.output) + await assert.rejects(fs.access(path.join(workspace.work,'library/groups.yaml')), 'pull must remove files absent from the exported library') + const trash = await fs.readdir(path.join(workspace.work,'.primo/trash')) + assert.ok(trash.some(name => name.startsWith('library-pull-'))) + const result = await run('--only','alpha') + assert.equal(result.code,legacy_export ? 1 : 0,result.output) + if (legacy_export) assert.match(result.output,/no saved baseline/) + } +}) + +test('preview never overwrites the library or advances a stale baseline', async t => { + const {server,run} = await fixture(t) + server.revisions[sites[0].id] = revision('b') + const preview = await run('--preview') + assert.equal(preview.code,0,preview.output) + assert.equal(server.matching('/api/primo/import-library').length,0) + assert.equal(server.requests.filter(r => r.method === 'POST' && !r.path.endsWith('/preview')).length,0) + const actual = await run() + assert.equal(actual.code,1,actual.output) + assert.match(actual.output,/alpha: changed on the server/) +}) + +test('a deleted server site is a conflict rather than silently recreated', async t => { + const {server,run} = await fixture(t) + server.revisions[sites[0].id] = 'absent' + const result = await run('--only','alpha') + assert.equal(result.code,1,result.output) + assert.match(result.output,/changed on the server/) + assert.equal(server.requests.filter(r => r.method === 'POST').length,0) +}) diff --git a/tests/smoke.test.mjs b/tests/smoke.test.mjs index d540343..f25fe7c 100644 --- a/tests/smoke.test.mjs +++ b/tests/smoke.test.mjs @@ -2,6 +2,7 @@ import { test, before, after, describe } from 'node:test' import assert from 'node:assert/strict' import fs from 'fs/promises' import path from 'path' +import { save_baseline } from '../dist/utils/push-guard.js' import { start_mock_server } from './helpers/mock-server.mjs' import { run_cli, make_workspace, CLI_ENTRY } from './helpers/run-cli.mjs' @@ -122,6 +123,7 @@ describe('primo push', () => { await fs.mkdir(path.join(site_dir, 'pages'), { recursive: true }) await fs.writeFile(path.join(site_dir, 'site.yaml'), `name: Smoke Site\nsite_id: ${SITE.id}\nserver: ${server.url}\n`) await fs.writeFile(path.join(site_dir, 'pages/index.yaml'), 'name: Home\nfields: {}\n') + await save_baseline(site_dir, server.url, SITE.id, server.revisions[SITE.id]) await fs.writeFile(path.join(workspace.work, 'server.yaml'), `server: ${server.url}\n`) const result = await run_cli(['push', '--server', server.url, '--token', 'test-token'], {