primo-cli 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,6 +51,11 @@ export async function build_site(options) {
51
51
  // Find all pages
52
52
  const pages_dir = path.join(site_dir, 'pages');
53
53
  const page_files = await find_pages(pages_dir);
54
+ // Map each page's _id to its live URL so internal `page:` links resolve.
55
+ // The URL is derived from the page's file location (same convention the
56
+ // build uses to write output), not the slug, so a page-ref and a raw
57
+ // url pointing at the same page produce identical hrefs.
58
+ const page_url_map = await build_page_url_map(site_dir, page_files);
54
59
  spinner.text = `Building ${page_files.length} page${page_files.length !== 1 ? 's' : ''}...`;
55
60
  // Compile all blocks once and cache them
56
61
  const block_cache = new Map();
@@ -77,7 +82,8 @@ export async function build_site(options) {
77
82
  block_cache,
78
83
  layout_cache,
79
84
  page_type_head_cache,
80
- site_data
85
+ site_data,
86
+ page_url_map
81
87
  });
82
88
  if (result.error) {
83
89
  console.log(chalk.yellow(` Warning: ${page.name}: ${result.error}`));
@@ -140,7 +146,7 @@ async function load_page_type_head(site_dir, page_type, cache) {
140
146
  return content;
141
147
  }
142
148
  async function build_page(options) {
143
- const { page, page_path, site_dir, temp_dir, head_content, site_name, block_cache, layout_cache, page_type_head_cache, site_data } = options;
149
+ const { page, page_path, site_dir, temp_dir, head_content, site_name, block_cache, layout_cache, page_type_head_cache, site_data, page_url_map } = options;
144
150
  try {
145
151
  const page_build_id = safe_temp_id(page._id || page.id || page_path || page.name || 'page');
146
152
  // Load layout for this page type
@@ -155,9 +161,9 @@ async function build_page(options) {
155
161
  const page_type_head = await load_page_type_head(site_dir, page_type, page_type_head_cache);
156
162
  const combined_head_content = page_type_head ? `${head_content}\n${page_type_head}` : head_content;
157
163
  // Combine header + page sections + footer
158
- const header_sections = await resolve_layout_sections(layout.header || [], site_dir, site_data);
159
- const footer_sections = await resolve_layout_sections(layout.footer || [], site_dir, site_data);
160
- const page_sections = await resolve_page_sections(page.sections || [], site_dir, site_data);
164
+ const header_sections = await resolve_layout_sections(layout.header || [], site_dir, site_data, page_url_map);
165
+ const footer_sections = await resolve_layout_sections(layout.footer || [], site_dir, site_data, page_url_map);
166
+ const page_sections = await resolve_page_sections(page.sections || [], site_dir, site_data, page_url_map);
161
167
  const all_sections = [...header_sections, ...page_sections, ...footer_sections];
162
168
  if (all_sections.length === 0) {
163
169
  return { html: generate_empty_page(site_name, page.name, combined_head_content) };
@@ -448,7 +454,7 @@ async function load_layout(site_dir, page_type) {
448
454
  return {};
449
455
  }
450
456
  }
451
- async function resolve_layout_sections(sections, site_dir, site_data) {
457
+ async function resolve_layout_sections(sections, site_dir, site_data, page_url_map) {
452
458
  // For layout sections without content, load from block's content.yaml
453
459
  const resolved = [];
454
460
  for (const section of sections) {
@@ -462,20 +468,73 @@ async function resolve_layout_sections(sections, site_dir, site_data) {
462
468
  }
463
469
  // Resolve any site-field references in the content
464
470
  const resolved_content = await resolve_site_fields(site_dir, section.block, content, site_data);
465
- resolved.push({ ...section, content: resolved_content });
471
+ // Resolve internal page: links to URLs (walks nested repeaters/groups too)
472
+ resolved.push({ ...section, content: resolve_links(resolved_content, page_url_map) });
466
473
  }
467
474
  return resolved;
468
475
  }
469
- async function resolve_page_sections(sections, site_dir, site_data) {
476
+ async function resolve_page_sections(sections, site_dir, site_data, page_url_map) {
470
477
  // Resolve site-field references in page sections
471
478
  const resolved = [];
472
479
  for (const section of sections) {
473
480
  const content = section.content || {};
474
481
  const resolved_content = await resolve_site_fields(site_dir, section.block, content, site_data);
475
- resolved.push({ ...section, content: resolved_content });
482
+ // Resolve internal page: links to URLs (walks nested repeaters/groups too)
483
+ resolved.push({ ...section, content: resolve_links(resolved_content, page_url_map) });
476
484
  }
477
485
  return resolved;
478
486
  }
487
+ // Build a map of page _id -> live URL path. The URL is derived from the page
488
+ // file's location (the same convention used to write output HTML), so a
489
+ // `page:` reference resolves to exactly the path that page is deployed at.
490
+ async function build_page_url_map(site_dir, page_files) {
491
+ const map = new Map();
492
+ for (const page_file of page_files) {
493
+ try {
494
+ const page = load_yaml(await fs.readFile(page_file, 'utf-8'));
495
+ const id = page._id || page.id;
496
+ if (!id)
497
+ continue;
498
+ const page_path = get_page_path_from_file(site_dir, page_file);
499
+ map.set(id, page_path === '' ? '/' : `/${page_path}`);
500
+ }
501
+ catch {
502
+ // Skip unparseable page files; they'll surface elsewhere in the build.
503
+ }
504
+ }
505
+ return map;
506
+ }
507
+ // Recursively resolve internal `page:` links to their URL. Walks arbitrarily
508
+ // nested content (repeaters, groups, page-lists), so links inside a
509
+ // site-field-referenced repeater resolve the same as top-level link fields.
510
+ //
511
+ // A link value is any object carrying a `page` id. We look the id up in the
512
+ // page URL map and populate `url`:
513
+ // - known page id -> the page's live URL
514
+ // - missing/deleted id -> '' (degrades to href="#" downstream, never crashes)
515
+ // Objects with only `url` (raw/external links) are left untouched, and every
516
+ // other key on the link object (label, etc.) is preserved.
517
+ function resolve_links(value, page_url_map) {
518
+ if (Array.isArray(value)) {
519
+ return value.map((item) => resolve_links(item, page_url_map));
520
+ }
521
+ if (value && typeof value === 'object') {
522
+ const obj = value;
523
+ // A link with a page reference: resolve it to a URL.
524
+ if (typeof obj.page === 'string' && obj.page) {
525
+ const url = page_url_map.get(obj.page) ?? '';
526
+ return { ...obj, url };
527
+ }
528
+ // Otherwise recurse into every value (covers repeater arrays, groups,
529
+ // and the `{ link: {...} }` wrapper repeaters produce).
530
+ const result = {};
531
+ for (const [key, child] of Object.entries(obj)) {
532
+ result[key] = resolve_links(child, page_url_map);
533
+ }
534
+ return result;
535
+ }
536
+ return value;
537
+ }
479
538
  async function load_block_defaults(site_dir, block_name) {
480
539
  const content_path = path.join(site_dir, 'blocks', block_name, 'content.yaml');
481
540
  try {
@@ -224,11 +224,13 @@ async function check_provider_auth(provider) {
224
224
  return false;
225
225
  }
226
226
  }
227
- // Pinned to the upstream-published image. primocms's main.yml workflow
228
- // publishes branch tags for whitelisted prefixes (main, feature/**, rc/**),
229
- // with slashes slugified to dashes (feature/local-dev-cli :feature-local-dev-cli).
230
- // Bump to a release tag (:v3.0.0) when primocms cuts a stable release.
231
- const PRIMO_SERVER_IMAGE = 'ghcr.io/primocms/primo:main';
227
+ // Track the latest stable release. primocms's tag.yml workflow publishes a
228
+ // :latest image (alongside semver tags) on every version tag the comment
229
+ // there calls out that "Railway/self-host deployers follow this tag to track
230
+ // releases". Following :latest means deploys pick up new releases on their next
231
+ // rebuild without a per-release bump here. (main.yml still publishes branch tags
232
+ // like :main / :feature-* for testing; :latest is the released line.)
233
+ const PRIMO_SERVER_IMAGE = 'ghcr.io/primocms/primo:latest';
232
234
  async function generate_dockerfile(inventory) {
233
235
  // One-line Dockerfile: pull the published primo image and run it
234
236
  // unchanged. Workspace data (server.yaml, sites/, library/) is uploaded
@@ -29,6 +29,12 @@ let last_import_time = 0; // Timestamp of last import completion
29
29
  let last_local_change_time = 0; // Timestamp of most recent local watcher event
30
30
  const importing_site_keys = new Set();
31
31
  const pending_local_site_keys = new Set();
32
+ // Sites whose last import returned null (duplicate _ids across files — nothing
33
+ // was pushed). They stay quarantined until a later import succeeds: no CMS→file
34
+ // polling into them (a quarantined site is out of sync with the CMS, so copying
35
+ // remote-only paths in could clobber the local files the user must edit to fix
36
+ // the conflict). Cleared on the next successful import.
37
+ const blocked_site_keys = new Set();
32
38
  let is_importing_library = false;
33
39
  let has_pending_library_local_changes = false;
34
40
  let site_sync_baselines = new Map();
@@ -618,6 +624,7 @@ export async function dev_server(options) {
618
624
  }
619
625
  // Normalize and load all sites
620
626
  spinner.text = `Loading ${sites.length} site${sites.length > 1 ? 's' : ''}...`;
627
+ const blocked_sites = new Set();
621
628
  for (const site of sites) {
622
629
  const use_bootstrap = !await site_exists(api_url, site.config.site_id);
623
630
  if (sync_policy.mode === 'cms' && !use_bootstrap) {
@@ -625,7 +632,17 @@ export async function dev_server(options) {
625
632
  continue;
626
633
  }
627
634
  await normalize_site(site.dir);
635
+ const site_key = get_site_sync_key(site.dir, site.config);
628
636
  const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, use_bootstrap, base_dir));
637
+ if (import_timings === null) {
638
+ // Duplicate _ids — nothing was pushed; the watcher retries
639
+ // once the user removes a conflicting file. Quarantine it so
640
+ // CMS→file polling doesn't sync into an out-of-sync site.
641
+ blocked_sites.add(site.dir);
642
+ blocked_site_keys.add(site_key);
643
+ continue;
644
+ }
645
+ blocked_site_keys.delete(site_key);
629
646
  if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
630
647
  await update_site_sync_baseline(site, api_url, server_config, base_dir);
631
648
  }
@@ -633,6 +650,8 @@ export async function dev_server(options) {
633
650
  // Verify all sites are accessible before proceeding
634
651
  spinner.text = 'Verifying sites...';
635
652
  for (const site of sites) {
653
+ if (blocked_sites.has(site.dir))
654
+ continue;
636
655
  await verify_site_ready(api_url, site.config.site_id);
637
656
  }
638
657
  spinner.succeed('Primo running');
@@ -816,6 +835,15 @@ export async function dev_server(options) {
816
835
  }
817
836
  }
818
837
  const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, false, base_dir));
838
+ if (import_timings === null) {
839
+ // Duplicate _ids — import_site_files already printed the
840
+ // error and wrote sync_status; nothing was pushed. Keep
841
+ // the site quarantined from CMS→file polling.
842
+ blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
843
+ return;
844
+ }
845
+ // Import succeeded — lift any prior quarantine.
846
+ blocked_site_keys.delete(get_site_sync_key(site.dir, site.config));
819
847
  let reload_ms = 0;
820
848
  if (pending_reload) {
821
849
  try {
@@ -974,7 +1002,12 @@ export async function dev_server(options) {
974
1002
  else {
975
1003
  await normalize_site(site.dir);
976
1004
  const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, use_bootstrap, base_dir));
977
- if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
1005
+ if (import_timings === null) {
1006
+ // Duplicate _ids on a freshly discovered site — quarantine
1007
+ // it from CMS→file polling until a later import succeeds.
1008
+ blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
1009
+ }
1010
+ else if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
978
1011
  await update_site_sync_baseline(site, api_url, server_config, base_dir);
979
1012
  }
980
1013
  }
@@ -1013,7 +1046,9 @@ export async function dev_server(options) {
1013
1046
  for (const site of sites) {
1014
1047
  try {
1015
1048
  const site_key = get_site_sync_key(site.dir, site.config);
1016
- if (importing_site_keys.has(site_key) || pending_local_site_keys.has(site_key)) {
1049
+ if (importing_site_keys.has(site_key) ||
1050
+ pending_local_site_keys.has(site_key) ||
1051
+ blocked_site_keys.has(site_key)) {
1017
1052
  continue;
1018
1053
  }
1019
1054
  await sync_from_cms(site.dir, api_url, site.config, server_config, base_dir, sync_policy);
@@ -1678,19 +1713,19 @@ function describe_duplicate(category, id, occurrences) {
1678
1713
  const files = [...new Set(occurrences.map((occurrence) => occurrence.file))].sort();
1679
1714
  switch (category) {
1680
1715
  case 'pages':
1681
- return `duplicate page _id "${id}" in ${files.join(' and ')}; skipping those pages`;
1716
+ return `duplicate page _id "${id}" in ${files.join(' and ')}`;
1682
1717
  case 'page_sections':
1683
- return `duplicate section _id "${id}" in ${files.join(' and ')}; skipping those pages`;
1718
+ return `duplicate section _id "${id}" in ${files.join(' and ')}`;
1684
1719
  case 'blocks':
1685
- return `duplicate block _id "${id}" in ${files.join(' and ')}; skipping those blocks`;
1720
+ return `duplicate block _id "${id}" in ${files.join(' and ')}`;
1686
1721
  case 'page_types':
1687
- return `duplicate page type _id "${id}" in ${files.join(' and ')}; skipping those page types`;
1722
+ return `duplicate page type _id "${id}" in ${files.join(' and ')}`;
1688
1723
  case 'site_fields':
1689
- return `duplicate site field _id "${id}" in ${files.join(' and ')}; skipping site/fields.yaml`;
1724
+ return `duplicate site field _id "${id}" in ${files.join(' and ')}`;
1690
1725
  case 'block_fields':
1691
- return `duplicate block field _id "${id}" in ${files.join(' and ')}; skipping those blocks`;
1726
+ return `duplicate block field _id "${id}" in ${files.join(' and ')}`;
1692
1727
  case 'page_type_fields':
1693
- return `duplicate page type field _id "${id}" in ${files.join(' and ')}; skipping those page types`;
1728
+ return `duplicate page type field _id "${id}" in ${files.join(' and ')}`;
1694
1729
  }
1695
1730
  }
1696
1731
  async function prepare_site_for_local_dev(site_dir) {
@@ -1854,8 +1889,31 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1854
1889
  const site_id = config.site_id;
1855
1890
  const site_group = resolve_site_group(config, server_config);
1856
1891
  const preparation = await prepare_site_for_local_dev(site_dir);
1857
- for (const warning of preparation.warnings) {
1858
- console.log(chalk.yellow(` ⚠ ${config.name}: ${warning}`));
1892
+ if (preparation.warnings.length > 0) {
1893
+ // Duplicate _ids (the only warning source in prepare_site_for_local_dev)
1894
+ // would leave the server with stale/dangling state if we pushed anyway,
1895
+ // so fail loudly instead of silently excluding the conflicting files.
1896
+ for (const warning of preparation.warnings) {
1897
+ console.log(chalk.red(` ✖ ${config.name}: ${warning}`));
1898
+ }
1899
+ console.log(chalk.dim(' Remove one of the conflicting files and save to retry.'));
1900
+ const message = preparation.warnings[0];
1901
+ await write_sync_status(site_dir, {
1902
+ ok: false,
1903
+ error: message,
1904
+ failed_at: new Date().toISOString()
1905
+ });
1906
+ try {
1907
+ // Best-effort — older servers don't have this endpoint.
1908
+ await fetch(`${api_url}/api/primo/dev/status`, {
1909
+ method: 'POST',
1910
+ headers: { 'Content-Type': 'application/json' },
1911
+ body: JSON.stringify({ status: 'error', message }),
1912
+ signal: AbortSignal.timeout(2000)
1913
+ });
1914
+ }
1915
+ catch { }
1916
+ return null;
1859
1917
  }
1860
1918
  // Create ZIP of site files
1861
1919
  const zip_started = Date.now();
@@ -3,4 +3,5 @@ interface LoginOptions {
3
3
  email?: string;
4
4
  }
5
5
  export declare function login(options: LoginOptions): Promise<void>;
6
+ export declare function authenticate_interactively(server: string, email?: string): Promise<string | null>;
6
7
  export {};
@@ -3,7 +3,7 @@ import ora from 'ora';
3
3
  import readline from 'readline';
4
4
  import fs from 'fs/promises';
5
5
  import { save_auth_token } from '../utils/auth.js';
6
- import { read_server_config, get_server_config_path } from '../utils/server-config.js';
6
+ import { read_server_config, get_server_config_path, normalize_server_url } from '../utils/server-config.js';
7
7
  export async function login(options) {
8
8
  let server_url = options.server;
9
9
  if (!server_url) {
@@ -29,7 +29,19 @@ export async function login(options) {
29
29
  console.log('');
30
30
  console.log(chalk.bold(`Logging in to ${server}`));
31
31
  console.log('');
32
- let email = options.email;
32
+ const token = await authenticate_interactively(server, options.email);
33
+ if (!token) {
34
+ process.exit(1);
35
+ }
36
+ console.log('');
37
+ console.log(chalk.dim(' Token saved to ~/.primo/tokens.json'));
38
+ console.log(chalk.dim(' You can now use `primo pull` and `primo push` without --token'));
39
+ }
40
+ // Prompt for credentials (email if not supplied, then password), authenticate
41
+ // against the server, and persist the token on success. Returns the token, or
42
+ // null if authentication failed. Shared by `login` and the auto-login prompt
43
+ // that `pull`/`push` fall back to when no token is cached.
44
+ export async function authenticate_interactively(server, email) {
33
45
  if (!email) {
34
46
  email = await prompt('Email: ');
35
47
  }
@@ -50,28 +62,18 @@ export async function login(options) {
50
62
  if (!response.ok) {
51
63
  const error = await response.json().catch(() => ({ message: 'Authentication failed' }));
52
64
  spinner.fail(`Login failed: ${error.message || 'Invalid credentials'}`);
53
- process.exit(1);
65
+ return null;
54
66
  }
55
67
  const data = await response.json();
56
68
  // Save the token
57
69
  await save_auth_token(server, data.token);
58
70
  spinner.succeed(`Logged in as ${chalk.cyan(data.record.email)}`);
59
- console.log('');
60
- console.log(chalk.dim(' Token saved to ~/.primo/tokens.json'));
61
- console.log(chalk.dim(' You can now use `primo pull` and `primo push` without --token'));
71
+ return data.token;
62
72
  }
63
73
  catch (error) {
64
74
  spinner.fail(`Login failed: ${error instanceof Error ? error.message : error}`);
65
- process.exit(1);
66
- }
67
- }
68
- function normalize_server_url(server) {
69
- // Add https:// if no protocol specified
70
- if (!server.startsWith('http://') && !server.startsWith('https://')) {
71
- server = `https://${server}`;
75
+ return null;
72
76
  }
73
- // Remove trailing slash
74
- return server.replace(/\/+$/, '');
75
77
  }
76
78
  function prompt(question) {
77
79
  const rl = readline.createInterface({
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import extract from 'extract-zip';
6
6
  import { get_auth_token } from '../utils/auth.js';
7
+ import { normalize_server_url } from '../utils/server-config.js';
7
8
  async function detect_server() {
8
9
  const ports = [3000, 8080, 5173];
9
10
  for (const port of ports) {
@@ -27,7 +28,7 @@ export async function pull_library(options) {
27
28
  try {
28
29
  let server;
29
30
  if (options.server) {
30
- server = options.server.replace(/\/+$/, '');
31
+ server = normalize_server_url(options.server);
31
32
  }
32
33
  else {
33
34
  spinner.text = 'Looking for local server...';
@@ -5,8 +5,13 @@ import ora from 'ora';
5
5
  import extract from 'extract-zip';
6
6
  import { dump as dump_yaml, load as load_yaml } from 'js-yaml';
7
7
  import { get_auth_token } from '../utils/auth.js';
8
+ import { authenticate_interactively } from './login.js';
8
9
  import { write_site_config } from '../utils/site-config.js';
9
- import { read_server_config, write_server_config } from '../utils/server-config.js';
10
+ import { read_server_config, write_server_config, normalize_server_url } from '../utils/server-config.js';
11
+ // Directories owned by the server export. Local files under these that no
12
+ // longer exist in the export are stale (e.g. a page that gained children
13
+ // moved from pages/foo.yaml to pages/foo/index.yaml) and get trashed.
14
+ const MANAGED_DIRS = ['pages', 'blocks', 'page-types', 'site'];
10
15
  async function detect_server() {
11
16
  const ports = [3000, 8080, 5173];
12
17
  for (const port of ports) {
@@ -64,12 +69,15 @@ export async function pull_site(options) {
64
69
  let server;
65
70
  let used_configured = false;
66
71
  if (options.server) {
67
- server = options.server.replace(/\/+$/, '');
72
+ server = normalize_server_url(options.server);
68
73
  }
69
74
  else {
70
75
  const configured = await read_configured_server(process.cwd());
71
76
  if (configured) {
72
- server = configured;
77
+ // A hand-edited server.yaml may hold a bare host; normalize it so
78
+ // is_remote_server() and the inline-login guard below behave the
79
+ // same as they do for a --server flag.
80
+ server = normalize_server_url(configured);
73
81
  used_configured = true;
74
82
  spinner.text = `Using ${server}`;
75
83
  }
@@ -80,8 +88,21 @@ export async function pull_site(options) {
80
88
  spinner.text = `Using ${server}`;
81
89
  }
82
90
  }
83
- // Auth (optional for local)
84
- const token = options.token || await get_auth_token(server);
91
+ // Auth (optional for local). For a remote server with no cached token,
92
+ // prompt for login inline instead of bailing out — the user almost
93
+ // always wants to authenticate and continue rather than re-run.
94
+ let token = options.token || await get_auth_token(server);
95
+ if (!token && is_remote_server(server)) {
96
+ spinner.stop();
97
+ console.log('');
98
+ console.log(chalk.dim(` Not logged in to ${server}. Log in to continue.`));
99
+ console.log('');
100
+ token = await authenticate_interactively(server);
101
+ if (!token) {
102
+ process.exit(1);
103
+ }
104
+ spinner.start('Fetching sites...');
105
+ }
85
106
  const headers = {};
86
107
  if (token) {
87
108
  headers['Authorization'] = `Bearer ${token}`;
@@ -192,8 +213,27 @@ async function pull_one_site(server, headers, site, site_dir, spinner) {
192
213
  const temp_zip = path.join(site_dir, '.primo-export.zip');
193
214
  await fs.writeFile(temp_zip, Buffer.from(zip_data));
194
215
  spinner.text = `Extracting ${site.name}...`;
195
- await extract(temp_zip, { dir: site_dir });
196
- await fs.unlink(temp_zip);
216
+ const temp_dir = path.join(site_dir, '.primo', `pull-temp-${Date.now()}`);
217
+ let trashed = [];
218
+ try {
219
+ await fs.mkdir(temp_dir, { recursive: true });
220
+ await extract(temp_zip, { dir: temp_dir });
221
+ await fs.unlink(temp_zip);
222
+ trashed = await reconcile_managed_dirs(site_dir, temp_dir);
223
+ await fs.cp(temp_dir, site_dir, { recursive: true });
224
+ }
225
+ finally {
226
+ await fs.rm(temp_dir, { recursive: true, force: true });
227
+ // Also drop the archive: on a successful pull it was already unlinked
228
+ // above, but if extract() threw it's still sitting in site_dir.
229
+ await fs.rm(temp_zip, { force: true });
230
+ }
231
+ if (trashed.length > 0) {
232
+ console.log(chalk.dim(' Removed stale files (moved to .primo/trash):'));
233
+ for (const trashed_path of trashed) {
234
+ console.log(chalk.dim(` ${trashed_path}`));
235
+ }
236
+ }
197
237
  await write_site_config(site_dir, {
198
238
  name: site.name || 'Imported Site',
199
239
  site_id: site.id,
@@ -203,6 +243,92 @@ async function pull_one_site(server, headers, site, site_dir, spinner) {
203
243
  await copy_schemas(site_dir);
204
244
  await add_schema_references(site_dir);
205
245
  }
246
+ // Move local files under MANAGED_DIRS that have no counterpart in the fresh
247
+ // export into .primo/trash/pull-<ts>/, then remove newly-empty directories
248
+ // (best-effort). Returns the trashed paths relative to the site dir.
249
+ async function reconcile_managed_dirs(site_dir, temp_dir) {
250
+ const trashed = [];
251
+ const stamp = Date.now();
252
+ for (const dir of MANAGED_DIRS) {
253
+ const local_root = path.join(site_dir, dir);
254
+ const temp_root = path.join(temp_dir, dir);
255
+ for (const relative of await list_files_recursive(local_root)) {
256
+ // Keep the local file only if the export still has a *file* at the
257
+ // same path. If the counterpart is now a directory (a file→dir
258
+ // transition, e.g. pages/foo.yaml became pages/foo/…), the local
259
+ // file is stale and must be trashed — otherwise the later fs.cp
260
+ // can't lay a directory over the surviving file and the pull fails.
261
+ try {
262
+ const counterpart = await fs.stat(path.join(temp_root, relative));
263
+ if (counterpart.isFile())
264
+ continue;
265
+ }
266
+ catch {
267
+ // Missing from the export — stale.
268
+ }
269
+ const local_path = path.join(local_root, relative);
270
+ // Scope the trash path by the managed dir. Two managed dirs can hold
271
+ // the same relative name (pages/config.yaml, blocks/config.yaml);
272
+ // without the dir segment they'd collide at trash/pull-<ts>/config.yaml
273
+ // and the second move would clobber the first while both are reported.
274
+ const trash_path = path.join(site_dir, '.primo', 'trash', `pull-${stamp}`, dir, relative);
275
+ await fs.mkdir(path.dirname(trash_path), { recursive: true });
276
+ try {
277
+ await fs.rename(local_path, trash_path);
278
+ }
279
+ catch {
280
+ // Cross-device move — fall back to copy + delete.
281
+ await fs.copyFile(local_path, trash_path);
282
+ await fs.unlink(local_path);
283
+ }
284
+ trashed.push(`${dir}/${relative}`);
285
+ }
286
+ await remove_empty_dirs(local_root);
287
+ }
288
+ return trashed;
289
+ }
290
+ async function list_files_recursive(root, prefix = '') {
291
+ let entries;
292
+ try {
293
+ entries = await fs.readdir(root, { withFileTypes: true });
294
+ }
295
+ catch {
296
+ return [];
297
+ }
298
+ const files = [];
299
+ for (const entry of entries) {
300
+ if (entry.name.startsWith('.'))
301
+ continue;
302
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
303
+ if (entry.isDirectory()) {
304
+ files.push(...await list_files_recursive(path.join(root, entry.name), relative));
305
+ }
306
+ else if (entry.isFile()) {
307
+ files.push(relative);
308
+ }
309
+ }
310
+ return files;
311
+ }
312
+ // Remove empty directories bottom-up. rmdir on a non-empty dir fails, which
313
+ // we ignore — only newly-emptied dirs go away.
314
+ async function remove_empty_dirs(root) {
315
+ let entries;
316
+ try {
317
+ entries = await fs.readdir(root, { withFileTypes: true });
318
+ }
319
+ catch {
320
+ return;
321
+ }
322
+ for (const entry of entries) {
323
+ if (entry.isDirectory()) {
324
+ await remove_empty_dirs(path.join(root, entry.name));
325
+ }
326
+ }
327
+ try {
328
+ await fs.rmdir(root);
329
+ }
330
+ catch { }
331
+ }
206
332
  async function pull_library_into(server, headers, root_dir, spinner) {
207
333
  spinner.start('Pulling library...');
208
334
  const response = await fetch(`${server}/api/primo/export-library`, { headers });
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import archiver from 'archiver';
6
6
  import { get_auth_token } from '../utils/auth.js';
7
+ import { normalize_server_url } from '../utils/server-config.js';
7
8
  function is_local_server(server) {
8
9
  try {
9
10
  const url = new URL(server);
@@ -28,7 +29,7 @@ export async function push_library(options) {
28
29
  spinner.fail(`Library directory not found at ${chalk.cyan(library_dir)}.`);
29
30
  process.exit(1);
30
31
  }
31
- const server = options.server?.replace(/\/+$/, '');
32
+ const server = options.server ? normalize_server_url(options.server) : undefined;
32
33
  if (!server) {
33
34
  spinner.fail('Server URL required. Pass it as the first argument or use --server.');
34
35
  process.exit(1);
@@ -5,7 +5,7 @@ import ora from 'ora';
5
5
  import archiver from 'archiver';
6
6
  import { get_auth_token } from '../utils/auth.js';
7
7
  import { read_site_config, get_site_config_path, SITE_CONFIG_FILE } from '../utils/site-config.js';
8
- import { get_server_config_path, read_server_config } from '../utils/server-config.js';
8
+ import { get_server_config_path, read_server_config, normalize_server_url } from '../utils/server-config.js';
9
9
  async function path_exists(p) {
10
10
  try {
11
11
  await fs.access(p);
@@ -133,7 +133,8 @@ async function print_push_dry_run(root_dir, has_site_yaml, has_server_yaml, opti
133
133
  }
134
134
  library_present = await path_exists(path.join(root_dir, 'library'));
135
135
  }
136
- const server = (options.server || inferred_server)?.replace(/\/+$/, '');
136
+ const server_raw = options.server || inferred_server;
137
+ const server = server_raw ? normalize_server_url(server_raw) : undefined;
137
138
  console.log(` Target server: ${chalk.cyan(server || '(not set — pass --server or set in site.yaml)')}`);
138
139
  console.log('');
139
140
  console.log(chalk.bold(' Will sync:'));
@@ -243,7 +244,8 @@ async function push_single_site(site_dir, options, spinner) {
243
244
  catch {
244
245
  // No config file, must provide options
245
246
  }
246
- const server = (options.server || config?.server)?.replace(/\/+$/, '');
247
+ const server_raw = options.server || config?.server;
248
+ const server = server_raw ? normalize_server_url(server_raw) : undefined;
247
249
  const site_id = options.site || config?.site_id;
248
250
  if (!server) {
249
251
  throw new Error(`Server URL required. Use --server or add server field to ${SITE_CONFIG_FILE}.`);
@@ -331,14 +333,12 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id, gr
331
333
  form.append('group', config.group);
332
334
  if (group_name)
333
335
  form.append('group_name', group_name);
334
- // Register the site against the deploy URL's host so the first visit to
335
- // that domain finds a matching site instead of dropping into CreateSite.
336
- try {
337
- form.append('host', new URL(server).host);
338
- }
339
- catch {
340
- // Malformed server URL — let the server fall back to its own default.
341
- }
336
+ // Host is intentionally not sent. A pushed site is created unassigned
337
+ // the server seeds `host` with a placeholder (the site's own id) so the
338
+ // site is editable in the dashboard but not publicly served until an
339
+ // operator assigns a real domain. The lone exception is bootstrap of the
340
+ // very first site on a fresh instance, where the server falls back to the
341
+ // deploy URL's host so that instance's front door resolves immediately.
342
342
  form.append('file', new Blob([zip_buffer]), 'site.zip');
343
343
  const headers = {};
344
344
  if (token)
@@ -362,7 +362,7 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id, gr
362
362
  }
363
363
  async function push_library_dir(root_dir, options, spinner) {
364
364
  // Resolve server: --server > any site.yaml's server (they all point at the same server)
365
- let server = options.server?.replace(/\/+$/, '');
365
+ let server = options.server ? normalize_server_url(options.server) : undefined;
366
366
  if (!server) {
367
367
  const sites_root = path.join(root_dir, 'sites');
368
368
  if (await path_exists(sites_root)) {
@@ -9,7 +9,64 @@ import ora from 'ora';
9
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
10
  const PRIMO_HOME = path.join(os.homedir(), '.primo');
11
11
  const BIN_DIR = path.join(PRIMO_HOME, 'bin');
12
- const VERSION = '3.2.3'; // matches primo releases
12
+ const REPO = 'primocms/primo';
13
+ // Resolve the latest primo release tag at runtime rather than pinning a version
14
+ // here — a pinned constant silently goes stale (it sat on 3.2.3 through two
15
+ // releases). Cached for the process so repeated calls in one CLI run don't
16
+ // re-hit the API. Bounded by a 10s timeout so an already-installed binary can
17
+ // still be reused promptly when GitHub is slow or unreachable.
18
+ let latest_version_cache;
19
+ async function get_latest_version() {
20
+ if (latest_version_cache !== undefined)
21
+ return latest_version_cache;
22
+ try {
23
+ const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
24
+ headers: { Accept: 'application/vnd.github+json' },
25
+ // Node 18+ ships AbortSignal.timeout; keeps the version check from
26
+ // hanging CLI startup when GitHub stalls.
27
+ signal: AbortSignal.timeout(10_000)
28
+ });
29
+ // Rate limits come back as 403 or 429. Detect them the way GitHub's docs
30
+ // prescribe, since no single signal covers every case:
31
+ // - primary limit: 403 with x-ratelimit-remaining: 0
32
+ // - secondary limit: 403/429 with a Retry-After header, or a body
33
+ // message mentioning a secondary rate limit (remaining may be > 0)
34
+ // - 429 is always a rate limit
35
+ // Treat all of these as "can't tell", never a definitive version —
36
+ // otherwise a throttled run would mask an outdated binary as current. A
37
+ // plain 403 with quota remaining (e.g. a genuine permission error) is a
38
+ // real "unavailable" verdict, not a throttle.
39
+ if (res.status === 403 || res.status === 429) {
40
+ const remaining = res.headers.get('x-ratelimit-remaining');
41
+ const retry_after = res.headers.get('retry-after');
42
+ let rate_limited = res.status === 429 || remaining === '0' || retry_after !== null;
43
+ if (!rate_limited) {
44
+ // Last resort: peek at the body for the secondary-limit message.
45
+ const body = await res.text().catch(() => '');
46
+ rate_limited = /secondary rate limit|rate limit/i.test(body);
47
+ }
48
+ latest_version_cache = rate_limited
49
+ ? { status: 'throttled', reason: `GitHub rate limit (${res.status})` }
50
+ : { status: 'unavailable' };
51
+ return latest_version_cache;
52
+ }
53
+ if (!res.ok)
54
+ throw new Error(`GitHub API ${res.status}`);
55
+ const data = (await res.json());
56
+ const version = parse_semver(data.tag_name ?? null);
57
+ latest_version_cache = version ? { status: 'resolved', version } : { status: 'unavailable' };
58
+ }
59
+ catch (err) {
60
+ // Timeout / DNS / connection reset — indistinguishable from being
61
+ // offline. Treat as throttled (can't confirm) so we don't force a
62
+ // needless re-download of a working binary.
63
+ latest_version_cache = {
64
+ status: 'throttled',
65
+ reason: err instanceof Error ? err.message : 'network error'
66
+ };
67
+ }
68
+ return latest_version_cache;
69
+ }
13
70
  // Path to locally built binary (for development)
14
71
  // The binary is at primo/primo (inside the primo repo directory)
15
72
  const LOCAL_BINARY = path.resolve(__dirname, '..', '..', '..', 'primo', 'primo');
@@ -46,9 +103,12 @@ function get_platform() {
46
103
  return { os: osName, arch: archName, ext };
47
104
  }
48
105
  function get_download_url(platform) {
49
- const base = 'https://github.com/primocms/primo/releases/download';
106
+ // /releases/latest/download/<asset> 302-redirects to the newest release's
107
+ // asset, so we never name a version here — the binary always tracks the
108
+ // latest published release. fetch() follows the redirect automatically.
109
+ const base = `https://github.com/${REPO}/releases/latest/download`;
50
110
  const filename = `primo_${platform.os}_${platform.arch}${platform.ext}`;
51
- return `${base}/v${VERSION}/${filename}`;
111
+ return `${base}/${filename}`;
52
112
  }
53
113
  // Extract a bare semver (e.g. "3.2.1") from a binary's --version output.
54
114
  // The server prints a build banner first, then "<name> version vX.Y.Z", so we
@@ -113,8 +173,8 @@ export async function ensure_binary() {
113
173
  return LOCAL_BINARY;
114
174
  }
115
175
  catch { }
116
- // A managed binary already on disk is reused only when it matches the pinned
117
- // version. A stale binary (older release, or a pre-rename "palacms" build
176
+ // A managed binary already on disk is reused only when it matches the latest
177
+ // release. A stale binary (older release, or a pre-rename "palacms" build
118
178
  // reporting a different version) is re-downloaded so fixes actually reach
119
179
  // users who already have a binary installed.
120
180
  let updating_from = null;
@@ -127,8 +187,17 @@ export async function ensure_binary() {
127
187
  // Need to download - get the target path
128
188
  const platform = get_platform();
129
189
  const binary_path = path.join(BIN_DIR, `primo${platform.ext}`);
190
+ // Resolve the target version for display only (the download URL follows the
191
+ // /latest redirect regardless). Falls back to "latest" when we couldn't
192
+ // confirm the tag; surface a throttle notice so a rate-limited check isn't
193
+ // silent.
194
+ const lookup = await get_latest_version();
195
+ const target_version = lookup.status === 'resolved' ? lookup.version : 'latest';
196
+ if (lookup.status === 'throttled') {
197
+ console.log(chalk.dim(` (couldn't confirm latest version: ${lookup.reason}; downloading current release)`));
198
+ }
130
199
  const spinner = ora(updating_from
131
- ? `Updating primo ${updating_from} → ${VERSION}...`
200
+ ? `Updating primo ${updating_from} → ${target_version}...`
132
201
  : 'Setting up Primo...').start();
133
202
  try {
134
203
  // Create directories
@@ -148,7 +217,7 @@ export async function ensure_binary() {
148
217
  // Make executable, then atomically replace any existing binary.
149
218
  await fs.chmod(tmp_path, 0o755);
150
219
  await fs.rename(tmp_path, binary_path);
151
- spinner.succeed(updating_from ? `Primo updated to ${VERSION}` : 'Primo setup complete');
220
+ spinner.succeed(updating_from ? `Primo updated to ${target_version}` : 'Primo setup complete');
152
221
  return binary_path;
153
222
  }
154
223
  catch (error) {
@@ -184,5 +253,15 @@ export async function get_binary_version() {
184
253
  // developer-chosen and must not be clobbered by a download.
185
254
  async function is_binary_current() {
186
255
  const installed = await get_binary_version();
187
- return installed === VERSION;
256
+ if (!installed)
257
+ return false;
258
+ const latest = await get_latest_version();
259
+ // resolved → compare versions.
260
+ // throttled → can't confirm (rate-limited / offline); keep the installed
261
+ // binary rather than thrashing a re-download, and it'll refresh
262
+ // on the next unthrottled run.
263
+ // unavailable → no usable release to compare against; keep what's on disk.
264
+ if (latest.status !== 'resolved')
265
+ return true;
266
+ return installed === latest.version;
188
267
  }
@@ -12,6 +12,7 @@ export interface ServerConfig {
12
12
  }
13
13
  export declare const SERVER_CONFIG_FILE = "server.yaml";
14
14
  export declare function get_server_config_path(base_dir: string): string;
15
+ export declare function normalize_server_url(server: string): string;
15
16
  export declare function format_group_name(group_id: string): string;
16
17
  export declare function normalize_server_config(config: ServerConfig): ServerConfig;
17
18
  export declare function resolve_format_options(config: ServerConfig): FormatOptions;
@@ -6,6 +6,17 @@ export const SERVER_CONFIG_FILE = 'server.yaml';
6
6
  export function get_server_config_path(base_dir) {
7
7
  return path.join(base_dir, SERVER_CONFIG_FILE);
8
8
  }
9
+ // Normalize a user-supplied server value: prepend a protocol when missing and
10
+ // strip trailing slashes. Bare localhost/127.0.0.1 stay on http:// (local dev);
11
+ // everything else gets https://.
12
+ export function normalize_server_url(server) {
13
+ let normalized = server.trim();
14
+ if (!/^https?:\/\//.test(normalized)) {
15
+ const is_local = /^(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?(\/|$)/.test(normalized);
16
+ normalized = `${is_local ? 'http' : 'https'}://${normalized}`;
17
+ }
18
+ return normalized.replace(/\/+$/, '');
19
+ }
9
20
  export function format_group_name(group_id) {
10
21
  if (!group_id.trim())
11
22
  return 'Default';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {