From f418c1f56fdf997f621417d52d138f8ea7a82fd2 Mon Sep 17 00:00:00 2001 From: Matthew Morris Date: Fri, 9 Oct 2026 18:07:21 -0500 Subject: [PATCH] Add explicit hosted publication and phase status to CLI --- README.md | 64 +++++++- src/commands/deploy.ts | 2 +- src/commands/login.ts | 2 +- src/commands/preview.ts | 4 +- src/commands/publish.ts | 51 ++++++ src/commands/push.ts | 149 +++++++++++++++--- src/commands/status.ts | 46 +++++- src/index.ts | 42 +++-- src/utils/command-output.ts | 14 ++ src/utils/hosted-targets.ts | 90 +++++++++++ src/utils/publication.ts | 185 ++++++++++++++++++++++ tests/helpers/mock-server.mjs | 39 ++++- tests/publication.test.mjs | 283 ++++++++++++++++++++++++++++++++++ tests/publish-cms.test.mjs | 124 +++++++++++++++ 14 files changed, 1043 insertions(+), 52 deletions(-) create mode 100644 src/commands/publish.ts create mode 100644 src/utils/command-output.ts create mode 100644 src/utils/hosted-targets.ts create mode 100644 src/utils/publication.ts create mode 100644 tests/publication.test.mjs create mode 100644 tests/publish-cms.test.mjs diff --git a/README.md b/README.md index 735db5f..755f9b4 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,16 @@ The selected port is saved in `.primo/dev-server.json`, leaving `server.yaml` un ### `primo push` -Sync local changes to an existing hosted Primo server. Requires a server you've +Save local changes to the editable hosted draft. Plain `push` does not publish +them to the public website. Requires a server you've already deployed (run `primo deploy` first) and authenticated against (`primo login -s `). ```bash primo push https://cms.example.com --site abc123 -primo push --only my-site # Push just one site folder (workspace root) +primo push --only my-site # Save one hosted draft (workspace root) +primo push --only my-site --publish # Save the draft, then publish it +primo push --json # Separate push/publication results for scripts primo push --preview # Preview changes without applying primo push --dry-run # Show what would be sent without making requests ``` @@ -77,7 +80,9 @@ Options: - `--only ` - Push only the named site folder under `sites/` (skips library) - `-d, --dir ` - Directory (default: `.`) - `-t, --token ` - Auth token -- `--preview` - Preview only +- `--publish` - Publish selected sites after all site/library imports succeed +- `--json` - One machine-readable result on stdout; diagnostics go to stderr +- `--preview` - Preview the import without applying or publishing - `--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 @@ -132,6 +137,55 @@ Options: - `-o, --output ` - Workspace output directory (default: `.`) - `-t, --token ` - Auth token +### `primo publish` + +Publish the **current hosted draft**, including edits made in the CMS, without +uploading local files. Use the same server, token, `--dir`, `--site`, and `--only` +selection options as `push`. From a workspace root it publishes each included +site; it does not publish the shared library separately. + +```bash +primo publish --only my-site +primo publish https://cms.example.com --site abc123 # No local checkout required +primo publish --dir sites/my-site --json +primo push --only my-site --publish --json +primo status --hosted --only my-site --json +``` + +`deploy` provisions hosting, `push` saves drafts, `publish` makes hosted drafts +public, and `preview` rebuilds a local `primo dev` preview. `--publish` cannot be +combined with `--preview` or `--dry-run`. Publication uses your hosted login, +never the local dev-auth endpoint. + +A combined push waits for all selected imports (including the shared library) +before publishing any sites. If an import fails, publication is not attempted; +earlier draft imports remain saved. Publications then run per site, with every +outcome reported separately. A failed publication exits nonzero, preserves the +successful push baseline and draft, and prints a scoped `primo publish` retry +command. Retry publication without re-uploading the successful push. + +JSON results include `ok`, `results`, and per-target `push`/`publish` objects +with `state`, revisions, attempt IDs, and stable error codes. States distinguish +`not_requested`, `not_attempted`, `succeeded`, `failed`, and `unknown`. A lost +publication response is checked against server status; if it cannot be resolved, +the outcome is `unknown`. Check status before retrying. Tokens are excluded from +results and retry commands. + +`status --hosted` (also implied by `--server`) reads authenticated server state: +draft and published revisions, unpublished changes, last publication and attempt, +errors, and the public URL. It reports `never_published`, `current`, `behind`, +`publishing`, `failed`, or `unknown`. Local `status` remains available without +hosted authentication. Legacy publications have an unknown revision; unavailable +servers yield unknown status and a nonzero exit, rather than cached success. + +This workflow requires the accompanying CMS publication endpoints and migration. +Upgrade the CMS and CLI together and pull to refresh push baselines: publication +artifacts are now excluded from the content fingerprint. Builds activate only +after generation succeeds and the draft revision is checked again. A concurrent +CMS edit rejects publication; review it before retrying. Failed builds retain the +previous public output. Active and previous tracked builds are retained; older +tracked builds and failed staging files are cleaned up best-effort. + ### `primo library push` Push the local shared block library back to a hosted Primo instance. @@ -177,7 +231,9 @@ on a single site and deploy the output folder with that host's CLI. | --------------------------------------------- | -------------- | | Let collaborators edit content from a CMS UI | `primo deploy` | | Ship a static site to any static host | `primo build` | -| Sync local edits to an existing hosted server | `primo push` | +| Save local edits to a hosted draft | `primo push` | +| Publish the current hosted draft | `primo publish` | +| Save local edits and publish them | `primo push --publish` | ### `primo validate` diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 4c79e85..713fb1f 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -475,7 +475,7 @@ function print_post_deploy_next_steps(provider: Provider, url: string | undefine console.log(chalk.bold('Next steps')) console.log('') console.log(chalk.dim(' Open the URL above and create your editor account.')) - console.log(chalk.dim(' Future edits: `primo push` to upload, `primo pull` to fetch.')) + console.log(chalk.dim(' Future edits: `primo push` to save drafts, `primo push --publish` to publish, `primo pull` to fetch.')) console.log('') return } diff --git a/src/commands/login.ts b/src/commands/login.ts index 55947a9..a7e33b2 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -43,7 +43,7 @@ export async function login(options: LoginOptions) { } console.log('') console.log(chalk.dim(' Token saved to ~/.primo/tokens.json')) - console.log(chalk.dim(' You can now use `primo pull` and `primo push` without --token')) + console.log(chalk.dim(' You can now use `primo pull`, `primo push`, and `primo publish` without --token')) } // Prompt for credentials (email if not supplied, then password), authenticate diff --git a/src/commands/preview.ts b/src/commands/preview.ts index b5639b2..5475310 100644 --- a/src/commands/preview.ts +++ b/src/commands/preview.ts @@ -156,7 +156,7 @@ async function dev_auth(api_url: string): Promise { return body.token } -async function fetch_site_record(api_url: string, token: string, site_id: string): Promise { +export async function fetch_site_record(api_url: string, token: string, site_id: string): Promise { const response = await fetch(`${api_url}/api/collections/sites/records/${encodeURIComponent(site_id)}`, { headers: { Authorization: token } }) @@ -176,7 +176,7 @@ type CompileAndUpload = ( record: SiteRecord ) => Promise<{ pageCount: number; symbolCount: number }> -async function load_compiler(): Promise { +export async function load_compiler(): Promise { // Non-literal specifier: the subpath only exists in primo-mcp >= 0.1.8, so a // static specifier would fail to type-resolve against older installs. const compiler_specifier = 'primo-mcp/compiler' diff --git a/src/commands/publish.ts b/src/commands/publish.ts new file mode 100644 index 0000000..df64657 --- /dev/null +++ b/src/commands/publish.ts @@ -0,0 +1,51 @@ +import { hosted_targets, type HostedOptions } from '../utils/hosted-targets.js' +import { + publish_target, + print_publication_result, + type PublicationResult +} from '../utils/publication.js' +import { with_command_output } from '../utils/command-output.js' + +export async function publish_site(options: HostedOptions) { + const results: { + target: string + server: string + site_id: string + publish: PublicationResult + }[] = [] + let error: string | undefined + await with_command_output(options.json, async () => { + try { + const targets = await hosted_targets(options) + for (const target of targets) { + const publish = await publish_target(target) + results.push({ + target: target.label, + server: target.server, + site_id: target.target, + publish + }) + if (!options.json) print_publication_result(target.label, publish) + } + } catch (cause) { + error = cause instanceof Error ? cause.message : String(cause) + if (!options.json) console.error(error) + } + }) + const ok = + !error && results.every((result) => result.publish.state === 'succeeded') + if (!ok) process.exitCode = 1 + if (options.json) + console.log( + JSON.stringify( + { + ok, + results, + ...(error ? { error, error_code: 'invalid_publication_target' } : {}) + }, + null, + 2 + ) + ) + return results +} diff --git a/src/commands/push.ts b/src/commands/push.ts index 6dfac89..f9756de 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -10,6 +10,9 @@ import { find_duplicate_site_ids, describe_duplicate_site_ids } from '../utils/s import { read_site_config, get_site_config_path, type SiteConfig, SITE_CONFIG_FILE } from '../utils/site-config.js' import { get_server_config_path, read_server_config, resolve_format_options, normalize_server_url, type ServerConfig, type SiteGroupConfig } from '../utils/server-config.js' import { format_file_contents } from '../utils/format.js' +import { with_command_output } from '../utils/command-output.js' +import { publish_target, print_publication_result, type PublicationResult } from '../utils/publication.js' +import { publish_retry_command } from '../utils/hosted-targets.js' interface PushOptions { server?: string @@ -17,12 +20,85 @@ interface PushOptions { only?: string dir: string token?: string + publish?: boolean + json?: boolean + report?: PushReport + workspaceServer?: string preview?: boolean dryRun?: boolean force?: boolean yes?: boolean } +interface PushResult { + target: string + server?: string + site_id?: string + push: { state: 'not_attempted' | 'previewed' | 'succeeded' | 'failed' | 'unknown'; revision?: string; error?: string; error_code?: string } + publish: PublicationResult + plan?: PushPlan +} +interface PushReport { + ok: boolean + results: PushResult[] + error?: string + error_code?: string +} + +function push_result(options: PushOptions, label: string, plan?: PushPlan): PushResult { + let result = options.report?.results.find((item) => (plan ? item.plan?.dir === plan.dir && item.plan.target === plan.target : item.target === label)) + if (!result) { + result = { target: label, push: { state: 'not_attempted' }, publish: { state: options.publish ? 'not_attempted' : 'not_requested' } } + options.report?.results.push(result) + } + if (plan?.target === 'library') result.publish = { state: 'not_requested' } + if (plan) Object.assign(result, { plan, server: plan.server, site_id: plan.target === 'library' ? undefined : plan.target }) + return result +} + +async function publish_pushed_sites(options: PushOptions): Promise { + const failed: string[] = [] + for (const result of options.report?.results || []) { + if (result.push.state !== 'succeeded' || result.plan?.target === 'library' || !result.plan) continue + if (!options.publish) { + console.log(` Draft updated: ${result.target}. Publication was not requested.`) + console.log(` To publish: ${publish_retry_command(result.plan)}`) + continue + } + console.log(`Publishing: ${result.target}...`) + result.publish = await publish_target(result.plan, result.push.revision) + print_publication_result(result.target, result.publish, true) + if (result.publish.state !== 'succeeded') failed.push(result.target) + } + return failed +} + +// Preserve the return contract used by deploy while exposing phase results to +// scripts. A publication failure must never erase a successful push baseline. +export async function push_site(options: PushOptions): Promise { + const report: PushReport = { ok: false, results: [] } + let failed: string[] = [] + await with_command_output(options.json, async () => { + try { + if (options.publish && (options.preview || options.dryRun)) throw new Error('--publish cannot be combined with --preview or --dry-run. No changes were sent.') + failed = await push_site_internal({ ...options, report }) + if (failed.length === 0 && !options.preview && !options.dryRun) failed = await publish_pushed_sites({ ...options, report }) + else if (options.publish && report.results.some((result) => result.push.state === 'succeeded')) { + console.log('Publication was not attempted because the selected push did not complete. Earlier draft changes remain saved.') + } + report.ok = failed.length === 0 + } catch (error) { + report.error = error instanceof Error ? error.message : String(error) + report.error_code = 'push_failed' + failed = ['push'] + console.error(report.error) + } + }) + if (!report.ok) process.exitCode = 1 + if (options.json) console.log(JSON.stringify({ ...report, results: report.results.map(({ plan, ...result }) => result) }, null, 2)) + return failed +} + async function path_exists(p: string): Promise { try { await fs.access(p) @@ -100,7 +176,7 @@ type CreatedIDs = Record> // Returns the labels (site slugs / 'library') that failed to push so callers // like `primo deploy` can tell a clean run from a partial one. Empty = success. -export async function push_site(options: PushOptions): Promise { +async function push_site_internal(options: PushOptions): Promise { const root_dir = path.resolve(options.dir) const has_site_yaml = await path_exists(get_site_config_path(root_dir)) const has_server_yaml = await path_exists(get_server_config_path(root_dir)) @@ -117,9 +193,11 @@ export async function push_site(options: PushOptions): Promise { // fall through — bad server.yaml will surface elsewhere } } - const effective_options: PushOptions = workspace_server && !options.server - ? { ...options, server: workspace_server } - : options + if (!workspace_server && has_site_yaml) { + try { workspace_server = (await read_server_config(await root_dir_for(root_dir))).server } catch { /* Single-site checkout. */ } + } + const effective_options: PushOptions = { ...options, workspaceServer: workspace_server } + if (has_site_yaml && options.only) throw new Error('--only selects a site from a workspace root. Use --dir for a site directory.') if (options.dryRun) { await print_push_dry_run(root_dir, has_site_yaml, has_server_yaml, effective_options) @@ -152,9 +230,7 @@ export async function push_site(options: PushOptions): Promise { const duplicates = await find_duplicate_site_ids(siblings) const mine = [...duplicates].filter(([, dirs]) => dirs.includes(root_dir)) if (mine.length > 0) { - console.error(describe_duplicate_site_ids(new Map(mine), path.dirname(parent))) - process.exitCode = 1 - return [path.basename(root_dir)] + throw new Error(describe_duplicate_site_ids(new Map(mine), path.dirname(parent))) } } @@ -163,9 +239,13 @@ export async function push_site(options: PushOptions): Promise { await push_single_site(root_dir, effective_options, spinner) return [] } catch (error) { + const result = push_result(options, path.basename(root_dir)) + if (result.push.state !== 'succeeded' && result.push.state !== 'unknown') result.push = { state: 'failed', error: error instanceof Error ? error.message : String(error), error_code: 'push_failed' } + result.push.error = error instanceof Error ? error.message : String(error) spinner.fail(`Push failed: ${error instanceof Error ? error.message : error}`) if (is_auth_error(error)) print_auth_hint() - process.exit(1) + process.exitCode = 1 + return [path.basename(root_dir)] } } @@ -186,9 +266,7 @@ async function print_push_dry_run(root_dir: string, has_site_yaml: boolean, has_ console.log('') if (!has_site_yaml && !has_server_yaml) { - console.log(chalk.red(` No ${SITE_CONFIG_FILE} or ${path.basename(get_server_config_path(root_dir))} found in ${root_dir}.`)) - console.log('') - return + throw new Error(`No ${SITE_CONFIG_FILE} or ${path.basename(get_server_config_path(root_dir))} found in ${root_dir}.`) } const sites: { dir: string; config: ReturnType | null; label: string }[] = [] @@ -223,7 +301,16 @@ async function print_push_dry_run(root_dir: string, has_site_yaml: boolean, has_ library_present = await path_exists(path.join(root_dir, 'library')) } - const server_raw = options.server || inferred_server + const included = options.only ? sites.filter(site => path.basename(site.dir) === options.only) : sites + if (options.only && !included.length) throw new Error(`No site folder named "${options.only}" under sites/.`) + for (const site of included) { + const raw = options.server || site.config?.server || options.workspaceServer || inferred_server + const result = push_result(options, path.basename(site.dir)) + result.site_id = options.site || site.config?.site_id + if (raw) result.server = normalize_server_url(raw) + } + if (library_present && !options.only) push_result(options, 'library') + const server_raw = options.server || options.workspaceServer || inferred_server const server = server_raw ? normalize_server_url(server_raw) : undefined console.log(` Target server: ${chalk.cyan(server || '(not set — pass --server or set in site.yaml)')}`) @@ -232,11 +319,11 @@ async function print_push_dry_run(root_dir: string, has_site_yaml: boolean, has_ if (sites.length === 0) { console.log(chalk.yellow(' (no sites found)')) } else { - for (const site of sites) { + for (const site of included) { console.log(` ${chalk.green('+')} site: ${site.label} ${chalk.dim(`(${path.basename(site.dir)})`)}`) } } - if (library_present) { + if (library_present && !options.only) { console.log(` ${chalk.green('+')} library/`) } console.log('') @@ -260,7 +347,7 @@ async function print_push_dry_run(root_dir: string, has_site_yaml: boolean, has_ 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 + const server_raw = options.server || config.server || options.workspaceServer 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 @@ -283,26 +370,27 @@ async function push_server(root_dir: string, options: PushOptions): Promise 0) { - console.error(describe_duplicate_site_ids(duplicate_ids, root_dir)) - return [...duplicate_ids.values()].flat().map(dir => path.basename(dir)) + throw new Error(describe_duplicate_site_ids(duplicate_ids, root_dir)) } 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'] + throw new Error(options.only ? `No site folder named "${options.only}" under sites/.` : 'No site folders found in this server directory.') } let plans: PushPlan[] try { + if (options.site && selected.length !== 1) throw new Error('--site requires a single site directory.') 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 + const server = options.server || options.workspaceServer ? normalize_server_url((options.server || options.workspaceServer)!) : 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) + for (const dir of selected) push_result(options, path.basename(dir)).push = { state: 'not_attempted', error: error instanceof Error ? error.message : String(error), error_code: 'push_preflight_failed' } return selected.map(dir => path.basename(dir)) } + for (const plan of plans) push_result(options, plan.label, plan) const completed: string[] = [] for (let index = 0; index < plans.length; index++) { const plan = plans[index] @@ -314,8 +402,12 @@ async function push_server(root_dir: string, options: PushOptions): Promise/site.yaml`, and each site's `.primo/sync_status.json`. */ export async function status(options: StatusOptions) { + if (options.hosted || options.server) return hosted_status(options) const base_dir = path.resolve(options.dir) const as_json = !!options.json @@ -208,3 +210,41 @@ async function read_live_cms(api_url: string): Promise<{ sites: CmsSite[]; group return null } } + +async function hosted_status(options: StatusOptions) { + const results: object[] = [] + let ok = true + let error: string | undefined + try { + for (const target of await hosted_targets(options)) { + try { + const publication = await read_publication_status(target) + results.push({ target: target.label, server: target.server, site_id: target.target, publication }) + if (!options.json) { + console.log(`${target.label}: ${publication.state}`) + console.log(` Draft: ${publication.draft_revision}`) + console.log(` Published: ${publication.published_revision || '(revision unknown)'}`) + if (publication.published_at) console.log(` Last published: ${publication.published_at}`) + if (publication.site_url) console.log(` ${publication.site_url}`) + if (publication.attempt.error) console.log(` Last attempt: ${publication.attempt.error}`) + } + } catch (cause) { + ok = false + const message = cause instanceof Error ? cause.message : String(cause) + results.push({ + target: target.label, + server: target.server, + site_id: target.target, + publication: { state: 'unknown', error: message, error_code: 'publication_status_unavailable' } + }) + if (!options.json) console.error(`${target.label}: unknown — ${message}`) + } + } + } catch (cause) { + ok = false + error = cause instanceof Error ? cause.message : String(cause) + if (!options.json) console.error(error) + } + if (!ok) process.exitCode = 1 + if (options.json) console.log(JSON.stringify({ ok, results, ...(error ? { error } : {}) }, null, 2)) +} diff --git a/src/index.ts b/src/index.ts index c9c7eb3..06d9897 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { new_site } from './commands/new.js' import { add_site } from './commands/add.js' import { pull_site } from './commands/pull.js' import { push_site } from './commands/push.js' +import { publish_site } from './commands/publish.js' import { pull_library } from './commands/pull-library.js' import { push_library } from './commands/push-library.js' import { dev_server } from './commands/dev.js' @@ -42,7 +43,7 @@ ${chalk.bold('Local development')} ${chalk.bold('Going live — pick one')} Want others to edit content? .................. ${chalk.cyan('primo deploy')} Just hosting a static blog? ................... ${chalk.cyan('primo build')} - Already have a hosted Primo server? ........... ${chalk.cyan('primo push')} + Already have a hosted Primo server? ........... ${chalk.cyan('primo push --publish')} ${chalk.bold('Working with an agent')} Give your editor Primo MCP tools .............. ${chalk.cyan('primo mcp install')} @@ -114,13 +115,13 @@ ${chalk.bold('Workspace layout uploaded as one unit')} ${chalk.bold('See also')} primo build Export a single site as static files - primo push Sync local changes to an existing hosted Primo server + primo push Save local changes as hosted drafts; --publish also publishes them `) .action(deploy) program .command('push [server]') - .description('Sync local changes to an existing hosted Primo server') + .description('Save local changes to the hosted draft; use --publish to make them public') .option('-s, --server ', 'Server URL') .option('--site ', 'Site ID') .option('--only ', 'Push only the named site folder under sites/ (skips library)') @@ -128,7 +129,9 @@ program .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('--publish', 'Publish the hosted sites after all selected pushes succeed') + .option('--json', 'Machine-readable push and publication results') + .option('--preview', 'Preview changes without applying or publishing') .option('--dry-run', 'Show what would be pushed without sending requests') .addHelpText('after', ` ${chalk.bold('Requires an existing hosted Primo server.')} @@ -138,9 +141,22 @@ to authenticate this machine before pushing. ${chalk.bold('See also')} primo deploy Stand up a new hosted Primo server primo login Authenticate with a hosted Primo server + primo publish Publish the hosted draft without uploading local files `) .action(async (server, options) => { await push_site({ ...options, server: server || options.server }) }) +program + .command('publish [server]') + .description('Publish the current hosted draft without uploading local files') + .option('-s, --server ', 'Hosted server URL') + .option('--site ', 'Site ID (single site only)') + .option('--only ', 'Publish only the named site folder under sites/') + .option('-d, --dir ', 'Site or workspace directory', '.') + .option('-t, --token ', 'Auth token') + .option('--json', 'Machine-readable publication results') + .addHelpText('after', '\nPublishes the hosted draft, including CMS edits. Unsaved local edits are not uploaded.\nUse `primo push --publish` to upload local files and then publish.\n`primo deploy` provisions hosting; `primo preview` rebuilds a local dev preview.\n') + .action(async (server, options) => { await publish_site({ ...options, server: server || options.server }) }) + program .command('pull [server] [dir]') .description('Pull entire server (all sites + library) to local files (defaults to ./)') @@ -205,7 +221,12 @@ ${chalk.bold('See also')} program .command('status') - .description('Report the workspace: server state, sites, groups, and last sync') + .description('Report local workspace state, or hosted draft/publication state with --hosted') + .option('--hosted', 'Read authenticated publication status from hosted servers') + .option('-s, --server ', 'Hosted server URL (implies --hosted)') + .option('--site ', 'Site ID (single site only)') + .option('--only ', 'Select one hosted site folder') + .option('-t, --token ', 'Hosted auth token') .option('-d, --dir ', 'Workspace directory', '.') .option('--json', 'Machine-readable output') .action((options) => status(options)) @@ -265,20 +286,13 @@ mcp .option('--json', 'Machine-readable result') .action(mcp_print) -// Custom unknown-command handler. commander's default suggestion engine is -// based on Levenshtein distance and won't reach across renames like -// publish→deploy, so handle the common renamed/unknown cases explicitly. +// Give helpful guidance for common command guesses that commander cannot infer. program.on('command:*', (operands: string[]) => { const cmd = operands[0] console.error('') console.error(chalk.red(`Unknown command: ${cmd}`)) console.error('') - if (cmd === 'publish') { - console.error(` ${chalk.cyan('primo publish')} has been replaced by ${chalk.cyan('primo deploy')}.`) - console.error(` ${chalk.dim('It now deploys your whole workspace (all sites + library) as one unit.')}`) - console.error('') - console.error(` Run: ${chalk.cyan('primo deploy --help')}`) - } else if (cmd === 'register' || cmd === 'import') { + if (cmd === 'register' || cmd === 'import') { // Likely guesses for site registration. Deliberately not aliases: // one canonical name keeps docs/transcripts consistent, and leaves // `register` free for a future account/signup meaning. diff --git a/src/utils/command-output.ts b/src/utils/command-output.ts new file mode 100644 index 0000000..c7901cf --- /dev/null +++ b/src/utils/command-output.ts @@ -0,0 +1,14 @@ +// Commands keep one JSON document on stdout; existing diagnostic helpers and +// spinners remain visible on stderr. Restore console even when a command fails. +export async function with_command_output( + json: boolean | undefined, + run: () => Promise +): Promise { + const log = console.log + if (json) console.log = (...args: unknown[]) => console.error(...args) + try { + return await run() + } finally { + console.log = log + } +} diff --git a/src/utils/hosted-targets.ts b/src/utils/hosted-targets.ts new file mode 100644 index 0000000..494cbaa --- /dev/null +++ b/src/utils/hosted-targets.ts @@ -0,0 +1,90 @@ +import fs from 'fs/promises' +import path from 'path' +import { read_site_config, get_site_config_path } from './site-config.js' +import { read_server_config, get_server_config_path, normalize_server_url } from './server-config.js' +import { get_auth_token } from './auth.js' +import { find_duplicate_site_ids, describe_duplicate_site_ids } from './site-ids.js' +import type { PushTarget } from './push-guard.js' + +export interface HostedOptions { + dir: string + server?: string + site?: string + only?: string + token?: string + json?: boolean +} + +async function exists(file: string) { + try { + await fs.access(file) + return true + } catch { + return false + } +} + +export async function hosted_targets(options: HostedOptions): Promise { + const root = path.resolve(options.dir) + // Publishing/status can target a hosted site without a local checkout. + if (options.server && options.site && !options.only) { + const server = normalize_server_url(options.server) + return [{ dir: root, server, target: options.site, label: options.site, token: options.token || (await get_auth_token(server)) }] + } + + let workspace_server: string | undefined + let dirs: string[] + if (await exists(get_site_config_path(root))) { + if (options.only) throw new Error('--only selects a site from a workspace root. Use --dir for a site directory.') + dirs = [root] + for (const parent of [path.dirname(root), path.dirname(path.dirname(root))]) { + if (await exists(get_server_config_path(parent))) { + workspace_server = (await read_server_config(parent)).server + break + } + } + } else if (await exists(get_server_config_path(root))) { + workspace_server = (await read_server_config(root)).server + const entries = await fs.readdir(path.join(root, 'sites'), { + withFileTypes: true + }) + dirs = [] + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue + const dir = path.join(root, 'sites', entry.name) + if (await exists(get_site_config_path(dir))) dirs.push(dir) + } + if (options.only) dirs = dirs.filter((dir) => path.basename(dir) === options.only) + if (options.site && dirs.length !== 1) throw new Error('--site requires a single site directory or --only selection.') + } else { + throw new Error(`No site.yaml or server.yaml found in ${root}. Pass --dir .`) + } + if (!dirs.length) throw new Error(options.only ? `No site folder named "${options.only}" under sites/.` : 'No sites found in this workspace.') + const duplicates = await find_duplicate_site_ids(dirs) + if (duplicates.size) throw new Error(describe_duplicate_site_ids(duplicates, root)) + const targets = [] + for (const dir of dirs) { + const config = await read_site_config(dir) + const server_raw = options.server || config.server || workspace_server + if (!server_raw) throw new Error(`No hosted server configured for ${path.basename(dir)}. Pass --server or set server in site.yaml/server.yaml.`) + const target = options.site || config.site_id + if (!target) throw new Error(`Missing site_id in ${get_site_config_path(dir)}.`) + const server = normalize_server_url(server_raw) + targets.push({ + dir, + server, + target, + label: path.basename(dir), + token: options.token || (await get_auth_token(server)) + }) + } + const identities = targets.map((t) => JSON.stringify([t.server, t.target])) + if (new Set(identities).size !== identities.length) throw new Error('Multiple folders select the same server site.') + return targets +} + +// Quote paths and target identifiers for copying into a POSIX shell. +export function publish_retry_command(target: PushTarget): string { + const quote = (s: string) => "'" + s.replaceAll("'", "'\\''") + "'" + return `primo publish --dir ${quote(target.dir)} --server ${quote(target.server)} --site ${quote(target.target)}` +} diff --git a/src/utils/publication.ts b/src/utils/publication.ts new file mode 100644 index 0000000..ed689f2 --- /dev/null +++ b/src/utils/publication.ts @@ -0,0 +1,185 @@ +import type { PushTarget } from './push-guard.js' +import { response_error } from './push-guard.js' +import { fetch_site_record, load_compiler } from '../commands/preview.js' +import { publish_retry_command } from './hosted-targets.js' + +export interface PublicationStatus { + protocol: number + site_id: string + state: 'never_published' | 'current' | 'behind' | 'publishing' | 'failed' | 'unknown' + draft_revision: string + published_revision: string + unpublished_changes: boolean | null + published_at: string + site_url: string | null + attempt: { + id: string + revision: string + state: string + started_at: string + finished_at: string + error: string + } +} + +export interface PublicationResult { + state: 'not_requested' | 'not_attempted' | 'succeeded' | 'failed' | 'unknown' + revision?: string + attempt_id?: string + site_url?: string | null + error?: string + error_code?: string + retry_command?: string + status?: PublicationStatus +} + +function publication_url(target: PushTarget) { + return `${target.server}/api/primo/publication/${encodeURIComponent(target.target)}` +} + +async function request(target: PushTarget, suffix = '', body?: unknown): Promise { + if (!target.token) throw new Error('Authentication required to publish. Run `primo login -s `, then retry publication.') + return fetch(publication_url(target) + suffix, { + method: body === undefined ? 'GET' : 'POST', + headers: { + Authorization: `Bearer ${target.token}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.timeout(120000) + }) +} + +export async function read_publication_status(target: PushTarget): Promise { + const response = await request(target) + if (response.status === 404) throw new Error('Publication status is unavailable. Update the CMS and verify this site exists on the selected server.') + if (!response.ok) throw new Error(await response_error(response)) + const value = (await response.json()) as PublicationStatus + if ( + value.protocol !== 1 || + value.site_id !== target.target || + !/^v1:[a-f0-9]{64}$/.test(value.draft_revision) || + !['never_published', 'current', 'behind', 'publishing', 'failed', 'unknown'].includes(value.state) || + !value.attempt || + typeof value.attempt.id !== 'string' + ) { + throw new Error('Server returned invalid publication status.') + } + return value +} + +export async function publish_target(target: PushTarget, expected_revision?: string): Promise { + const retry_command = publish_retry_command(target) + let attempt_id: string | undefined + let revision: string | undefined + let activation_sent = false + let start_sent = false + try { + const before = await read_publication_status(target) + revision = expected_revision || before.draft_revision + if (before.draft_revision !== revision) throw new Error('Hosted content changed after the push. Review the hosted draft before publishing it.') + // Resolve the compiler before starting an attempt, so an unsupported + // installation cannot leave the hosted site stuck in publishing state. + const compiler = await load_compiler() + start_sent = true + const start = await request(target, '', { expected_revision: revision }) + if (!start.ok) { + start_sent = false + throw new Error(await response_error(start)) + } + const attempt = (await start.json()) as { + attempt_id: string + revision: string + } + if (!/^[a-zA-Z0-9]{24}$/.test(attempt.attempt_id) || attempt.revision !== revision) throw new Error('Invalid publication attempt returned by server.') + attempt_id = attempt.attempt_id + const record = await fetch_site_record(target.server, target.token!, target.target) + await compiler(target.server, target.token!, record) + activation_sent = true + const activation = await request(target, `/${encodeURIComponent(attempt_id)}/activate`, {}) + if (!activation.ok) { + throw new Error(await response_error(activation)) + } + const receipt = (await activation.json()) as { + protocol: number + site_id: string + attempt_id: string + revision: string + state: string + site_url: string | null + } + if (receipt.protocol !== 1 || receipt.site_id !== target.target || receipt.attempt_id !== attempt_id || receipt.revision !== revision || receipt.state !== 'succeeded') { + throw new Error('Could not confirm the requested publication. Check hosted status before retrying.') + } + return { state: 'succeeded', revision, attempt_id, site_url: receipt.site_url } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (activation_sent || (start_sent && !attempt_id)) { + // A lost activation/start response is ambiguous. Read authoritative + // state, and never turn a possibly successful publication into failure. + try { + const status = await read_publication_status(target) + if (attempt_id && status.attempt.id === attempt_id && status.attempt.state === 'succeeded') { + return { + state: 'succeeded', + revision, + attempt_id, + site_url: status.site_url, + status + } + } + if (attempt_id && status.attempt.id === attempt_id && status.attempt.state === 'failed') { + return { + state: 'failed', + revision, + attempt_id, + error: status.attempt.error || message, + error_code: 'publication_failed', + retry_command, + status + } + } + } catch { + /* Preserve uncertainty when status is unavailable. */ + } + return { + state: 'unknown', + revision, + attempt_id, + error: message, + error_code: 'publication_outcome_unknown', + retry_command + } + } + if (attempt_id) { + try { + await request(target, `/${encodeURIComponent(attempt_id)}/fail`, { + error: message + }) + } catch { + /* Local outcome is known; remote status may remain in progress. */ + } + } + return { + state: 'failed', + revision, + attempt_id, + error: message, + error_code: 'publication_failed', + retry_command + } + } +} + +export function print_publication_result(label: string, result: PublicationResult, draft_saved = false) { + if (result.state === 'succeeded') { + console.log(`Published: ${label} (${result.revision})`) + if (result.site_url) console.log(` ${result.site_url}`) + else console.log(' Build published; no public domain is assigned. Assign a domain in the dashboard.') + } else { + console.error(`Publication ${result.state}: ${label}: ${result.error}`) + if (draft_saved) console.error(' Your draft changes are saved.') + if (result.state === 'unknown') console.error(' Check `primo status --hosted` before retrying; the server may have completed publication.') + else console.error(` Retry publication: ${result.retry_command}`) + } +} diff --git a/tests/helpers/mock-server.mjs b/tests/helpers/mock-server.mjs index ee3df5c..681b635 100644 --- a/tests/helpers/mock-server.mjs +++ b/tests/helpers/mock-server.mjs @@ -10,12 +10,14 @@ 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 = [], revisions = {}, on_request, unsupported_guard = false, legacy_export = false } = {}) { +export async function start_mock_server({ sites = [], export_files = {}, site_groups = [], revisions = {}, on_request, unsupported_guard = false, legacy_export = false, publication = 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 publications = {} + let attempt_sequence = 0 const server = http.createServer(async (req, res) => { const url = new URL(req.url, 'http://127.0.0.1') @@ -29,12 +31,44 @@ export async function start_mock_server({ sites = [], export_files = {}, site_gr body_length: body.length, body }) - if (on_request) await on_request({ req, url, body, revisions, requests }) + if (on_request && await on_request({ req, res, url, body, revisions, requests, publications })) return 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' }) } + const publication_match = url.pathname.match(/^\/api\/primo\/publication\/([^/]+)(?:\/([^/]+)\/(activate|fail))?$/) + if (publication && publication_match) { + const [, id, attempt_id, action] = publication_match + const site = sites.find(site => site.id === id) + if (!site) return json(res, 404, {message:'no such site'}) + const state = publications[id] ??= {protocol:1, site_id:id, state:'never_published', draft_revision:revisions[id], + published_revision:'', unpublished_changes:true, published_at:'', site_url:site.host ? 'https://' + site.host : null, + attempt:{id:'', revision:'', state:'', started_at:'', finished_at:'', error:''}} + state.draft_revision = revisions[id] + if (req.method === 'GET') { + state.unpublished_changes = state.published_revision !== revisions[id] + if (state.attempt.state === 'succeeded') state.state = state.unpublished_changes ? 'behind' : 'current' + return json(res,200,state) + } + const value = JSON.parse(body) + if (!action) { + if (value.expected_revision !== revisions[id]) return json(res,409,{message:'draft changed'}) + state.attempt = {id:(++attempt_sequence).toString().padStart(24,'a'), revision:revisions[id], state:'publishing', started_at:new Date().toISOString(),finished_at:'',error:''} + state.state = 'publishing' + return json(res,200,{attempt_id:state.attempt.id,revision:state.attempt.revision}) + } + if (state.attempt.id !== attempt_id) return json(res,409,{message:'attempt changed'}) + if (action === 'fail') { + state.attempt.state = 'failed'; state.attempt.error = value.error; state.state = 'failed' + } else { + state.attempt.state = 'succeeded'; state.published_revision = state.attempt.revision + state.state = 'current'; state.published_at = new Date().toISOString() + return json(res,200,{protocol:1,site_id:id,attempt_id,revision:state.published_revision,state:'succeeded',site_url:state.site_url}) + } + return json(res,200,state) + } + if (url.pathname.includes('/api/primo/push-backups/')) { const zip = await make_zip(export_files) res.writeHead(200, { 'Content-Type': 'application/zip' }) @@ -94,6 +128,7 @@ export async function start_mock_server({ sites = [], export_files = {}, site_gr }) } + if (publication && url.pathname.startsWith('/api/collections/') && req.method === 'GET') return json(res,200,{items:[],totalPages:1}) return json(res, 404, { message: `unhandled ${req.method} ${url.pathname}` }) }) diff --git a/tests/publication.test.mjs b/tests/publication.test.mjs new file mode 100644 index 0000000..65e9733 --- /dev/null +++ b/tests/publication.test.mjs @@ -0,0 +1,283 @@ +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 = 'v1:' + 'a'.repeat(64) +const sites = ['alpha', 'beta'].map((name) => ({ id: name.padEnd(15, '0'), name, host: `${name}.example.com` })) +async function fixture(t, config = {}) { + const workspace = await make_workspace() + const server = await start_mock_server({ sites, publication: true, ...config }) + 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: Home\n') + await save_baseline(dir, server.url, site.id, revision) + } + const run = (...args) => run_cli([args[0], '--token', 'test-token', ...args.slice(1)], { cwd: workspace.work, home: workspace.home }) + return { workspace, server, run } +} + +test('plain push saves drafts and prints the scoped publication command without publishing', async (t) => { + const { server, run } = await fixture(t) + const result = await run('push', '--only', 'alpha') + assert.equal(result.code, 0, result.output) + assert.match(result.output, /Publication was not requested/) + assert.match(result.output, /primo publish --dir/) + assert.equal(server.matching(/\/publication\//).length, 0) +}) + +test('push --publish reports each phase separately and waits until all imports, including library, finish', async (t) => { + const { server, workspace, run } = await fixture(t) + 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) + const result = await run('push', '--publish', '--json') + assert.equal(result.code, 0, result.output) + const parsed = JSON.parse(result.stdout) + assert.equal(parsed.ok, true) + assert.equal(parsed.results.length, 3) + assert.equal(parsed.results[2].publish.state, 'not_requested') + for (const site of parsed.results.slice(0, 2)) { + assert.equal(site.push.state, 'succeeded') + assert.equal(site.publish.state, 'succeeded') + assert.equal(site.push.revision, site.publish.revision) + } + const imports = server.requests.map((r, i) => (r.path.includes('/import') ? i : -1)).filter((i) => i >= 0) + const firstPublication = server.requests.findIndex((r) => r.path.includes('/publication/')) + assert.ok(firstPublication > Math.max(...imports)) + assert.ok(server.matching(/\/publication\/.*\/activate/).every((r) => r.authorization === 'Bearer test-token')) + assert.doesNotMatch(result.stdout, /test-token/) +}) + +test('publish uses hosted authentication, uploads no local files and does not invoke dev-auth', async (t) => { + const { server, run } = await fixture(t) + const result = await run('publish', '--only', 'alpha', '--json') + assert.equal(result.code, 0, result.output) + assert.equal(JSON.parse(result.stdout).results[0].publish.state, 'succeeded') + assert.equal(server.matching(/\/import/).length, 0) + assert.equal(server.matching('/api/primo/dev-auth').length, 0) + assert.equal(server.matching('/api/primo/generate').length, 0) + assert.ok(server.matching(/\/collections\//).every((r) => r.authorization === 'test-token')) +}) + +test('failed publication preserves push success and baseline; standalone publication retries without pushing', async (t) => { + let fail = true + const { server, workspace, run } = await fixture(t, { + on_request: ({ res, url, publications }) => { + if (fail && url.pathname.endsWith('/activate')) { + const state = publications[sites[0].id] + state.state = 'failed' + state.attempt.state = 'failed' + state.attempt.error = 'Generation failed' + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ message: 'Generation failed' })) + return true + } + } + }) + const result = await run('push', '--only', 'alpha', '--publish', '--json') + assert.equal(result.code, 1, result.output) + const target = JSON.parse(result.stdout).results[0] + assert.equal(target.push.state, 'succeeded') + assert.equal(target.publish.state, 'failed') + assert.match(target.publish.retry_command, /primo publish/) + const baseline = JSON.parse(await fs.readFile(path.join(workspace.work, 'sites/alpha/.primo/sync-state.json'), 'utf8')) + assert.equal(Object.values(baseline)[0].revision, server.revisions[sites[0].id]) + const imports = server.matching(/\/import/).length + fail = false + const retried = await run('publish', '--only', 'alpha', '--json') + assert.equal(retried.code, 0, retried.output) + assert.equal(server.matching(/\/import/).length, imports) +}) + +test('a lost activation response is reconciled with authoritative status', async (t) => { + const { run } = await fixture(t, { + on_request: ({ res, url, publications }) => { + if (url.pathname.endsWith('/activate')) { + const state = publications[sites[0].id] + state.attempt.state = 'succeeded' + state.published_revision = state.attempt.revision + state.state = 'current' + res.destroy() + return true + } + } + }) + const result = await run('publish', '--only', 'alpha', '--json') + assert.equal(result.code, 0, result.output) + assert.equal(JSON.parse(result.stdout).results[0].publish.state, 'succeeded') +}) + +test('an ambiguous activation reports unknown and never records failure', async (t) => { + let activated = false + const { server, run } = await fixture(t, { + on_request: ({ req, res, url }) => { + if (url.pathname.endsWith('/activate')) { + activated = true + res.destroy() + return true + } + if (activated && req.method === 'GET' && url.pathname.includes('/publication/')) { + res.destroy() + return true + } + } + }) + const result = await run('publish', '--only', 'alpha', '--json') + assert.equal(result.code, 1, result.output) + assert.equal(JSON.parse(result.stdout).results[0].publish.state, 'unknown') + assert.equal(server.matching(/\/fail$/).length, 0) +}) + +test('failed push prevents publication of all targets and reports unattempted 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] = 'v1:' + 'b'.repeat(64) + } + }) + const result = await run('push', '--publish', '--json') + assert.equal(result.code, 1, result.output) + const parsed = JSON.parse(result.stdout) + assert.equal(parsed.results[0].push.state, 'succeeded') + assert.equal(parsed.results[0].publish.state, 'not_attempted') + assert.equal(parsed.results[1].push.state, 'failed') + assert.equal(server.matching(/\/publication\//).length, 0) +}) + +test('preview and dry-run cannot publish, even in non-interactive JSON mode', async (t) => { + const { server, run } = await fixture(t) + for (const flag of ['--preview', '--dry-run']) { + const result = await run('push', flag, '--publish', '--json') + assert.equal(result.code, 1, result.output) + assert.match(JSON.parse(result.stdout).error, /cannot be combined/) + } + assert.equal(server.requests.length, 0) +}) + +test('hosted status reports behind and unknown without falling back to local state', async (t) => { + const { server, run } = await fixture(t) + const published = await run('publish', '--only', 'alpha') + assert.equal(published.code, 0, published.output) + server.revisions[sites[0].id] = 'v1:' + 'f'.repeat(64) + const result = await run('status', '--hosted', '--only', 'alpha', '--json') + assert.equal(result.code, 0, result.output) + const publication = JSON.parse(result.stdout).results[0].publication + assert.equal(publication.state, 'behind') + assert.equal(publication.unpublished_changes, true) + assert.equal(publication.published_revision, revision) +}) + +test('an older CMS leaves the successful push saved and reports unsupported publication', async (t) => { + const { run } = await fixture(t, { publication: false }) + const result = await run('push', '--only', 'alpha', '--publish', '--json') + assert.equal(result.code, 1, result.output) + const target = JSON.parse(result.stdout).results[0] + assert.equal(target.push.state, 'succeeded') + assert.equal(target.publish.state, 'failed') + assert.match(target.publish.error, /Update the CMS/) + const status = await run('status', '--hosted', '--only', 'alpha', '--json') + assert.equal(status.code, 1, status.output) + assert.equal(JSON.parse(status.stdout).results[0].publication.state, 'unknown') +}) + +test('a shared library upload failure leaves every publication unattempted', async (t) => { + const { server, workspace, run } = await fixture(t, { + on_request: ({ req, res, url }) => { + if (req.method === 'POST' && url.pathname === '/api/primo/import-library') { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ message: 'Library import failed' })) + return true + } + } + }) + 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) + const result = await run('push', '--publish', '--json') + assert.equal(result.code, 1, result.output) + const parsed = JSON.parse(result.stdout) + assert.equal(parsed.results[2].push.state, 'failed') + assert.equal(parsed.results[2].push.error_code, 'push_failed') + assert.ok(parsed.results.slice(0, 2).every((item) => item.push.state === 'succeeded' && item.publish.state === 'not_attempted')) + assert.equal(server.matching(/\/publication\//).length, 0) +}) + +test('publication failure on one site still reports successful publication of another', async (t) => { + const { run } = await fixture(t, { + on_request: ({ res, url, publications }) => { + if (url.pathname === `/api/primo/publication/${sites[0].id}/${publications[sites[0].id]?.attempt.id}/activate`) { + const state = publications[sites[0].id] + state.state = 'failed' + state.attempt.state = 'failed' + state.attempt.error = 'alpha failed' + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ message: 'alpha failed' })) + return true + } + } + }) + const result = await run('push', '--publish', '--json') + assert.equal(result.code, 1, result.output) + const parsed = JSON.parse(result.stdout) + assert.equal(parsed.results[0].publish.state, 'failed') + assert.equal(parsed.results[1].publish.state, 'succeeded') +}) + +test('standalone publish reads stored login tokens and the workspace server fallback', async (t) => { + const { workspace, server } = await fixture(t) + const dir = path.join(workspace.work, 'sites/alpha') + await fs.writeFile(path.join(dir, 'site.yaml'), `name: Alpha\nsite_id: ${sites[0].id}\n`) + await workspace.write_token(server.url, 'stored-token') + const result = await run_cli(['publish', '--dir', dir, '--json'], { cwd: workspace.work, home: workspace.home }) + assert.equal(result.code, 0, result.output) + assert.ok(server.matching(/\/publication\//).every((r) => r.authorization === 'Bearer stored-token')) + assert.doesNotMatch(result.stdout, /stored-token/) +}) + +test('push and standalone publication agree on explicit per-site servers', async (t) => { + const { workspace, server, run } = await fixture(t) + const alternate = await start_mock_server({ sites: [sites[0]], publication: true }) + t.after(() => alternate.close()) + const dir = path.join(workspace.work, 'sites/alpha') + await fs.writeFile(path.join(dir, 'site.yaml'), `name: Alpha\nsite_id: ${sites[0].id}\nserver: ${alternate.url}\n`) + await save_baseline(dir, alternate.url, sites[0].id, revision) + const result = await run('push', '--only', 'alpha', '--publish', '--json') + assert.equal(result.code, 0, result.output) + assert.equal(JSON.parse(result.stdout).results[0].server, alternate.url) + assert.equal(server.requests.length, 0) + const publish = await run('publish', '--only', 'alpha', '--json') + assert.equal(publish.code, 0, publish.output) + assert.equal(JSON.parse(publish.stdout).results[0].server, alternate.url) +}) + +test('dry-run JSON lists selected targets without sending requests', async (t) => { + const { server, run } = await fixture(t) + const result = await run('push', '--only', 'alpha', '--dry-run', '--json') + assert.equal(result.code, 0, result.output) + const parsed = JSON.parse(result.stdout) + assert.equal(parsed.results.length, 1) + assert.equal(parsed.results[0].target, 'alpha') + assert.equal(parsed.results[0].push.state, 'not_attempted') + assert.equal(parsed.results[0].publish.state, 'not_requested') + assert.equal(server.requests.length, 0) +}) + +test('explicit server and site publish hosted content without a local checkout', async (t) => { + const { workspace, server } = await fixture(t) + const result = await run_cli(['publish', server.url, '--site', sites[0].id, '--token', 'test-token', '--json'], { cwd: workspace.home, home: workspace.home }) + assert.equal(result.code, 0, result.output) + assert.equal(JSON.parse(result.stdout).results[0].publish.state, 'succeeded') + const status = await run_cli(['status', '--server', server.url, '--site', sites[0].id, '--token', 'test-token', '--json'], { cwd: workspace.home, home: workspace.home }) + assert.equal(status.code, 0, status.output) + assert.equal(JSON.parse(status.stdout).results[0].publication.state, 'current') +}) diff --git a/tests/publish-cms.test.mjs b/tests/publish-cms.test.mjs new file mode 100644 index 0000000..de1a185 --- /dev/null +++ b/tests/publish-cms.test.mjs @@ -0,0 +1,124 @@ +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' + +test( + 'real CMS: draft push, publish, unpublished edits, failed publication and retry', + { + 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 cms = spawn(process.env.PRIMO_TEST_CMS_BINARY, ['serve', '--http', `127.0.0.1:${port}`, '--dir', path.join(workspace.root, 'pb_data')], { + env: { ...process.env, PRIMO_DEV_MODE: '1', PRIMO_AUTHOR_MODE: 'both', PRIMO_ENABLE_USAGE_STATS: 'false' }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + cms.stdout.on('data', (data) => { + logs += data + }) + cms.stderr.on('data', (data) => { + logs += data + }) + t.after(async () => { + if (cms.exitCode === null) { + const stopped = new Promise((resolve) => cms.once('close', resolve)) + cms.kill('SIGTERM') + await stopped + } + await workspace.cleanup() + }) + let ready = false + for (let i = 0; i < 100; i++) { + try { + if ((await fetch(`${server}/api/health`)).ok) { + ready = true + break + } + } catch {} + if (cms.exitCode !== null) break + await delay(100) + } + assert.ok(ready, logs) + const id = 'publicationdemo' + const dir = path.join(workspace.work, 'sites/demo') + const component = path.join(dir, 'blocks/hero/component.svelte') + const page = path.join(dir, 'pages/index.yaml') + 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 [file, value] of Object.entries(files)) { + await fs.mkdir(path.dirname(path.join(workspace.work, file)), { recursive: true }) + await fs.writeFile(path.join(workspace.work, file), value) + } + const cli = (args) => run_cli(args, { cwd: workspace.work, home: workspace.home, timeout_ms: 30000 }) + const bootstrap = await cli(['push', '--json']) + assert.equal(bootstrap.code, 0, bootstrap.output) + assert.equal(JSON.parse(bootstrap.stdout).results[0].publish.state, 'not_requested') + const { token } = await fetch(`${server}/api/primo/dev-auth`, { method: 'POST' }).then((r) => r.json()) + assert.ok(token) + const auth = { Authorization: `Bearer ${token}` } + const publicHTML = () => fetch(`${server}/?_site=${id}`).then((r) => r.text()) + const status = () => fetch(`${server}/api/primo/publication/${id}`, { headers: auth }).then((r) => r.json()) + assert.equal((await status()).state, 'never_published') + const published = await cli(['publish', '--only', 'demo', '--token', token, '--json']) + assert.equal(published.code, 0, published.output) + assert.equal(JSON.parse(published.stdout).results[0].publish.state, 'succeeded') + assert.match(await publicHTML(), /Original headline/) + const originalRevision = (await status()).published_revision + await fs.writeFile(page, files['sites/demo/pages/index.yaml'].replace('Original headline', 'Draft headline')) + const draft = await cli(['push', '--only', 'demo', '--token', token, '--json']) + assert.equal(draft.code, 0, draft.output) + assert.equal((await status()).state, 'behind') + assert.match(await publicHTML(), /Original headline/) + assert.doesNotMatch(await publicHTML(), /Draft headline/) + const combined = await cli(['push', '--only', 'demo', '--token', token, '--publish', '--json']) + assert.equal(combined.code, 0, combined.output) + const result = JSON.parse(combined.stdout).results[0] + assert.equal(result.push.state, 'succeeded') + assert.equal(result.publish.state, 'succeeded') + assert.equal(result.push.revision, result.publish.revision) + assert.notEqual(result.push.revision, originalRevision) + assert.match(await publicHTML(), /Draft headline/) + // Importing source saves a draft; only publication evaluates the Svelte code. + await fs.writeFile(component, '

{heading}

') + const failure = await cli(['push', '--only', 'demo', '--token', token, '--publish', '--json']) + assert.equal(failure.code, 1, failure.output) + const failed = JSON.parse(failure.stdout).results[0] + assert.equal(failed.push.state, 'succeeded', failure.output) + assert.equal(failed.publish.state, 'failed', failure.output) + assert.match(await publicHTML(), /Draft headline/) + assert.equal((await status()).state, 'failed') + // Repair the hosted draft through CMS APIs and retry publication alone. + const symbols = await fetch(`${server}/api/collections/site_symbols/records?filter=${encodeURIComponent(`site="${id}"`)}`, { headers: auth }).then((r) => r.json()) + const repair = await fetch(`${server}/api/collections/site_symbols/records/${symbols.items[0].id}`, { + method: 'PATCH', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ js: '', html: '

Repaired hosted draft

' }) + }) + assert.equal(repair.status, 200, await repair.text()) + const retry = await cli(['publish', '--only', 'demo', '--token', token, '--json']) + assert.equal(retry.code, 0, retry.output) + assert.match(await publicHTML(), /Repaired hosted draft/) + assert.equal((await status()).state, 'current') + const baseline = JSON.parse(await fs.readFile(path.join(dir, '.primo/sync-state.json'), 'utf8')) + assert.equal(Object.values(baseline)[0].revision, failed.push.revision, 'publication must not advance the local push baseline across a CMS edit') + } +)