primo-cli 0.1.3 → 0.1.5

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.
Files changed (38) hide show
  1. package/README.md +111 -39
  2. package/dist/commands/build.js +488 -272
  3. package/dist/commands/deploy.d.ts +1 -1
  4. package/dist/commands/deploy.js +293 -141
  5. package/dist/commands/dev.d.ts +2 -0
  6. package/dist/commands/dev.js +2007 -150
  7. package/dist/commands/init.d.ts +2 -2
  8. package/dist/commands/init.js +65 -43
  9. package/dist/commands/login.d.ts +1 -2
  10. package/dist/commands/login.js +24 -6
  11. package/dist/commands/new.js +161 -274
  12. package/dist/commands/pull-library.d.ts +7 -0
  13. package/dist/commands/pull-library.js +92 -0
  14. package/dist/commands/pull.d.ts +0 -1
  15. package/dist/commands/pull.js +160 -165
  16. package/dist/commands/push-library.d.ts +7 -0
  17. package/dist/commands/push-library.js +88 -0
  18. package/dist/commands/push.d.ts +2 -0
  19. package/dist/commands/push.js +358 -51
  20. package/dist/commands/validate.d.ts +1 -1
  21. package/dist/commands/validate.js +379 -161
  22. package/dist/index.js +110 -20
  23. package/dist/utils/binary.js +1 -1
  24. package/dist/utils/format.d.ts +12 -0
  25. package/dist/utils/format.js +98 -0
  26. package/dist/utils/head-svelte.d.ts +2 -0
  27. package/dist/utils/head-svelte.js +53 -0
  28. package/dist/utils/server-config.d.ts +19 -0
  29. package/dist/utils/server-config.js +49 -0
  30. package/dist/utils/site-config.d.ts +11 -0
  31. package/dist/utils/site-config.js +14 -0
  32. package/package.json +8 -4
  33. package/dist/commands/export.d.ts +0 -8
  34. package/dist/commands/export.js +0 -163
  35. package/dist/commands/import.d.ts +0 -9
  36. package/dist/commands/import.js +0 -118
  37. package/dist/commands/publish.d.ts +0 -6
  38. package/dist/commands/publish.js +0 -239
@@ -1,23 +1,400 @@
1
1
  import fs from 'fs/promises';
2
- import { watch } from 'fs';
3
2
  import path from 'path';
3
+ import { createHash, randomInt } from 'crypto';
4
4
  import chalk from 'chalk';
5
5
  import ora from 'ora';
6
6
  import { spawn } from 'child_process';
7
7
  import archiver from 'archiver';
8
8
  import extract from 'extract-zip';
9
+ import { dump as dump_yaml, load as load_yaml } from 'js-yaml';
10
+ import chokidar from 'chokidar';
9
11
  import { ensure_binary, ensure_data_dir } from '../utils/binary.js';
12
+ import { read_site_config, SITE_CONFIG_FILE } from '../utils/site-config.js';
13
+ import { read_server_config, format_group_name, SERVER_CONFIG_FILE, resolve_format_options } from '../utils/server-config.js';
14
+ import { format_file_contents, should_format } from '../utils/format.js';
10
15
  import { normalize_site } from './validate.js';
16
+ function local_dev_host(name, port) {
17
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'site';
18
+ return `${slug}.localhost:${port}`;
19
+ }
11
20
  let cms_process = null;
12
21
  let watchers = [];
13
22
  let reimport_timeout = null;
23
+ let library_reimport_timeout = null;
14
24
  let sync_interval = null;
15
25
  let is_syncing = false;
16
26
  let is_importing = false;
17
27
  let is_cleaning_up = false;
28
+ let last_import_time = 0; // Timestamp of last import completion
29
+ let last_local_change_time = 0; // Timestamp of most recent local watcher event
30
+ const importing_site_keys = new Set();
31
+ const pending_local_site_keys = new Set();
32
+ let is_importing_library = false;
33
+ let has_pending_library_local_changes = false;
34
+ let site_sync_baselines = new Map();
35
+ // Tracks the last set of conflict paths logged per site so we don't reprint
36
+ // the same conflict block every pull cycle when palacms's serialization
37
+ // keeps producing the same divergence (e.g. data-key mangling, key reorder).
38
+ const last_logged_conflicts = new Map();
18
39
  // Track files written by sync to prevent watcher from re-pushing them
19
40
  // Map of filepath -> mtime (ms) when we wrote it
20
41
  const synced_files = new Map();
42
+ const synced_deleted_paths = new Map();
43
+ const warned_empty_schema_writebacks = new Set();
44
+ let library_snapshot = new Map();
45
+ const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
46
+ const SITES_DIR = 'sites';
47
+ const LIBRARY_DIR = 'library';
48
+ const MCP_CONFIG_FILE = '.mcp.json';
49
+ const SITE_SYNC_DIRS = ['blocks', 'page-types', 'pages', 'site'];
50
+ const LOCAL_PUSH_DEBOUNCE_MS = 150;
51
+ const LOCAL_ZIP_COMPRESSION_LEVEL = 0;
52
+ // Tolerance for matching a file mtime against the synced_files map to decide
53
+ // whether an fs.watch event was caused by our own sync-write. Wider values
54
+ // trade duplicate push work for safety against slow I/O and flaky watchers
55
+ // (macOS fs.watch fires inconsistently for recursive writes).
56
+ const SYNC_MTIME_TOLERANCE_MS = 3000;
57
+ // When the user writes a file locally, suppress CMS->local pulls for this
58
+ // long to prevent a pull that was in-flight before the watcher fired from
59
+ // stomping the just-written content on arrival.
60
+ const LOCAL_CHANGE_PULL_COOLDOWN_MS = 3000;
61
+ // Prior file content is copied to .primo/trash/ before any CMS->file
62
+ // overwrite so the user can recover work if the sync picked the wrong side.
63
+ // Entries older than this are pruned on dev server startup.
64
+ const TRASH_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
65
+ async function trash_existing_file(prior_content, workspace_dir, site_name, file_relative) {
66
+ const trash_dir = path.join(workspace_dir, '.primo', 'trash');
67
+ await fs.mkdir(trash_dir, { recursive: true });
68
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
69
+ const safe_path = file_relative.replace(/[/\\]/g, '__');
70
+ const trash_path = path.join(trash_dir, `${stamp}_${site_name}_${safe_path}`);
71
+ await fs.writeFile(trash_path, prior_content);
72
+ }
73
+ // Recursively trash every file under a path before it gets deleted, so
74
+ // CMS->file deletes are recoverable the same way overwrites are.
75
+ async function trash_path_recursive(target_path, workspace_dir, site_name, file_relative) {
76
+ let stat;
77
+ try {
78
+ stat = await fs.stat(target_path);
79
+ }
80
+ catch {
81
+ return;
82
+ }
83
+ if (stat.isFile()) {
84
+ const content = await fs.readFile(target_path, 'utf-8').catch(() => null);
85
+ if (content !== null) {
86
+ await trash_existing_file(content, workspace_dir, site_name, file_relative);
87
+ }
88
+ return;
89
+ }
90
+ if (!stat.isDirectory())
91
+ return;
92
+ const entries = await fs.readdir(target_path, { withFileTypes: true }).catch(() => []);
93
+ for (const entry of entries) {
94
+ const child_path = path.join(target_path, entry.name);
95
+ const child_relative = `${file_relative}/${entry.name}`;
96
+ await trash_path_recursive(child_path, workspace_dir, site_name, child_relative);
97
+ }
98
+ }
99
+ // Compare line counts between prior and incoming content to flag suspicious
100
+ // shrinkage. Returns the negative delta (e.g. -12) when the file lost lines
101
+ // or was emptied; null when it grew, stayed the same, or didn't exist before.
102
+ function compute_shrink_delta(prior, next) {
103
+ if (!prior)
104
+ return null;
105
+ const prior_lines = prior.split('\n').length;
106
+ const next_lines = next.split('\n').length;
107
+ const delta = next_lines - prior_lines;
108
+ return delta < 0 ? delta : null;
109
+ }
110
+ // Write the most recent push outcome to a file the MCP build_preview tool
111
+ // reads, so the agent learns when its file changes failed to land in the CMS.
112
+ // Without this, build_preview compiles whatever stale DB state existed before
113
+ // the failed push and reports ok:true, leaving the agent to chase phantom
114
+ // rendering bugs instead of fixing the source error.
115
+ async function write_sync_status(site_dir, status) {
116
+ const status_dir = path.join(site_dir, '.primo');
117
+ try {
118
+ await fs.mkdir(status_dir, { recursive: true });
119
+ await fs.writeFile(path.join(status_dir, 'sync_status.json'), JSON.stringify(status, null, 2));
120
+ }
121
+ catch {
122
+ // Status reporting must not break the push.
123
+ }
124
+ }
125
+ async function prune_old_trash(workspace_dir) {
126
+ const trash_dir = path.join(workspace_dir, '.primo', 'trash');
127
+ try {
128
+ const entries = await fs.readdir(trash_dir);
129
+ const cutoff = Date.now() - TRASH_RETENTION_MS;
130
+ await Promise.all(entries.map(async (name) => {
131
+ const full = path.join(trash_dir, name);
132
+ const stat = await fs.stat(full).catch(() => null);
133
+ if (stat && stat.mtimeMs < cutoff) {
134
+ await fs.unlink(full).catch(() => { });
135
+ }
136
+ }));
137
+ }
138
+ catch {
139
+ // trash dir doesn't exist yet — nothing to prune
140
+ }
141
+ }
142
+ function get_site_sync_key(site_dir, config) {
143
+ return config.site_id || site_dir;
144
+ }
145
+ function resolve_sync_policy(options) {
146
+ // Default mirrors the CLI's --author default: files-authoritative.
147
+ // This branch matters for callers that invoke dev_server programmatically
148
+ // (e.g. `primo new` after scaffolding) and bypass commander's default.
149
+ const raw = options.author ?? 'files';
150
+ if (raw !== 'files' && raw !== 'cms' && raw !== 'both') {
151
+ throw new Error(`Invalid --author value "${raw}". Use "files", "cms", or "both".`);
152
+ }
153
+ return { mode: raw };
154
+ }
155
+ function is_file_to_cms_active(sync_policy) {
156
+ return sync_policy.mode !== 'cms';
157
+ }
158
+ function is_cms_to_file_active(sync_policy) {
159
+ return sync_policy.mode !== 'files';
160
+ }
161
+ function describe_sync_state(sync_policy, sites) {
162
+ const file_state = is_file_to_cms_active(sync_policy)
163
+ ? 'file→CMS active'
164
+ : 'file→CMS paused (--author cms)';
165
+ let cms_state;
166
+ if (!is_cms_to_file_active(sync_policy)) {
167
+ cms_state = 'CMS→file paused (--author files)';
168
+ }
169
+ else {
170
+ const pending_sites = sites
171
+ .filter(site => pending_local_site_keys.has(get_site_sync_key(site.dir, site.config)))
172
+ .map(site => site.config.name);
173
+ if (pending_sites.length > 0) {
174
+ const shown = pending_sites.slice(0, 3).join(', ');
175
+ const suffix = pending_sites.length > 3 ? `, +${pending_sites.length - 3} more` : '';
176
+ cms_state = `CMS→file paused (pending local imports/warnings: ${shown}${suffix})`;
177
+ }
178
+ else if (sync_policy.mode === 'both') {
179
+ cms_state = 'CMS→file active (auto-pauses during local imports)';
180
+ }
181
+ else {
182
+ cms_state = 'CMS→file active';
183
+ }
184
+ }
185
+ return `${file_state}, ${cms_state}`;
186
+ }
187
+ function print_sync_status(sync_policy, sites) {
188
+ console.log(chalk.dim(` watching: ${describe_sync_state(sync_policy, sites)}`));
189
+ }
190
+ function update_site_sync_state_after_import(site, timings, sync_policy) {
191
+ const site_key = get_site_sync_key(site.dir, site.config);
192
+ if (timings.warning_count > 0) {
193
+ pending_local_site_keys.add(site_key);
194
+ if (is_cms_to_file_active(sync_policy)) {
195
+ console.log(chalk.yellow(` ${site.config.name}: CMS-to-file sync paused until import warnings are resolved.`));
196
+ }
197
+ return false;
198
+ }
199
+ const was_pending = pending_local_site_keys.delete(site_key);
200
+ if (was_pending && is_cms_to_file_active(sync_policy)) {
201
+ console.log(chalk.dim(` ${site.config.name}: CMS-to-file sync resumed.`));
202
+ }
203
+ return true;
204
+ }
205
+ // The baseline must reflect what the CMS actually has after a write, not what
206
+ // we wrote to disk. The import endpoint mutates records as a side effect
207
+ // (bumps `updated`, delete+inserts page_sections and *_entries with new IDs,
208
+ // normalizes field shapes), so a baseline snapshotted from local files
209
+ // disagrees with the CMS export on the very next pull and find_conflict_paths
210
+ // reports a phantom conflict — losing the user's edits to .primo/trash/.
211
+ // Fetching the post-import export and snapshotting that keeps the baseline
212
+ // aligned with the remote. Falls back to the local snapshot if the fetch
213
+ // fails so we don't lose conflict detection on transient network errors.
214
+ async function update_site_sync_baseline(site, api_url, server_config, workspace_dir) {
215
+ const site_key = get_site_sync_key(site.dir, site.config);
216
+ if (api_url && server_config && workspace_dir) {
217
+ try {
218
+ const cms_snapshot = await fetch_cms_site_snapshot(site.dir, api_url, site.config, server_config, workspace_dir, 'baseline-temp');
219
+ if (cms_snapshot) {
220
+ site_sync_baselines.set(site_key, cms_snapshot);
221
+ return;
222
+ }
223
+ }
224
+ catch {
225
+ // Fall through to local snapshot.
226
+ }
227
+ }
228
+ site_sync_baselines.set(site_key, await collect_site_snapshot(site.dir));
229
+ }
230
+ function get_site_sync_baseline(site) {
231
+ return site_sync_baselines.get(get_site_sync_key(site.dir, site.config));
232
+ }
233
+ function hash_snapshot_content(contents) {
234
+ return createHash('sha256').update(contents.trim()).digest('hex');
235
+ }
236
+ function snapshot_value(snapshot, file_path) {
237
+ return snapshot.has(file_path) ? snapshot.get(file_path) : null;
238
+ }
239
+ // Reads a file but treats ENOENT as a soft miss — palacms' export step can
240
+ // reshape the on-disk layout (e.g. promoting pages/foo.yaml to
241
+ // pages/foo/index.yaml when a child route is added) between when a directory
242
+ // listing is captured and when each file is read. The vanished file isn't an
243
+ // error; it's just out of scope for this snapshot.
244
+ async function read_file_or_vanish(full_path, label) {
245
+ try {
246
+ return await fs.readFile(full_path, 'utf-8');
247
+ }
248
+ catch (error) {
249
+ if (error.code === 'ENOENT') {
250
+ console.log(chalk.dim(` skipped (vanished): ${label}`));
251
+ return null;
252
+ }
253
+ throw error;
254
+ }
255
+ }
256
+ // A path is "in conflict" when local has content that differs from CMS AND
257
+ // the local content represents a real user change — not just CMS-side
258
+ // serialization noise (palacms re-emits YAML with normalized key order,
259
+ // ISO-coerced dates, etc., so the CMS export legitimately differs from a
260
+ // freshly-scaffolded file forever, and we don't want to scream about that
261
+ // every pull cycle).
262
+ //
263
+ // "Real user change" means one of:
264
+ // - No baseline entry exists for this path (brand-new local file the
265
+ // CMS hasn't seen yet — protects the post-scaffold race)
266
+ // - Local content differs from baseline (user has edited the file since
267
+ // the last successful sync)
268
+ //
269
+ // If the baseline matches local but CMS differs, that's pure CMS-side
270
+ // drift — pure pull, no conflict, the CMS value is allowed to overwrite.
271
+ function find_conflict_paths(base, local, cms) {
272
+ const conflicts = [];
273
+ for (const [file_path, local_value] of local) {
274
+ if (local_value === undefined || local_value === null)
275
+ continue;
276
+ const cms_value = snapshot_value(cms, file_path);
277
+ const base_value = base ? snapshot_value(base, file_path) : null;
278
+ // Same content on both sides → not a conflict.
279
+ if (cms_value !== null && cms_value === local_value)
280
+ continue;
281
+ // CMS-side delete: still a conflict if local has content the user
282
+ // authored (covers the case of a brand-new local file the CMS
283
+ // hasn't been told about yet).
284
+ const local_is_new = base_value === null;
285
+ const local_is_edited = base_value !== null && local_value !== base_value;
286
+ if (!local_is_new && !local_is_edited)
287
+ continue;
288
+ conflicts.push(file_path);
289
+ }
290
+ return conflicts.sort();
291
+ }
292
+ function log_sync_conflict(site_name, winner, reason, paths) {
293
+ if (paths.length === 0)
294
+ return;
295
+ const winner_text = winner === 'unresolved'
296
+ ? chalk.red('NO SIDE WON — files diverged')
297
+ : winner === 'files'
298
+ ? chalk.green('FILES WON')
299
+ : chalk.blue('CMS WON');
300
+ // Blank lines + bold header so the conflict is impossible to miss in
301
+ // the surrounding push/pull spam. Beta users need to see this clearly.
302
+ console.log('');
303
+ console.log(chalk.bold.yellow(` ⚠ SYNC CONFLICT ${site_name} → ${winner_text}`));
304
+ console.log(chalk.dim(` reason: ${reason}`));
305
+ for (const p of paths.slice(0, 10)) {
306
+ console.log(chalk.yellow(` • ${p}`));
307
+ }
308
+ if (paths.length > 10) {
309
+ console.log(chalk.dim(` • +${paths.length - 10} more`));
310
+ }
311
+ if (winner === 'CMS') {
312
+ console.log(chalk.dim(` prior file content saved to .primo/trash/`));
313
+ }
314
+ console.log('');
315
+ }
316
+ async function collect_site_snapshot(root_dir, options = {}) {
317
+ const snapshot = new Map();
318
+ for (const dir of SITE_SYNC_DIRS) {
319
+ await collect_directory_snapshot(path.join(root_dir, dir), dir, snapshot, options);
320
+ }
321
+ return snapshot;
322
+ }
323
+ async function collect_directory_snapshot(current_dir, relative_dir, snapshot, options) {
324
+ let entries;
325
+ try {
326
+ entries = await fs.readdir(current_dir, { withFileTypes: true });
327
+ }
328
+ catch {
329
+ return;
330
+ }
331
+ for (const entry of entries) {
332
+ if (entry.name.startsWith('.'))
333
+ continue;
334
+ const full_path = path.join(current_dir, entry.name);
335
+ const file_relative = relative_dir ? `${relative_dir}/${entry.name}` : entry.name;
336
+ if (entry.isDirectory()) {
337
+ await collect_directory_snapshot(full_path, file_relative, snapshot, options);
338
+ continue;
339
+ }
340
+ if (!entry.isFile())
341
+ continue;
342
+ const initial = await read_file_or_vanish(full_path, file_relative);
343
+ if (initial === null)
344
+ continue;
345
+ let contents = initial;
346
+ const dest_path = options.dest_root ? path.join(options.dest_root, file_relative) : full_path;
347
+ if (options.format_options && options.workspace_dir && should_format(dest_path)) {
348
+ contents = await format_file_contents(dest_path, contents, options.workspace_dir, options.format_options);
349
+ }
350
+ snapshot.set(file_relative, hash_snapshot_content(contents));
351
+ }
352
+ }
353
+ async function fetch_cms_site_snapshot(site_dir, api_url, config, server_config, workspace_dir, temp_name) {
354
+ const response = await fetch_with_timeout(`${api_url}/api/palacms/export/${config.site_id}`, {}, 15000);
355
+ if (!response.ok)
356
+ return null;
357
+ const temp_dir = path.join(site_dir, '.primo', temp_name);
358
+ const temp_zip = path.join(temp_dir, 'export.zip');
359
+ await fs.rm(temp_dir, { recursive: true, force: true });
360
+ await fs.mkdir(temp_dir, { recursive: true });
361
+ try {
362
+ const zip_data = await response.arrayBuffer();
363
+ await fs.writeFile(temp_zip, Buffer.from(zip_data));
364
+ await extract(temp_zip, { dir: temp_dir });
365
+ await fs.unlink(temp_zip);
366
+ return await collect_site_snapshot(temp_dir, {
367
+ workspace_dir,
368
+ format_options: resolve_format_options(server_config),
369
+ dest_root: site_dir
370
+ });
371
+ }
372
+ finally {
373
+ await fs.rm(temp_dir, { recursive: true, force: true });
374
+ }
375
+ }
376
+ async function detect_site_file_push_conflicts(site, api_url, server_config, workspace_dir) {
377
+ const baseline = get_site_sync_baseline(site);
378
+ if (!baseline)
379
+ return [];
380
+ const [local_snapshot, cms_snapshot] = await Promise.all([
381
+ collect_site_snapshot(site.dir),
382
+ fetch_cms_site_snapshot(site.dir, api_url, site.config, server_config, workspace_dir, 'conflict-temp')
383
+ ]);
384
+ if (!cms_snapshot)
385
+ return [];
386
+ return find_conflict_paths(baseline, local_snapshot, cms_snapshot);
387
+ }
388
+ async function with_site_import_lock(site_dir, config, fn) {
389
+ const site_key = get_site_sync_key(site_dir, config);
390
+ importing_site_keys.add(site_key);
391
+ try {
392
+ return await fn();
393
+ }
394
+ finally {
395
+ importing_site_keys.delete(site_key);
396
+ }
397
+ }
21
398
  // Check if a port is in use
22
399
  async function is_port_in_use(port) {
23
400
  try {
@@ -31,6 +408,30 @@ async function is_port_in_use(port) {
31
408
  return false;
32
409
  }
33
410
  }
411
+ // Kill processes on a specific port
412
+ async function kill_port(port) {
413
+ return new Promise((resolve) => {
414
+ const lsof = spawn('lsof', ['-ti', `:${port}`]);
415
+ let pids = '';
416
+ lsof.stdout.on('data', (data) => { pids += data.toString(); });
417
+ lsof.on('close', () => {
418
+ const pid_list = pids.trim().split('\n').filter(Boolean);
419
+ if (pid_list.length === 0) {
420
+ resolve(false);
421
+ return;
422
+ }
423
+ for (const pid of pid_list) {
424
+ try {
425
+ process.kill(parseInt(pid, 10), 'SIGKILL');
426
+ }
427
+ catch {
428
+ // Process may have already exited
429
+ }
430
+ }
431
+ resolve(true);
432
+ });
433
+ });
434
+ }
34
435
  // Fetch with timeout helper
35
436
  async function fetch_with_timeout(url, options = {}, timeout_ms = 10000) {
36
437
  const controller = new AbortController();
@@ -61,32 +462,44 @@ async function kill_process(proc) {
61
462
  }
62
463
  }
63
464
  export async function dev_server(options) {
64
- const spinner = ora('Starting Pala...').start();
465
+ const spinner = ora('Starting Primo...').start();
65
466
  try {
467
+ const sync_policy = resolve_sync_policy(options);
468
+ site_sync_baselines = new Map();
66
469
  const base_dir = path.resolve(options.dir);
67
- // Check for server.json (multi-site mode) or primo.json (single-site mode)
68
- const server_config_path = path.join(base_dir, 'server.json');
69
- const site_config_path = path.join(base_dir, 'primo.json');
470
+ await prune_old_trash(base_dir);
471
+ const mcp_registration_path = await register_primo_mcp_server(base_dir);
472
+ // Check for server config (multi-site mode) or site config (single-site mode)
473
+ const server_config_path = path.join(base_dir, SERVER_CONFIG_FILE);
70
474
  let server_config = {};
71
475
  let sites = [];
72
476
  let is_server_mode = false;
73
477
  try {
74
- const server_data = await fs.readFile(server_config_path, 'utf-8');
75
- server_config = JSON.parse(server_data);
478
+ server_config = await read_server_config(base_dir);
76
479
  is_server_mode = true;
77
480
  }
78
481
  catch {
79
- // No server.json, check for primo.json
80
- }
81
- let port = server_config.port || parseInt(options.port, 10);
82
- // Find an available port
83
- const max_port_attempts = 10;
84
- for (let i = 0; i < max_port_attempts; i++) {
85
- if (!await is_port_in_use(port))
86
- break;
87
- port++;
88
- if (i === max_port_attempts - 1) {
89
- spinner.fail(`Ports ${port - max_port_attempts + 1}-${port} are all in use`);
482
+ // No server config, check for site config
483
+ }
484
+ const port = server_config.port || parseInt(options.port, 10);
485
+ // Check if ports are in use
486
+ const main_in_use = await is_port_in_use(port);
487
+ const reload_in_use = await is_port_in_use(port + 1);
488
+ if (main_in_use || reload_in_use) {
489
+ if (options.force) {
490
+ spinner.text = 'Killing existing processes...';
491
+ if (main_in_use)
492
+ await kill_port(port);
493
+ if (reload_in_use)
494
+ await kill_port(port + 1);
495
+ // Give processes time to release ports
496
+ await new Promise(resolve => setTimeout(resolve, 500));
497
+ }
498
+ else {
499
+ const ports_msg = main_in_use && reload_in_use
500
+ ? `Ports ${port} and ${port + 1} are`
501
+ : `Port ${main_in_use ? port : port + 1} is`;
502
+ spinner.fail(`${ports_msg} already in use. Use --force to kill existing processes.`);
90
503
  process.exit(1);
91
504
  }
92
505
  }
@@ -99,12 +512,11 @@ export async function dev_server(options) {
99
512
  else {
100
513
  // Single site mode
101
514
  try {
102
- const config_data = await fs.readFile(site_config_path, 'utf-8');
103
- const config = JSON.parse(config_data);
515
+ const config = await read_site_config(base_dir);
104
516
  sites = [{ dir: base_dir, config }];
105
517
  }
106
518
  catch {
107
- spinner.fail('No server.json or primo.json found. Run `primo new` first.');
519
+ spinner.fail(`No ${SERVER_CONFIG_FILE} or ${SITE_CONFIG_FILE} found. Run \`primo new\` first.`);
108
520
  process.exit(1);
109
521
  }
110
522
  }
@@ -114,10 +526,14 @@ export async function dev_server(options) {
114
526
  // Create data directory in project folder
115
527
  const data_dir = await ensure_data_dir(base_dir);
116
528
  spinner.text = 'Starting CMS...';
117
- // Start the CMS binary with dev mode enabled
529
+ // Start the CMS binary with dev mode enabled. PRIMO_AUTHOR_MODE
530
+ // tells palacms which sync mode the CLI is running in so the CMS
531
+ // UI can gate its editable surfaces accordingly (read-only when
532
+ // the CLI is in --author files, since CMS edits would be discarded
533
+ // before they ever round-trip to disk).
118
534
  cms_process = spawn(binary_path, ['serve', '--http', `127.0.0.1:${port}`, '--dir', data_dir], {
119
535
  stdio: ['pipe', 'pipe', 'pipe'],
120
- env: { ...process.env, PALA_DEV_MODE: '1' }
536
+ env: { ...process.env, PALA_DEV_MODE: '1', PRIMO_AUTHOR_MODE: sync_policy.mode }
121
537
  });
122
538
  // Capture stderr for errors
123
539
  let stderr_output = '';
@@ -133,26 +549,53 @@ export async function dev_server(options) {
133
549
  }
134
550
  process.exit(1);
135
551
  }
552
+ const api_url = `http://127.0.0.1:${port}`;
553
+ if (is_server_mode) {
554
+ spinner.text = sync_policy.mode === 'cms' ? 'Pulling shared library...' : 'Loading shared library...';
555
+ is_importing_library = true;
556
+ try {
557
+ if (sync_policy.mode === 'cms') {
558
+ await sync_library_from_cms(base_dir, api_url);
559
+ }
560
+ else {
561
+ await import_library_files(base_dir, api_url);
562
+ }
563
+ }
564
+ finally {
565
+ is_importing_library = false;
566
+ }
567
+ }
136
568
  // Normalize and load all sites
137
569
  spinner.text = `Loading ${sites.length} site${sites.length > 1 ? 's' : ''}...`;
138
- const api_url = `http://127.0.0.1:${port}`;
139
570
  for (const site of sites) {
571
+ const use_bootstrap = !await site_exists(api_url, site.config.site_id);
572
+ if (sync_policy.mode === 'cms' && !use_bootstrap) {
573
+ await sync_from_cms(site.dir, api_url, site.config, server_config, base_dir, sync_policy);
574
+ continue;
575
+ }
140
576
  await normalize_site(site.dir);
141
- await import_site_files(site.dir, api_url, site.config, port);
577
+ 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));
578
+ if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
579
+ await update_site_sync_baseline(site, api_url, server_config, base_dir);
580
+ }
142
581
  }
143
582
  // Verify all sites are accessible before proceeding
144
583
  spinner.text = 'Verifying sites...';
145
584
  for (const site of sites) {
146
585
  await verify_site_ready(api_url, site.config.site_id);
147
586
  }
148
- spinner.succeed('Pala running');
587
+ spinner.succeed('Primo running');
149
588
  console.log('');
589
+ if (mcp_registration_path) {
590
+ console.log(` ${chalk.dim(`MCP server registered at ${mcp_registration_path} - agents in this directory can now use the Primo MCP server.`)}`);
591
+ console.log('');
592
+ }
150
593
  if (is_server_mode) {
151
594
  console.log(` ${chalk.cyan('Dashboard:')} http://127.0.0.1:${port}/admin/dashboard`);
152
595
  console.log('');
153
596
  }
154
597
  for (const site of sites) {
155
- const host = site.config.host || `${site.config.name.toLowerCase().replace(/\s+/g, '-')}.localhost:${port}`;
598
+ const host = local_dev_host(site.config.name, port);
156
599
  console.log(` ${chalk.cyan(site.config.name)}`);
157
600
  console.log(` ${chalk.dim('Edit:')} http://${host}/admin/site`);
158
601
  console.log(` ${chalk.dim('Preview:')} http://${host}/`);
@@ -163,52 +606,271 @@ export async function dev_server(options) {
163
606
  // Start watching for file changes
164
607
  const dirs_to_watch = ['blocks', 'page-types', 'pages', 'site'];
165
608
  const known_sites = new Set(sites.map(s => s.dir));
609
+ if (is_server_mode) {
610
+ const library_path = path.join(base_dir, LIBRARY_DIR);
611
+ // Prime the snapshot from the current disk state so the first
612
+ // post-startup push doesn't consider every existing folder as
613
+ // a potential delete.
614
+ library_snapshot = await scan_library_folders(library_path);
615
+ try {
616
+ const watcher = chokidar.watch(library_path, {
617
+ ignored: (p) => path.basename(p).startsWith('.'),
618
+ ignoreInitial: true,
619
+ awaitWriteFinish: {
620
+ stabilityThreshold: 60,
621
+ pollInterval: 30
622
+ }
623
+ });
624
+ const log_library_push_paused = (full_path) => {
625
+ const filename = path.relative(library_path, full_path);
626
+ console.log(chalk.dim(` library: ${filename} ignored (file→CMS paused by --author cms)`));
627
+ };
628
+ const push_or_ignore_library_change = (full_path) => {
629
+ if (!is_file_to_cms_active(sync_policy)) {
630
+ log_library_push_paused(full_path);
631
+ return;
632
+ }
633
+ // Mark pending synchronously so the sync interval cannot
634
+ // sneak a pull through between the event and push.
635
+ const was_pending = has_pending_library_local_changes;
636
+ has_pending_library_local_changes = true;
637
+ last_local_change_time = Date.now();
638
+ if (!was_pending && sync_policy.mode === 'both') {
639
+ console.log(chalk.dim(' library: CMS-to-file sync paused while local import is pending.'));
640
+ }
641
+ schedule_library_push();
642
+ };
643
+ const on_event = (full_path) => {
644
+ if (should_skip_synced_delete(full_path))
645
+ return;
646
+ const synced_mtime = synced_files.get(full_path);
647
+ if (synced_mtime) {
648
+ // Our own sync-write — skip.
649
+ fs.stat(full_path)
650
+ .then(stat => {
651
+ if (Math.abs(stat.mtimeMs - synced_mtime) < SYNC_MTIME_TOLERANCE_MS) {
652
+ synced_files.delete(full_path);
653
+ return;
654
+ }
655
+ synced_files.delete(full_path);
656
+ push_or_ignore_library_change(full_path);
657
+ })
658
+ .catch(() => {
659
+ synced_files.delete(full_path);
660
+ push_or_ignore_library_change(full_path);
661
+ });
662
+ return;
663
+ }
664
+ push_or_ignore_library_change(full_path);
665
+ };
666
+ const schedule_library_push = () => {
667
+ if (library_reimport_timeout)
668
+ clearTimeout(library_reimport_timeout);
669
+ library_reimport_timeout = setTimeout(async () => {
670
+ if (is_importing) {
671
+ // Re-arm; another push is in-flight.
672
+ schedule_library_push();
673
+ return;
674
+ }
675
+ try {
676
+ is_importing = true;
677
+ is_importing_library = true;
678
+ // Diff current disk state against the last snapshot to
679
+ // compute the deletes manifest. Only paths present in
680
+ // the snapshot but absent on disk right now are real
681
+ // user deletions. Race-free: we read disk AFTER the
682
+ // debounce has settled.
683
+ const current = await scan_library_folders(library_path);
684
+ const delete_group_ids = [];
685
+ const delete_symbol_ids = [];
686
+ const delete_paths = [];
687
+ for (const [snap_path, entry] of library_snapshot) {
688
+ if (current.has(snap_path))
689
+ continue;
690
+ delete_paths.push(snap_path);
691
+ if (entry.id) {
692
+ if (entry.kind === 'group')
693
+ delete_group_ids.push(entry.id);
694
+ else
695
+ delete_symbol_ids.push(entry.id);
696
+ }
697
+ }
698
+ delete_paths.sort();
699
+ if (delete_paths.length > 0) {
700
+ console.log(chalk.yellow(` library: deleting ${delete_paths.length} path(s): ${delete_paths.join(', ')}`));
701
+ }
702
+ const import_timings = await import_library_files(base_dir, api_url, delete_group_ids, delete_symbol_ids);
703
+ const reload_started = Date.now();
704
+ await request_browser_reload(api_url);
705
+ const reload_ms = Date.now() - reload_started;
706
+ has_pending_library_local_changes = false;
707
+ if (sync_policy.mode === 'both') {
708
+ console.log(chalk.dim(' library: CMS-to-file sync resumed.'));
709
+ }
710
+ // Update snapshot only on successful push.
711
+ library_snapshot = current;
712
+ console.log(chalk.dim(` library: zip ${import_timings.zip_ms}ms, import ${import_timings.request_ms}ms, reload ${reload_ms}ms`));
713
+ console.log(chalk.green(' ✓ Library pushed'));
714
+ }
715
+ catch (err) {
716
+ console.log(chalk.red(` ✗ Library push failed: ${err}`));
717
+ }
718
+ finally {
719
+ is_importing_library = false;
720
+ is_importing = false;
721
+ last_import_time = Date.now();
722
+ }
723
+ }, LOCAL_PUSH_DEBOUNCE_MS);
724
+ };
725
+ watcher.on('add', on_event);
726
+ watcher.on('change', on_event);
727
+ watcher.on('unlink', on_event);
728
+ watcher.on('addDir', on_event);
729
+ watcher.on('unlinkDir', on_event);
730
+ watchers.push(watcher);
731
+ }
732
+ catch {
733
+ // Library directory might not exist
734
+ }
735
+ }
166
736
  const setup_site_watchers = (site) => {
737
+ let pending_reload = false;
167
738
  const schedule_reimport = () => {
168
739
  if (reimport_timeout) {
169
740
  clearTimeout(reimport_timeout);
170
741
  }
171
742
  reimport_timeout = setTimeout(async () => {
743
+ // If already importing, reschedule and wait
744
+ if (is_importing) {
745
+ schedule_reimport();
746
+ return;
747
+ }
172
748
  try {
173
749
  is_importing = true;
750
+ const normalize_started = Date.now();
174
751
  await normalize_site(site.dir);
175
- await import_site_files(site.dir, api_url, site.config, port);
752
+ const normalize_ms = Date.now() - normalize_started;
753
+ let conflict_paths = [];
754
+ if (sync_policy.mode === 'both') {
755
+ try {
756
+ conflict_paths = await detect_site_file_push_conflicts(site, api_url, server_config, base_dir);
757
+ }
758
+ catch {
759
+ // Conflict detection must not block the local push.
760
+ }
761
+ }
762
+ 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));
763
+ let reload_ms = 0;
764
+ if (pending_reload) {
765
+ try {
766
+ const reload_started = Date.now();
767
+ await request_browser_reload(api_url);
768
+ reload_ms = Date.now() - reload_started;
769
+ }
770
+ catch {
771
+ console.log(chalk.yellow(` Warning: Failed to trigger browser reload for ${site.config.name}`));
772
+ }
773
+ pending_reload = false;
774
+ }
775
+ if (conflict_paths.length > 0) {
776
+ if (import_timings.warning_count > 0) {
777
+ log_sync_conflict(site.config.name, 'unresolved', 'file push completed with import warnings; CMS-to-file sync paused until resolved', conflict_paths);
778
+ }
779
+ else {
780
+ log_sync_conflict(site.config.name, 'files', 'both sides changed since last sync; local push was applied (CMS values from last poll were discarded)', conflict_paths);
781
+ }
782
+ }
783
+ if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
784
+ await update_site_sync_baseline(site, api_url, server_config, base_dir);
785
+ }
786
+ console.log(chalk.dim(` ${site.config.name}: normalize ${normalize_ms}ms, zip ${import_timings.zip_ms}ms, ${import_timings.mode} ${import_timings.request_ms}ms${reload_ms ? `, reload ${reload_ms}ms` : ''}`));
176
787
  console.log(chalk.green(` ✓ ${site.config.name} pushed`));
788
+ await write_sync_status(site.dir, { ok: true });
177
789
  }
178
790
  catch (err) {
179
- console.log(chalk.red(` ✗ ${site.config.name} push failed: ${err}`));
791
+ const message = err instanceof Error ? err.message : String(err);
792
+ console.log(chalk.red(` ✗ ${site.config.name} push failed: ${message}`));
793
+ await write_sync_status(site.dir, {
794
+ ok: false,
795
+ error: message,
796
+ failed_at: new Date().toISOString()
797
+ });
180
798
  }
181
799
  finally {
182
800
  is_importing = false;
801
+ last_import_time = Date.now(); // Track when import finished
183
802
  }
184
- }, 300);
803
+ }, LOCAL_PUSH_DEBOUNCE_MS);
185
804
  };
186
805
  for (const dir of dirs_to_watch) {
187
806
  const watch_path = path.join(site.dir, dir);
188
807
  try {
189
- const watcher = watch(watch_path, { recursive: true }, async (event, filename) => {
190
- if (!filename || filename.startsWith('.'))
808
+ const watcher = chokidar.watch(watch_path, {
809
+ ignored: (p) => path.basename(p).startsWith('.'),
810
+ ignoreInitial: true,
811
+ awaitWriteFinish: {
812
+ stabilityThreshold: 60,
813
+ pollInterval: 30
814
+ }
815
+ });
816
+ const continue_event = (full_path) => {
817
+ const filename = path.relative(watch_path, full_path);
818
+ console.log(chalk.dim(` ${site.config.name}: ${dir}/${filename}`));
819
+ if (change_requires_reload(dir, filename)) {
820
+ pending_reload = true;
821
+ }
822
+ schedule_reimport();
823
+ };
824
+ const mark_pending_local_change = () => {
825
+ const site_key = get_site_sync_key(site.dir, site.config);
826
+ const was_pending = pending_local_site_keys.has(site_key);
827
+ pending_local_site_keys.add(site_key);
828
+ last_local_change_time = Date.now();
829
+ if (!was_pending && sync_policy.mode === 'both') {
830
+ console.log(chalk.dim(` ${site.config.name}: CMS-to-file sync paused while local import is pending.`));
831
+ }
832
+ };
833
+ const log_file_push_paused = (full_path) => {
834
+ const filename = path.relative(watch_path, full_path);
835
+ console.log(chalk.dim(` ${site.config.name}: ${dir}/${filename} ignored (file→CMS paused by --author cms)`));
836
+ };
837
+ const push_or_ignore_file_change = (full_path) => {
838
+ if (!is_file_to_cms_active(sync_policy)) {
839
+ log_file_push_paused(full_path);
840
+ return;
841
+ }
842
+ // Mark site as pending synchronously so the sync interval
843
+ // cannot pull against a site that's actively being edited.
844
+ mark_pending_local_change();
845
+ continue_event(full_path);
846
+ };
847
+ const on_event = (full_path) => {
848
+ if (should_skip_synced_delete(full_path))
191
849
  return;
192
- // Check if this file was just written by sync
193
- const full_path = path.join(watch_path, filename);
194
850
  const synced_mtime = synced_files.get(full_path);
195
851
  if (synced_mtime) {
196
- try {
197
- const stat = await fs.stat(full_path);
198
- // If mtime matches what we wrote, skip this event
199
- if (Math.abs(stat.mtimeMs - synced_mtime) < 1000) {
852
+ fs.stat(full_path)
853
+ .then(stat => {
854
+ if (Math.abs(stat.mtimeMs - synced_mtime) < SYNC_MTIME_TOLERANCE_MS) {
200
855
  synced_files.delete(full_path);
201
856
  return;
202
857
  }
203
- }
204
- catch {
205
- // File might have been deleted
206
- }
207
- synced_files.delete(full_path);
858
+ synced_files.delete(full_path);
859
+ push_or_ignore_file_change(full_path);
860
+ })
861
+ .catch(() => {
862
+ synced_files.delete(full_path);
863
+ push_or_ignore_file_change(full_path);
864
+ });
865
+ return;
208
866
  }
209
- console.log(chalk.dim(` ${site.config.name}: ${dir}/${filename}`));
210
- schedule_reimport();
211
- });
867
+ push_or_ignore_file_change(full_path);
868
+ };
869
+ watcher.on('add', on_event);
870
+ watcher.on('change', on_event);
871
+ watcher.on('unlink', on_event);
872
+ watcher.on('addDir', on_event);
873
+ watcher.on('unlinkDir', on_event);
212
874
  watchers.push(watcher);
213
875
  }
214
876
  catch {
@@ -235,10 +897,19 @@ export async function dev_server(options) {
235
897
  continue;
236
898
  known_sites.add(site.dir);
237
899
  sites.push(site);
238
- await normalize_site(site.dir);
239
- await import_site_files(site.dir, api_url, site.config, port);
900
+ const use_bootstrap = !await site_exists(api_url, site.config.site_id);
901
+ if (sync_policy.mode === 'cms' && !use_bootstrap) {
902
+ await sync_from_cms(site.dir, api_url, site.config, server_config, base_dir, sync_policy);
903
+ }
904
+ else {
905
+ await normalize_site(site.dir);
906
+ 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));
907
+ if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
908
+ await update_site_sync_baseline(site, api_url, server_config, base_dir);
909
+ }
910
+ }
240
911
  setup_site_watchers(site);
241
- const host = site.config.host || `${path.basename(site.dir).toLowerCase().replace(/\s+/g, '-')}.localhost:${port}`;
912
+ const host = local_dev_host(site.config.name || path.basename(site.dir), port);
242
913
  console.log(chalk.green(` ✓ New site loaded: ${site.config.name}`));
243
914
  console.log(` ${chalk.dim('Edit:')} http://${host}/admin/site`);
244
915
  console.log(` ${chalk.dim('Preview:')} http://${host}/`);
@@ -246,22 +917,56 @@ export async function dev_server(options) {
246
917
  res.writeHead(200);
247
918
  res.end('ok');
248
919
  });
920
+ reload_server.on('error', (err) => {
921
+ if (err.code === 'EADDRINUSE') {
922
+ console.log(chalk.yellow(`\n Warning: Reload server port ${port + 1} in use. Hot reload disabled.`));
923
+ }
924
+ });
249
925
  reload_server.listen(port + 1, '127.0.0.1');
250
926
  }
251
927
  // Start polling for CMS changes (sync back to local files)
252
- sync_interval = setInterval(async () => {
253
- if (is_syncing || is_importing)
254
- return;
255
- for (const site of sites) {
928
+ // Wait 3 seconds after import to avoid overwriting just-pushed changes
929
+ const IMPORT_COOLDOWN_MS = 3000;
930
+ if (is_cms_to_file_active(sync_policy)) {
931
+ sync_interval = setInterval(async () => {
932
+ if (is_syncing || is_importing)
933
+ return;
934
+ if (Date.now() - last_import_time < IMPORT_COOLDOWN_MS)
935
+ return;
936
+ // Skip the pull if the user just made a local change — otherwise a
937
+ // pull that started before the watcher fired could overwrite the
938
+ // fresh local edit on arrival.
939
+ if (Date.now() - last_local_change_time < LOCAL_CHANGE_PULL_COOLDOWN_MS)
940
+ return;
941
+ is_syncing = true;
256
942
  try {
257
- await sync_from_cms(site.dir, api_url, site.config);
943
+ for (const site of sites) {
944
+ try {
945
+ const site_key = get_site_sync_key(site.dir, site.config);
946
+ if (importing_site_keys.has(site_key) || pending_local_site_keys.has(site_key)) {
947
+ continue;
948
+ }
949
+ await sync_from_cms(site.dir, api_url, site.config, server_config, base_dir, sync_policy);
950
+ }
951
+ catch {
952
+ // Silently ignore sync errors
953
+ }
954
+ }
955
+ if (is_server_mode && !is_importing_library && !has_pending_library_local_changes && await has_library_content(path.join(base_dir, LIBRARY_DIR))) {
956
+ try {
957
+ await sync_library_from_cms(base_dir, api_url);
958
+ }
959
+ catch {
960
+ // Silently ignore library sync errors
961
+ }
962
+ }
258
963
  }
259
- catch {
260
- // Silently ignore sync errors
964
+ finally {
965
+ is_syncing = false;
261
966
  }
262
- }
263
- }, 5000);
264
- console.log(chalk.dim(' Watching for changes...'));
967
+ }, 1000);
968
+ }
969
+ print_sync_status(sync_policy, sites);
265
970
  console.log(chalk.dim(' Press Ctrl+C to stop'));
266
971
  // Handle cleanup
267
972
  const cleanup = async () => {
@@ -282,6 +987,10 @@ export async function dev_server(options) {
282
987
  clearTimeout(reimport_timeout);
283
988
  reimport_timeout = null;
284
989
  }
990
+ if (library_reimport_timeout) {
991
+ clearTimeout(library_reimport_timeout);
992
+ library_reimport_timeout = null;
993
+ }
285
994
  if (sync_interval) {
286
995
  clearInterval(sync_interval);
287
996
  sync_interval = null;
@@ -310,16 +1019,55 @@ export async function dev_server(options) {
310
1019
  process.exit(1);
311
1020
  }
312
1021
  }
1022
+ async function register_primo_mcp_server(base_dir) {
1023
+ if (!await path_exists(path.join(base_dir, SERVER_CONFIG_FILE))) {
1024
+ return null;
1025
+ }
1026
+ const mcp_config_path = path.join(base_dir, MCP_CONFIG_FILE);
1027
+ let config = {};
1028
+ try {
1029
+ const raw = await fs.readFile(mcp_config_path, 'utf-8');
1030
+ config = raw.trim() ? JSON.parse(raw) : {};
1031
+ }
1032
+ catch (error) {
1033
+ if (error.code !== 'ENOENT') {
1034
+ return null;
1035
+ }
1036
+ }
1037
+ if (!is_plain_record(config)) {
1038
+ return null;
1039
+ }
1040
+ if (config.mcpServers !== undefined && !is_plain_record(config.mcpServers)) {
1041
+ return null;
1042
+ }
1043
+ const existing_mcp_servers = config.mcpServers;
1044
+ const mcp_servers = { ...(existing_mcp_servers ?? {}) };
1045
+ if (mcp_servers.primo !== undefined) {
1046
+ return null;
1047
+ }
1048
+ const local_mcp = process.env.PRIMO_MCP_LOCAL
1049
+ ?? '/Users/mateo/Desktop/primo/primo-mcp/dist/index.js';
1050
+ mcp_servers.primo = local_mcp
1051
+ ? { command: 'node', args: [local_mcp] }
1052
+ : { command: 'npx', args: ['-y', '@primo/mcp'] };
1053
+ config.mcpServers = mcp_servers;
1054
+ // Claude Code reads project-root .mcp.json. .primo/ is gitignored local
1055
+ // state, so the discoverable root file is the right registration target.
1056
+ await fs.writeFile(mcp_config_path, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
1057
+ return MCP_CONFIG_FILE;
1058
+ }
1059
+ function is_plain_record(value) {
1060
+ return !!value && typeof value === 'object' && !Array.isArray(value);
1061
+ }
313
1062
  async function discover_sites(base_dir) {
314
1063
  const sites = [];
315
- const entries = await fs.readdir(base_dir, { withFileTypes: true });
1064
+ const sites_root = await get_sites_root(base_dir);
1065
+ const entries = await fs.readdir(sites_root, { withFileTypes: true });
316
1066
  for (const entry of entries) {
317
1067
  if (entry.isDirectory() && !entry.name.startsWith('.')) {
318
- const site_dir = path.join(base_dir, entry.name);
319
- const config_path = path.join(site_dir, 'primo.json');
1068
+ const site_dir = path.join(sites_root, entry.name);
320
1069
  try {
321
- const config_data = await fs.readFile(config_path, 'utf-8');
322
- const config = JSON.parse(config_data);
1070
+ const config = await read_site_config(site_dir);
323
1071
  sites.push({ dir: site_dir, config });
324
1072
  }
325
1073
  catch {
@@ -329,6 +1077,46 @@ async function discover_sites(base_dir) {
329
1077
  }
330
1078
  return sites;
331
1079
  }
1080
+ async function get_sites_root(base_dir) {
1081
+ const candidate = path.join(base_dir, SITES_DIR);
1082
+ try {
1083
+ const stat = await fs.stat(candidate);
1084
+ if (stat.isDirectory()) {
1085
+ return candidate;
1086
+ }
1087
+ }
1088
+ catch {
1089
+ // Missing sites/ directory
1090
+ }
1091
+ throw new Error(`Server workspace is missing ${SITES_DIR}/. Run \`primo new\` from the workspace root or create ${SITES_DIR}/ first.`);
1092
+ }
1093
+ function resolve_site_group(config, server_config) {
1094
+ const configured_groups = server_config.site_groups ?? [];
1095
+ const group_ref = config.group?.trim();
1096
+ const ensure_group_id = (group) => ({
1097
+ ...group,
1098
+ id: typeof group.id === 'string' && group.id.trim().length >= 15 ? group.id.trim() : generate_id()
1099
+ });
1100
+ if (group_ref) {
1101
+ const existing_group = configured_groups.find((group) => group.id === group_ref || group.name === group_ref);
1102
+ if (existing_group) {
1103
+ return ensure_group_id(existing_group);
1104
+ }
1105
+ return ensure_group_id({
1106
+ id: group_ref,
1107
+ name: format_group_name(group_ref),
1108
+ index: configured_groups.length
1109
+ });
1110
+ }
1111
+ if (configured_groups[0]) {
1112
+ return ensure_group_id(configured_groups[0]);
1113
+ }
1114
+ return ensure_group_id({
1115
+ id: '',
1116
+ name: 'Default',
1117
+ index: 0
1118
+ });
1119
+ }
332
1120
  async function wait_for_ready(url, timeout_ms) {
333
1121
  const start = Date.now();
334
1122
  const health_url = `${url}/api/health`;
@@ -366,132 +1154,1083 @@ async function verify_site_ready(api_url, site_id) {
366
1154
  }
367
1155
  return false;
368
1156
  }
369
- async function import_site_files(site_dir, api_url, config, port) {
370
- const site_name = config.name || 'My Site';
371
- const site_id = config.site_id;
372
- // Create ZIP of site files
373
- const zip_buffer = await create_site_zip(site_dir);
374
- // Use hostname from config, or generate from folder name
375
- const folder_name = path.basename(site_dir);
376
- const host = config.host || (folder_name.includes('.')
377
- ? `${folder_name}:${port}` // Looks like a domain
378
- : `${folder_name.toLowerCase().replace(/\s+/g, '-')}.localhost:${port}`);
379
- // Retry bootstrap up to 3 times (collections may not be ready immediately)
380
- const max_retries = 3;
381
- for (let attempt = 1; attempt <= max_retries; attempt++) {
382
- const form_data = new FormData();
383
- form_data.append('site_id', site_id);
384
- form_data.append('name', site_name);
385
- form_data.append('host', host);
386
- form_data.append('file', new Blob([zip_buffer]), 'site.zip');
387
- try {
388
- const bootstrap_response = await fetch_with_timeout(`${api_url}/api/palacms/bootstrap`, {
389
- method: 'POST',
390
- body: form_data
391
- }, 30000); // 30s timeout for imports
392
- if (bootstrap_response.ok) {
393
- return;
394
- }
395
- const error_text = await bootstrap_response.text();
396
- // Check if it's a collection not found error (timing issue)
397
- if (error_text.includes('collection') && attempt < max_retries) {
398
- await new Promise(resolve => setTimeout(resolve, 500 * attempt));
399
- continue;
400
- }
401
- console.log(chalk.yellow(` Bootstrap failed (${bootstrap_response.status}): ${error_text}`));
402
- // Bootstrap failed, try regular import
403
- const import_form = new FormData();
404
- import_form.append('file', new Blob([zip_buffer]), 'site.zip');
405
- const import_response = await fetch_with_timeout(`${api_url}/api/palacms/import/${site_id}`, {
406
- method: 'POST',
407
- body: import_form
408
- }, 30000); // 30s timeout for imports
1157
+ async function site_exists(api_url, site_id) {
1158
+ // Only 404 means the site genuinely doesn't exist. Any other non-ok status
1159
+ // (401/403 from auth, 5xx, rate limits) leaves us uncertain — default to
1160
+ // "exists" so we take the additive `import` path instead of the destructive
1161
+ // `bootstrap` path. Bootstrapping a site that already exists discards
1162
+ // remote state when the request later fails, and the next pull then
1163
+ // stomps in-progress local edits with stale DB content.
1164
+ try {
1165
+ const response = await fetch_with_timeout(`${api_url}/api/collections/sites/records/${site_id}`, {}, 5000);
1166
+ if (response.ok)
1167
+ return true;
1168
+ if (response.status === 404)
1169
+ return false;
1170
+ return true;
1171
+ }
1172
+ catch {
1173
+ return true;
1174
+ }
1175
+ }
1176
+ function change_requires_reload(_dir, _filename) {
1177
+ return true;
1178
+ }
1179
+ async function request_browser_reload(api_url) {
1180
+ await fetch_with_timeout(`${api_url}/api/palacms/dev/reload`, {
1181
+ method: 'POST'
1182
+ }, 5000);
1183
+ }
1184
+ function generate_id(length = 15) {
1185
+ let id = '';
1186
+ for (let i = 0; i < length; i++) {
1187
+ id += ID_ALPHABET[randomInt(ID_ALPHABET.length)];
1188
+ }
1189
+ return id;
1190
+ }
1191
+ function get_entity_id(value) {
1192
+ if (!value || typeof value !== 'object')
1193
+ return undefined;
1194
+ const record = value;
1195
+ if (typeof record._id === 'string' && record._id)
1196
+ return record._id;
1197
+ if (typeof record.id === 'string' && record.id)
1198
+ return record.id;
1199
+ return undefined;
1200
+ }
1201
+ function get_fields_array(data) {
1202
+ if (Array.isArray(data)) {
1203
+ return data.filter((item) => !!item && typeof item === 'object');
1204
+ }
1205
+ if (data && typeof data === 'object' && Array.isArray(data.fields)) {
1206
+ return data.fields
1207
+ .filter((item) => !!item && typeof item === 'object');
1208
+ }
1209
+ return [];
1210
+ }
1211
+ function get_sections_array(data) {
1212
+ if (!Array.isArray(data)) {
1213
+ return [];
1214
+ }
1215
+ return data.filter((item) => !!item && typeof item === 'object');
1216
+ }
1217
+ function collect_field_ids(fields, visit) {
1218
+ for (const field of fields) {
1219
+ const field_id = get_entity_id(field);
1220
+ if (field_id) {
1221
+ visit(field_id);
1222
+ }
1223
+ if (Array.isArray(field.subfields)) {
1224
+ const subfields = field.subfields.filter((item) => !!item && typeof item === 'object');
1225
+ collect_field_ids(subfields, visit);
1226
+ }
1227
+ }
1228
+ }
1229
+ function track_duplicate(duplicates, category, id, occurrence) {
1230
+ let by_id = duplicates.get(category);
1231
+ if (!by_id) {
1232
+ by_id = new Map();
1233
+ duplicates.set(category, by_id);
1234
+ }
1235
+ const existing = by_id.get(id) ?? [];
1236
+ existing.push(occurrence);
1237
+ by_id.set(id, existing);
1238
+ }
1239
+ async function mark_written_file(file_path) {
1240
+ synced_files.set(file_path, Date.now());
1241
+ try {
1242
+ const stat = await fs.stat(file_path);
1243
+ synced_files.set(file_path, stat.mtimeMs);
1244
+ }
1245
+ catch {
1246
+ // Ignore files that disappeared
1247
+ }
1248
+ }
1249
+ function mark_deleted_path(file_path) {
1250
+ synced_deleted_paths.set(file_path, Date.now());
1251
+ }
1252
+ function should_skip_synced_delete(file_path) {
1253
+ const deleted_at = synced_deleted_paths.get(file_path);
1254
+ if (!deleted_at) {
1255
+ return false;
1256
+ }
1257
+ if (Date.now() - deleted_at < 10_000) {
1258
+ synced_deleted_paths.delete(file_path);
1259
+ return true;
1260
+ }
1261
+ synced_deleted_paths.delete(file_path);
1262
+ return false;
1263
+ }
1264
+ async function mark_deleted_tree(root_path) {
1265
+ mark_deleted_path(root_path);
1266
+ let entries;
1267
+ try {
1268
+ entries = await fs.readdir(root_path, { withFileTypes: true });
1269
+ }
1270
+ catch {
1271
+ return;
1272
+ }
1273
+ for (const entry of entries) {
1274
+ await mark_deleted_tree(path.join(root_path, entry.name));
1275
+ }
1276
+ }
1277
+ async function remove_tracked_path(target_path) {
1278
+ await mark_deleted_tree(target_path);
1279
+ await fs.rm(target_path, { recursive: true, force: true });
1280
+ }
1281
+ async function path_exists(target_path) {
1282
+ try {
1283
+ await fs.stat(target_path);
1284
+ return true;
1285
+ }
1286
+ catch {
1287
+ return false;
1288
+ }
1289
+ }
1290
+ async function find_page_files(dir, relative_dir = 'pages') {
1291
+ const files = [];
1292
+ let entries;
1293
+ try {
1294
+ entries = await fs.readdir(dir, { withFileTypes: true });
1295
+ }
1296
+ catch {
1297
+ return files;
1298
+ }
1299
+ for (const entry of entries) {
1300
+ if (entry.name.startsWith('.'))
1301
+ continue;
1302
+ const full_path = path.join(dir, entry.name);
1303
+ const relative_path = `${relative_dir}/${entry.name}`;
1304
+ if (entry.isDirectory()) {
1305
+ files.push(...await find_page_files(full_path, relative_path));
1306
+ continue;
1307
+ }
1308
+ if (entry.name.endsWith('.yaml')) {
1309
+ files.push(relative_path);
1310
+ }
1311
+ }
1312
+ return files;
1313
+ }
1314
+ function to_posix_path(file_path) {
1315
+ return file_path.split(path.sep).join('/');
1316
+ }
1317
+ function sanitize_file_name(name) {
1318
+ return name
1319
+ .replaceAll('/', '-')
1320
+ .replaceAll('\\', '-')
1321
+ .replaceAll(':', '-')
1322
+ .replaceAll(' ', '-')
1323
+ .toLowerCase();
1324
+ }
1325
+ function add_block_alias(aliases, alias, block_name) {
1326
+ const trimmed = alias.trim();
1327
+ if (!trimmed)
1328
+ return;
1329
+ aliases.set(trimmed, block_name);
1330
+ aliases.set(trimmed.toLowerCase(), block_name);
1331
+ aliases.set(sanitize_file_name(trimmed), block_name);
1332
+ }
1333
+ function content_keys(value) {
1334
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1335
+ return [];
1336
+ }
1337
+ return Object.keys(value);
1338
+ }
1339
+ function is_empty_fields_yaml(contents) {
1340
+ try {
1341
+ const parsed = load_yaml(contents);
1342
+ if (parsed == null)
1343
+ return true;
1344
+ return Array.isArray(parsed) && parsed.length === 0;
1345
+ }
1346
+ catch {
1347
+ return false;
1348
+ }
1349
+ }
1350
+ function is_empty_content_yaml(contents) {
1351
+ try {
1352
+ const parsed = load_yaml(contents);
1353
+ if (parsed == null)
1354
+ return true;
1355
+ if (typeof parsed !== 'object' || Array.isArray(parsed))
1356
+ return false;
1357
+ return Object.keys(parsed).length === 0;
1358
+ }
1359
+ catch {
1360
+ return false;
1361
+ }
1362
+ }
1363
+ function has_user_authored_fields(contents) {
1364
+ try {
1365
+ return get_fields_array(load_yaml(contents)).length > 0;
1366
+ }
1367
+ catch {
1368
+ return false;
1369
+ }
1370
+ }
1371
+ function has_user_authored_content(contents) {
1372
+ try {
1373
+ const parsed = load_yaml(contents);
1374
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
1375
+ return false;
1376
+ return Object.keys(parsed).length > 0;
1377
+ }
1378
+ catch {
1379
+ return false;
1380
+ }
1381
+ }
1382
+ function block_fields_path_block_name(relative_path) {
1383
+ const parts = relative_path.split('/');
1384
+ if (parts.length !== 3 || parts[0] !== 'blocks' || parts[2] !== 'fields.yaml') {
1385
+ return null;
1386
+ }
1387
+ return parts[1] || null;
1388
+ }
1389
+ function is_site_fields_path(relative_path) {
1390
+ return relative_path === 'site/fields.yaml';
1391
+ }
1392
+ function is_site_content_path(relative_path) {
1393
+ return relative_path === 'site/content.yaml';
1394
+ }
1395
+ async function collect_block_aliases(site_dir) {
1396
+ const aliases = new Map();
1397
+ const blocks_dir = path.join(site_dir, 'blocks');
1398
+ let block_names;
1399
+ try {
1400
+ block_names = await fs.readdir(blocks_dir);
1401
+ }
1402
+ catch {
1403
+ return aliases;
1404
+ }
1405
+ for (const block_name of block_names) {
1406
+ if (block_name.startsWith('.'))
1407
+ continue;
1408
+ add_block_alias(aliases, block_name, block_name);
1409
+ try {
1410
+ const raw = await fs.readFile(path.join(blocks_dir, block_name, 'config.yaml'), 'utf-8');
1411
+ const parsed = load_yaml(raw);
1412
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1413
+ const display_name = parsed.name;
1414
+ if (typeof display_name === 'string') {
1415
+ add_block_alias(aliases, display_name, block_name);
1416
+ }
1417
+ }
1418
+ }
1419
+ catch {
1420
+ // Missing or invalid config.yaml should not make sync destructive.
1421
+ }
1422
+ }
1423
+ return aliases;
1424
+ }
1425
+ async function collect_block_content_references(site_dir) {
1426
+ const refs = new Map();
1427
+ const aliases = await collect_block_aliases(site_dir);
1428
+ const pages_dir = path.join(site_dir, 'pages');
1429
+ for (const relative_page_path of await find_page_files(pages_dir)) {
1430
+ const page_path = path.join(site_dir, relative_page_path);
1431
+ let page;
1432
+ try {
1433
+ page = load_yaml(await fs.readFile(page_path, 'utf-8'));
1434
+ }
1435
+ catch {
1436
+ continue;
1437
+ }
1438
+ if (!page || typeof page !== 'object' || Array.isArray(page))
1439
+ continue;
1440
+ const sections = get_sections_array(page.sections);
1441
+ for (const [index, section] of sections.entries()) {
1442
+ const block_ref = section.block;
1443
+ if (typeof block_ref !== 'string' || !block_ref.trim())
1444
+ continue;
1445
+ const keys = content_keys(section.content);
1446
+ if (keys.length === 0)
1447
+ continue;
1448
+ const block_name = aliases.get(block_ref)
1449
+ ?? aliases.get(block_ref.toLowerCase())
1450
+ ?? aliases.get(sanitize_file_name(block_ref))
1451
+ ?? block_ref;
1452
+ const existing = refs.get(block_name) ?? [];
1453
+ existing.push({
1454
+ page: relative_page_path,
1455
+ section_index: index,
1456
+ keys
1457
+ });
1458
+ refs.set(block_name, existing);
1459
+ }
1460
+ }
1461
+ return refs;
1462
+ }
1463
+ function describe_block_content_refs(refs) {
1464
+ const details = refs.slice(0, 3).map(ref => {
1465
+ const keys = ref.keys.slice(0, 6).join(', ');
1466
+ const suffix = ref.keys.length > 6 ? ', ...' : '';
1467
+ return `${ref.page} sections[${ref.section_index}] content keys: ${keys}${suffix}`;
1468
+ });
1469
+ if (refs.length > 3) {
1470
+ details.push(`${refs.length - 3} more section${refs.length === 4 ? '' : 's'}`);
1471
+ }
1472
+ return details.join('; ');
1473
+ }
1474
+ function warn_empty_schema_writeback(site_name, relative_path, block_name, refs, preserved) {
1475
+ const key = `${site_name}:${relative_path}:${preserved ? 'preserved' : 'empty'}:${refs.length}`;
1476
+ if (warned_empty_schema_writebacks.has(key))
1477
+ return;
1478
+ warned_empty_schema_writebacks.add(key);
1479
+ if (refs.length > 0) {
1480
+ console.log(chalk.red(` ✗ ${site_name}: refused CMS-to-file empty schema writeback for ${relative_path}`));
1481
+ console.log(chalk.red(` Block "${block_name}" has page section content, but the CMS export produced an empty fields.yaml.`));
1482
+ console.log(chalk.red(` ${describe_block_content_refs(refs)}`));
1483
+ if (preserved) {
1484
+ console.log(chalk.red(' Local fields.yaml was preserved. Re-run the local import or fix the CMS schema before pulling again.'));
1485
+ }
1486
+ return;
1487
+ }
1488
+ if (preserved) {
1489
+ console.log(chalk.yellow(` ⚠ ${site_name}: skipped empty CMS schema pull for ${relative_path}; local fields.yaml has authored fields.`));
1490
+ }
1491
+ }
1492
+ function should_skip_empty_block_schema_writeback(relative_path, src_content, dest_content, options) {
1493
+ const block_name = block_fields_path_block_name(relative_path);
1494
+ if (!block_name || !is_empty_fields_yaml(src_content)) {
1495
+ return false;
1496
+ }
1497
+ const refs = options.block_content_refs?.get(block_name) ?? [];
1498
+ const local_has_fields = has_user_authored_fields(dest_content);
1499
+ const should_skip = local_has_fields || refs.length > 0;
1500
+ if (should_skip) {
1501
+ warn_empty_schema_writeback(options.site_name ?? 'site', relative_path, block_name, refs, local_has_fields);
1502
+ }
1503
+ return should_skip;
1504
+ }
1505
+ // Symmetric guard for site-level files. The CMS export now always emits
1506
+ // site/fields.yaml and site/content.yaml (so the on-disk layout documents
1507
+ // itself), which means a site with no DB-side fields/values would
1508
+ // otherwise wipe local authored content on every pull. Refuse the
1509
+ // writeback when the local file has authored content and the incoming
1510
+ // CMS export is empty.
1511
+ const warned_empty_site_writebacks = new Set();
1512
+ function warn_empty_site_writeback(site_name, relative_path) {
1513
+ const key = `${site_name}:${relative_path}`;
1514
+ if (warned_empty_site_writebacks.has(key))
1515
+ return;
1516
+ warned_empty_site_writebacks.add(key);
1517
+ console.log(chalk.yellow(` ⚠ ${site_name}: skipped empty CMS pull for ${relative_path}; local file has authored content.`));
1518
+ }
1519
+ function should_skip_empty_site_writeback(relative_path, src_content, dest_content, options) {
1520
+ if (is_site_fields_path(relative_path)) {
1521
+ if (!is_empty_fields_yaml(src_content))
1522
+ return false;
1523
+ if (!has_user_authored_fields(dest_content))
1524
+ return false;
1525
+ warn_empty_site_writeback(options.site_name ?? 'site', relative_path);
1526
+ return true;
1527
+ }
1528
+ if (is_site_content_path(relative_path)) {
1529
+ if (!is_empty_content_yaml(src_content))
1530
+ return false;
1531
+ if (!has_user_authored_content(dest_content))
1532
+ return false;
1533
+ warn_empty_site_writeback(options.site_name ?? 'site', relative_path);
1534
+ return true;
1535
+ }
1536
+ return false;
1537
+ }
1538
+ async function blocked_empty_schema_writebacks(temp_dir, site_dir, site_name, block_content_refs) {
1539
+ const blocked = new Set();
1540
+ const blocks_dir = path.join(temp_dir, 'blocks');
1541
+ let block_names;
1542
+ try {
1543
+ block_names = await fs.readdir(blocks_dir);
1544
+ }
1545
+ catch {
1546
+ return blocked;
1547
+ }
1548
+ for (const block_name of block_names) {
1549
+ if (block_name.startsWith('.'))
1550
+ continue;
1551
+ const relative_path = `blocks/${block_name}/fields.yaml`;
1552
+ let src_content;
1553
+ try {
1554
+ src_content = await fs.readFile(path.join(blocks_dir, block_name, 'fields.yaml'), 'utf-8');
1555
+ }
1556
+ catch {
1557
+ continue;
1558
+ }
1559
+ if (!is_empty_fields_yaml(src_content))
1560
+ continue;
1561
+ let dest_content = '';
1562
+ try {
1563
+ dest_content = await fs.readFile(path.join(site_dir, relative_path), 'utf-8');
1564
+ }
1565
+ catch {
1566
+ // Missing local file: still block if page content references the block.
1567
+ }
1568
+ const refs = block_content_refs.get(block_name) ?? [];
1569
+ const local_has_fields = has_user_authored_fields(dest_content);
1570
+ if (local_has_fields || refs.length > 0) {
1571
+ warn_empty_schema_writeback(site_name, relative_path, block_name, refs, local_has_fields);
1572
+ }
1573
+ if (refs.length > 0) {
1574
+ blocked.add(relative_path);
1575
+ }
1576
+ }
1577
+ return blocked;
1578
+ }
1579
+ function is_excluded_path(relative_path, excluded_paths) {
1580
+ const normalized = to_posix_path(relative_path);
1581
+ for (const excluded of excluded_paths) {
1582
+ if (normalized === excluded || normalized.startsWith(`${excluded}/`)) {
1583
+ return true;
1584
+ }
1585
+ }
1586
+ return false;
1587
+ }
1588
+ async function add_directory_to_archive(archive, full_dir, archive_dir, excluded_paths) {
1589
+ let entries;
1590
+ try {
1591
+ entries = await fs.readdir(full_dir, { withFileTypes: true });
1592
+ }
1593
+ catch {
1594
+ return;
1595
+ }
1596
+ for (const entry of entries) {
1597
+ const full_path = path.join(full_dir, entry.name);
1598
+ const archive_path = archive_dir ? `${archive_dir}/${entry.name}` : entry.name;
1599
+ if (is_excluded_path(archive_path, excluded_paths)) {
1600
+ continue;
1601
+ }
1602
+ if (entry.isDirectory()) {
1603
+ await add_directory_to_archive(archive, full_path, archive_path, excluded_paths);
1604
+ }
1605
+ else if (entry.isFile()) {
1606
+ archive.file(full_path, { name: archive_path });
1607
+ }
1608
+ }
1609
+ }
1610
+ function describe_duplicate(category, id, occurrences) {
1611
+ const files = [...new Set(occurrences.map((occurrence) => occurrence.file))].sort();
1612
+ switch (category) {
1613
+ case 'pages':
1614
+ return `duplicate page _id "${id}" in ${files.join(' and ')}; skipping those pages`;
1615
+ case 'page_sections':
1616
+ return `duplicate section _id "${id}" in ${files.join(' and ')}; skipping those pages`;
1617
+ case 'blocks':
1618
+ return `duplicate block _id "${id}" in ${files.join(' and ')}; skipping those blocks`;
1619
+ case 'page_types':
1620
+ return `duplicate page type _id "${id}" in ${files.join(' and ')}; skipping those page types`;
1621
+ case 'site_fields':
1622
+ return `duplicate site field _id "${id}" in ${files.join(' and ')}; skipping site/fields.yaml`;
1623
+ case 'block_fields':
1624
+ return `duplicate block field _id "${id}" in ${files.join(' and ')}; skipping those blocks`;
1625
+ case 'page_type_fields':
1626
+ return `duplicate page type field _id "${id}" in ${files.join(' and ')}; skipping those page types`;
1627
+ }
1628
+ }
1629
+ async function prepare_site_for_local_dev(site_dir) {
1630
+ const excluded_paths = new Set();
1631
+ const warnings = [];
1632
+ const duplicates = new Map();
1633
+ const track_occurrence = (category, id, owner, file) => {
1634
+ track_duplicate(duplicates, category, id, { owner, file });
1635
+ };
1636
+ const pages_dir = path.join(site_dir, 'pages');
1637
+ for (const relative_path of await find_page_files(pages_dir)) {
1638
+ const full_path = path.join(site_dir, relative_path);
1639
+ const raw = await read_file_or_vanish(full_path, relative_path);
1640
+ if (raw === null)
1641
+ continue;
1642
+ const page = load_yaml(raw);
1643
+ if (!page || typeof page !== 'object' || Array.isArray(page))
1644
+ continue;
1645
+ const page_id = get_entity_id(page);
1646
+ const page_sections = get_sections_array(page.sections);
1647
+ if (page_id) {
1648
+ track_occurrence('pages', page_id, relative_path, relative_path);
1649
+ }
1650
+ for (const section of page_sections) {
1651
+ const section_id = get_entity_id(section);
1652
+ if (section_id) {
1653
+ track_occurrence('page_sections', section_id, relative_path, relative_path);
1654
+ }
1655
+ }
1656
+ }
1657
+ const blocks_dir = path.join(site_dir, 'blocks');
1658
+ try {
1659
+ const block_names = await fs.readdir(blocks_dir);
1660
+ for (const block_name of block_names) {
1661
+ if (block_name.startsWith('.'))
1662
+ continue;
1663
+ // Block _id lives in config.yaml; fields (each with their own _id)
1664
+ // live in a sibling fields.yaml as a bare list.
1665
+ const owner = `blocks/${block_name}`;
1666
+ const relative_config_path = `blocks/${block_name}/config.yaml`;
1667
+ const config_path = path.join(site_dir, relative_config_path);
1668
+ try {
1669
+ const raw = await fs.readFile(config_path, 'utf-8');
1670
+ const config = load_yaml(raw);
1671
+ if (config && typeof config === 'object' && !Array.isArray(config)) {
1672
+ const block_id = get_entity_id(config);
1673
+ if (block_id) {
1674
+ track_occurrence('blocks', block_id, owner, relative_config_path);
1675
+ }
1676
+ }
1677
+ }
1678
+ catch {
1679
+ // No config.yaml — skip
1680
+ }
1681
+ const relative_fields_path = `blocks/${block_name}/fields.yaml`;
1682
+ const fields_path = path.join(site_dir, relative_fields_path);
1683
+ try {
1684
+ const raw = await fs.readFile(fields_path, 'utf-8');
1685
+ const block_fields = get_fields_array(load_yaml(raw));
1686
+ collect_field_ids(block_fields, (field_id) => {
1687
+ track_occurrence('block_fields', field_id, owner, relative_fields_path);
1688
+ });
1689
+ }
1690
+ catch {
1691
+ // No fields.yaml — skip
1692
+ }
1693
+ }
1694
+ }
1695
+ catch {
1696
+ // No blocks dir
1697
+ }
1698
+ const page_types_dir = path.join(site_dir, 'page-types');
1699
+ try {
1700
+ const page_type_names = await fs.readdir(page_types_dir);
1701
+ for (const page_type_name of page_type_names) {
1702
+ if (page_type_name.startsWith('.'))
1703
+ continue;
1704
+ // Page type _id lives in config.yaml; fields live in sibling
1705
+ // fields.yaml as a bare list.
1706
+ const owner = `page-types/${page_type_name}`;
1707
+ const relative_config_path = `page-types/${page_type_name}/config.yaml`;
1708
+ const config_path = path.join(site_dir, relative_config_path);
1709
+ try {
1710
+ const raw = await fs.readFile(config_path, 'utf-8');
1711
+ const config = load_yaml(raw);
1712
+ if (config && typeof config === 'object' && !Array.isArray(config)) {
1713
+ const page_type_id = typeof config._id === 'string' && config._id ? config._id : undefined;
1714
+ if (page_type_id) {
1715
+ track_occurrence('page_types', page_type_id, owner, relative_config_path);
1716
+ }
1717
+ }
1718
+ }
1719
+ catch {
1720
+ // No config.yaml — skip
1721
+ }
1722
+ const relative_fields_path = `page-types/${page_type_name}/fields.yaml`;
1723
+ const fields_path = path.join(site_dir, relative_fields_path);
1724
+ try {
1725
+ const raw = await fs.readFile(fields_path, 'utf-8');
1726
+ const page_type_fields = get_fields_array(load_yaml(raw));
1727
+ collect_field_ids(page_type_fields, (field_id) => {
1728
+ track_occurrence('page_type_fields', field_id, owner, relative_fields_path);
1729
+ });
1730
+ }
1731
+ catch {
1732
+ // No fields.yaml — skip
1733
+ }
1734
+ }
1735
+ }
1736
+ catch {
1737
+ // No page-types dir
1738
+ }
1739
+ const site_fields_path = path.join(site_dir, 'site', 'fields.yaml');
1740
+ try {
1741
+ const raw = await fs.readFile(site_fields_path, 'utf-8');
1742
+ const site_fields_data = load_yaml(raw);
1743
+ const site_fields = get_fields_array(site_fields_data);
1744
+ if (site_fields.length > 0) {
1745
+ collect_field_ids(site_fields, (field_id) => {
1746
+ track_occurrence('site_fields', field_id, 'site/fields.yaml', 'site/fields.yaml');
1747
+ });
1748
+ }
1749
+ }
1750
+ catch {
1751
+ // No site fields file
1752
+ }
1753
+ for (const [category, by_id] of duplicates) {
1754
+ for (const [id, occurrences] of by_id) {
1755
+ if (occurrences.length < 2) {
1756
+ continue;
1757
+ }
1758
+ for (const occurrence of occurrences) {
1759
+ excluded_paths.add(occurrence.owner);
1760
+ }
1761
+ warnings.push(describe_duplicate(category, id, occurrences));
1762
+ }
1763
+ }
1764
+ return {
1765
+ excluded_paths,
1766
+ warnings
1767
+ };
1768
+ }
1769
+ // Loudly surface non-fatal import problems (e.g. orphaned fields whose
1770
+ // content would otherwise be silently dropped). Printed in yellow with the
1771
+ // full details so agents and humans both see exactly what was lost and where.
1772
+ function print_import_warnings(site_name, warnings) {
1773
+ if (!Array.isArray(warnings) || warnings.length === 0)
1774
+ return 0;
1775
+ const list = warnings;
1776
+ console.log('');
1777
+ console.log(chalk.yellow(` ⚠ ${site_name}: ${list.length} import warning${list.length === 1 ? '' : 's'}`));
1778
+ for (const w of list) {
1779
+ const msg = w.message || `${w.kind} at ${w.path} in ${w.file}`;
1780
+ console.log(chalk.yellow(` • ${msg}`));
1781
+ }
1782
+ console.log('');
1783
+ return list.length;
1784
+ }
1785
+ async function import_site_files(site_dir, api_url, config, port, server_config, use_bootstrap = true, workspace_dir = path.dirname(path.dirname(site_dir))) {
1786
+ const site_name = config.name || 'My Site';
1787
+ const site_id = config.site_id;
1788
+ const site_group = resolve_site_group(config, server_config);
1789
+ const preparation = await prepare_site_for_local_dev(site_dir);
1790
+ for (const warning of preparation.warnings) {
1791
+ console.log(chalk.yellow(` ⚠ ${config.name}: ${warning}`));
1792
+ }
1793
+ // Create ZIP of site files
1794
+ const zip_started = Date.now();
1795
+ const zip_buffer = await create_site_zip(site_dir, preparation.excluded_paths);
1796
+ const zip_ms = Date.now() - zip_started;
1797
+ // Always use a localhost-style host in dev — the production host stays in
1798
+ // site.yaml for push, but local routing must hit *.localhost
1799
+ const host = local_dev_host(config.name || path.basename(site_dir), port);
1800
+ if (!use_bootstrap) {
1801
+ const import_form = new FormData();
1802
+ import_form.append('file', new Blob([zip_buffer]), 'site.zip');
1803
+ const request_started = Date.now();
1804
+ const import_response = await fetch_with_timeout(`${api_url}/api/palacms/import/${site_id}`, {
1805
+ method: 'POST',
1806
+ body: import_form
1807
+ }, 300000);
1808
+ const request_ms = Date.now() - request_started;
1809
+ if (!import_response.ok) {
1810
+ const import_error = await import_response.text();
1811
+ throw new Error(`Import failed (${import_response.status}): ${import_error}`);
1812
+ }
1813
+ // Write created IDs back to files
1814
+ let warning_count = 0;
1815
+ try {
1816
+ const result = await import_response.json();
1817
+ if (result.created_ids) {
1818
+ await write_created_ids(site_dir, result.created_ids, server_config, workspace_dir);
1819
+ }
1820
+ warning_count = print_import_warnings(config.name, result.warnings);
1821
+ }
1822
+ catch {
1823
+ // ignore JSON parse errors
1824
+ }
1825
+ return {
1826
+ zip_ms,
1827
+ request_ms,
1828
+ mode: 'import',
1829
+ warning_count
1830
+ };
1831
+ }
1832
+ // Retry bootstrap up to 3 times (collections may not be ready immediately)
1833
+ const max_retries = 3;
1834
+ for (let attempt = 1; attempt <= max_retries; attempt++) {
1835
+ const form_data = new FormData();
1836
+ form_data.append('site_id', site_id);
1837
+ form_data.append('name', site_name);
1838
+ form_data.append('host', host);
1839
+ form_data.append('group', site_group.id);
1840
+ form_data.append('group_name', site_group.name);
1841
+ form_data.append('group_index', String(site_group.index ?? 0));
1842
+ form_data.append('server_groups', JSON.stringify(server_config.site_groups ?? [site_group]));
1843
+ form_data.append('file', new Blob([zip_buffer]), 'site.zip');
1844
+ try {
1845
+ const bootstrap_started = Date.now();
1846
+ const bootstrap_response = await fetch_with_timeout(`${api_url}/api/palacms/bootstrap`, {
1847
+ method: 'POST',
1848
+ body: form_data
1849
+ }, 300000); // 300s timeout for imports
1850
+ const bootstrap_ms = Date.now() - bootstrap_started;
1851
+ let warning_count = 0;
1852
+ if (bootstrap_response.ok) {
1853
+ try {
1854
+ const result = await bootstrap_response.json();
1855
+ warning_count = print_import_warnings(config.name, result.warnings);
1856
+ }
1857
+ catch {
1858
+ // ignore JSON parse errors
1859
+ }
1860
+ return {
1861
+ zip_ms,
1862
+ request_ms: bootstrap_ms,
1863
+ mode: 'bootstrap',
1864
+ warning_count
1865
+ };
1866
+ }
1867
+ const error_text = await bootstrap_response.text();
1868
+ // Check if it's a collection not found error (timing issue)
1869
+ if (error_text.includes('collection') && attempt < max_retries) {
1870
+ await new Promise(resolve => setTimeout(resolve, 500 * attempt));
1871
+ continue;
1872
+ }
1873
+ console.log(chalk.yellow(` Bootstrap failed (${bootstrap_response.status}): ${error_text}`));
1874
+ // Bootstrap failed, try regular import
1875
+ const import_form = new FormData();
1876
+ import_form.append('file', new Blob([zip_buffer]), 'site.zip');
1877
+ const import_started = Date.now();
1878
+ const import_response = await fetch_with_timeout(`${api_url}/api/palacms/import/${site_id}`, {
1879
+ method: 'POST',
1880
+ body: import_form
1881
+ }, 300000); // 300s timeout for imports
1882
+ const import_ms = Date.now() - import_started;
409
1883
  if (!import_response.ok) {
410
1884
  const import_error = await import_response.text();
411
1885
  console.log(chalk.yellow(` Import failed (${import_response.status}): ${import_error}`));
412
1886
  }
413
- return;
1887
+ else {
1888
+ // Write created IDs back to files
1889
+ try {
1890
+ const result = await import_response.json();
1891
+ if (result.created_ids) {
1892
+ await write_created_ids(site_dir, result.created_ids, server_config, workspace_dir);
1893
+ }
1894
+ warning_count = print_import_warnings(config.name, result.warnings);
1895
+ }
1896
+ catch {
1897
+ // ignore JSON parse errors
1898
+ }
1899
+ }
1900
+ return {
1901
+ zip_ms,
1902
+ request_ms: bootstrap_ms + import_ms,
1903
+ mode: 'bootstrap+import',
1904
+ warning_count
1905
+ };
414
1906
  }
415
1907
  catch (err) {
416
1908
  if (attempt < max_retries) {
417
1909
  await new Promise(resolve => setTimeout(resolve, 500 * attempt));
418
1910
  continue;
419
1911
  }
420
- console.log(chalk.yellow(` Import error: ${err}`));
1912
+ if (err instanceof Error && err.name === 'AbortError') {
1913
+ throw new Error(`Timed out importing ${config.name}. If the local .primo data is stale, delete .primo and rerun primo dev.`);
1914
+ }
1915
+ throw err instanceof Error ? err : new Error(String(err));
421
1916
  }
422
1917
  }
1918
+ throw new Error(`Import failed for ${config.name}`);
1919
+ }
1920
+ async function import_library_files(base_dir, api_url, delete_group_ids = [], delete_symbol_ids = []) {
1921
+ const library_dir = path.join(base_dir, LIBRARY_DIR);
1922
+ try {
1923
+ const stat = await fs.stat(library_dir);
1924
+ if (!stat.isDirectory())
1925
+ return { zip_ms: 0, request_ms: 0 };
1926
+ }
1927
+ catch {
1928
+ return { zip_ms: 0, request_ms: 0 };
1929
+ }
1930
+ const has_deletes = delete_group_ids.length > 0 || delete_symbol_ids.length > 0;
1931
+ // If the library is empty AND there are no explicit deletes, skip the
1932
+ // push entirely. This preserves the old behavior of not wiping the CMS
1933
+ // on accidental-empty-dir. Deletes are allowed through even on an empty
1934
+ // tree so a user can intentionally clear the library.
1935
+ if (!await has_library_content(library_dir) && !has_deletes) {
1936
+ return { zip_ms: 0, request_ms: 0 };
1937
+ }
1938
+ const zip_started = Date.now();
1939
+ const zip_buffer = await create_library_zip(base_dir);
1940
+ const zip_ms = Date.now() - zip_started;
1941
+ const form_data = new FormData();
1942
+ form_data.append('file', new Blob([zip_buffer]), 'library.zip');
1943
+ if (has_deletes) {
1944
+ form_data.append('deletes', JSON.stringify({
1945
+ group_ids: delete_group_ids,
1946
+ symbol_ids: delete_symbol_ids
1947
+ }));
1948
+ }
1949
+ const request_started = Date.now();
1950
+ const response = await fetch_with_timeout(`${api_url}/api/palacms/import-library`, {
1951
+ method: 'POST',
1952
+ body: form_data
1953
+ }, 120000);
1954
+ const request_ms = Date.now() - request_started;
1955
+ if (response.status === 404) {
1956
+ throw new Error('Shared library sync is not supported by the current palacms binary/server. Rebuild or update palacms to use library sync.');
1957
+ }
1958
+ if (!response.ok) {
1959
+ const error_text = await response.text();
1960
+ throw new Error(error_text);
1961
+ }
1962
+ return { zip_ms, request_ms };
423
1963
  }
424
- async function create_site_zip(dir) {
1964
+ // Returns a map of posix-style relative paths (under library_path) to the
1965
+ // underlying record ID for every group folder and block folder currently on
1966
+ // disk. Group IDs come from library/groups.yaml, block IDs from the block's
1967
+ // config.yaml. Missing IDs are null (new blocks that have never been pushed).
1968
+ async function scan_library_folders(library_path) {
1969
+ const result = new Map();
1970
+ // Load group ID mapping from groups.yaml
1971
+ const group_ids = {};
1972
+ try {
1973
+ const raw = await fs.readFile(path.join(library_path, 'groups.yaml'), 'utf-8');
1974
+ const parsed = load_yaml(raw);
1975
+ if (Array.isArray(parsed)) {
1976
+ for (const entry of parsed) {
1977
+ if (entry && typeof entry === 'object' && entry.folder && entry.id) {
1978
+ group_ids[String(entry.folder)] = String(entry.id);
1979
+ }
1980
+ }
1981
+ }
1982
+ }
1983
+ catch {
1984
+ // no groups.yaml, leave group_ids empty
1985
+ }
1986
+ let groups;
1987
+ try {
1988
+ groups = await fs.readdir(library_path, { withFileTypes: true });
1989
+ }
1990
+ catch {
1991
+ return result;
1992
+ }
1993
+ for (const group of groups) {
1994
+ if (!group.isDirectory() || group.name.startsWith('.'))
1995
+ continue;
1996
+ const group_rel = group.name;
1997
+ result.set(group_rel, { kind: 'group', id: group_ids[group_rel] ?? null });
1998
+ const group_dir = path.join(library_path, group.name);
1999
+ let blocks;
2000
+ try {
2001
+ blocks = await fs.readdir(group_dir, { withFileTypes: true });
2002
+ }
2003
+ catch {
2004
+ continue;
2005
+ }
2006
+ for (const block of blocks) {
2007
+ if (!block.isDirectory() || block.name.startsWith('.'))
2008
+ continue;
2009
+ const block_dir = path.join(group_dir, block.name);
2010
+ let has_block_file = false;
2011
+ let block_id = null;
2012
+ try {
2013
+ const files = await fs.readdir(block_dir);
2014
+ has_block_file = files.some(f => f === 'component.svelte' || f === 'config.yaml' || f === 'fields.yaml' || f === 'content.yaml');
2015
+ if (files.includes('config.yaml')) {
2016
+ try {
2017
+ const raw = await fs.readFile(path.join(block_dir, 'config.yaml'), 'utf-8');
2018
+ const parsed = load_yaml(raw);
2019
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
2020
+ const rec = parsed;
2021
+ if (typeof rec._id === 'string' && rec._id)
2022
+ block_id = rec._id;
2023
+ else if (typeof rec.id === 'string' && rec.id)
2024
+ block_id = rec.id;
2025
+ }
2026
+ }
2027
+ catch {
2028
+ // ignore
2029
+ }
2030
+ }
2031
+ }
2032
+ catch {
2033
+ // ignore
2034
+ }
2035
+ if (has_block_file) {
2036
+ result.set(`${group_rel}/${block.name}`, { kind: 'block', id: block_id });
2037
+ }
2038
+ }
2039
+ }
2040
+ return result;
2041
+ }
2042
+ async function create_library_zip(base_dir) {
425
2043
  return new Promise((resolve, reject) => {
426
- const archive = archiver('zip', { zlib: { level: 9 } });
2044
+ const archive = archiver('zip', { zlib: { level: LOCAL_ZIP_COMPRESSION_LEVEL } });
427
2045
  const chunks = [];
428
2046
  archive.on('data', chunk => chunks.push(chunk));
429
2047
  archive.on('end', () => resolve(Buffer.concat(chunks)));
430
2048
  archive.on('error', reject);
431
- const dirs_to_include = ['blocks', 'page-types', 'pages', 'site', 'uploads'];
432
- for (const subdir of dirs_to_include) {
433
- const full_path = path.join(dir, subdir);
434
- archive.directory(full_path, subdir);
435
- }
436
- const primo_json = path.join(dir, 'primo.json');
437
- archive.file(primo_json, { name: 'primo.json' });
2049
+ archive.directory(path.join(base_dir, LIBRARY_DIR), LIBRARY_DIR);
438
2050
  archive.finalize();
439
2051
  });
440
2052
  }
441
- async function sync_from_cms(site_dir, api_url, config) {
442
- is_syncing = true;
443
- try {
444
- const response = await fetch_with_timeout(`${api_url}/api/palacms/export/${config.site_id}`, {}, 15000);
445
- if (!response.ok)
446
- return;
447
- const zip_data = await response.arrayBuffer();
448
- // Extract to temp directory
449
- const temp_dir = path.join(site_dir, '.primo', 'sync-temp');
450
- const temp_zip = path.join(temp_dir, 'export.zip');
451
- await fs.mkdir(temp_dir, { recursive: true });
452
- await fs.writeFile(temp_zip, Buffer.from(zip_data));
453
- await extract(temp_zip, { dir: temp_dir });
454
- await fs.unlink(temp_zip);
455
- // Compare and sync files
456
- const dirs_to_sync = ['blocks', 'page-types', 'pages', 'site'];
457
- const changed_files = [];
458
- for (const dir of dirs_to_sync) {
459
- const temp_path = path.join(temp_dir, dir);
460
- const local_path = path.join(site_dir, dir);
2053
+ async function create_site_zip(dir, excluded_paths = new Set()) {
2054
+ return new Promise((resolve, reject) => {
2055
+ const archive = archiver('zip', { zlib: { level: LOCAL_ZIP_COMPRESSION_LEVEL } });
2056
+ const chunks = [];
2057
+ archive.on('data', chunk => chunks.push(chunk));
2058
+ archive.on('end', () => resolve(Buffer.concat(chunks)));
2059
+ archive.on('error', reject);
2060
+ void (async () => {
461
2061
  try {
462
- const files = await sync_directory(temp_path, local_path, dir);
463
- changed_files.push(...files);
2062
+ const dirs_to_include = ['blocks', 'page-types', 'pages', 'site', 'uploads'];
2063
+ for (const subdir of dirs_to_include) {
2064
+ const full_path = path.join(dir, subdir);
2065
+ await add_directory_to_archive(archive, full_path, subdir, excluded_paths);
2066
+ }
2067
+ const site_json = path.join(dir, SITE_CONFIG_FILE);
2068
+ if (!is_excluded_path(SITE_CONFIG_FILE, excluded_paths)) {
2069
+ archive.file(site_json, { name: SITE_CONFIG_FILE });
2070
+ }
2071
+ await archive.finalize();
464
2072
  }
465
- catch {
466
- // Directory might not exist in export
2073
+ catch (error) {
2074
+ reject(error);
467
2075
  }
468
- }
469
- // Clean up temp directory
2076
+ })();
2077
+ });
2078
+ }
2079
+ async function sync_from_cms(site_dir, api_url, config, server_config, workspace_dir, sync_policy = { mode: 'both' }) {
2080
+ const response = await fetch_with_timeout(`${api_url}/api/palacms/export/${config.site_id}`, {}, 15000);
2081
+ if (!response.ok)
2082
+ return;
2083
+ const zip_data = await response.arrayBuffer();
2084
+ // Extract to temp directory
2085
+ const temp_dir = path.join(site_dir, '.primo', 'sync-temp');
2086
+ const temp_zip = path.join(temp_dir, 'export.zip');
2087
+ await fs.rm(temp_dir, { recursive: true, force: true });
2088
+ await fs.mkdir(temp_dir, { recursive: true });
2089
+ await fs.writeFile(temp_zip, Buffer.from(zip_data));
2090
+ await extract(temp_zip, { dir: temp_dir });
2091
+ await fs.unlink(temp_zip);
2092
+ const format_options = resolve_format_options(server_config);
2093
+ const block_content_refs = await collect_block_content_references(site_dir);
2094
+ const blocked_writebacks = await blocked_empty_schema_writebacks(temp_dir, site_dir, config.name, block_content_refs);
2095
+ if (blocked_writebacks.size > 0) {
470
2096
  await fs.rm(temp_dir, { recursive: true, force: true });
471
- if (changed_files.length > 0) {
472
- for (const file of changed_files) {
473
- console.log(chalk.blue(` ↓ ${config.name}: ${file}`));
2097
+ return;
2098
+ }
2099
+ const remote_snapshot = await collect_site_snapshot(temp_dir, {
2100
+ workspace_dir,
2101
+ format_options,
2102
+ dest_root: site_dir
2103
+ });
2104
+ const local_snapshot = await collect_site_snapshot(site_dir);
2105
+ const conflict_paths = sync_policy.mode === 'both'
2106
+ ? find_conflict_paths(get_site_sync_baseline({ dir: site_dir, config }), local_snapshot, remote_snapshot)
2107
+ : [];
2108
+ // Default conflict policy: files win. The caller can override by
2109
+ // running with --author cms, in which case the policy flips and the
2110
+ // CMS export is allowed to overwrite the conflicted local files.
2111
+ const skip_paths = sync_policy.mode === 'cms' ? new Set() : new Set(conflict_paths);
2112
+ const sync_options = {
2113
+ workspace_dir,
2114
+ format_options,
2115
+ block_content_refs,
2116
+ site_name: config.name,
2117
+ skip_paths
2118
+ };
2119
+ // Compare and sync files
2120
+ const changed_files = [];
2121
+ for (const dir of SITE_SYNC_DIRS) {
2122
+ const temp_path = path.join(temp_dir, dir);
2123
+ const local_path = path.join(site_dir, dir);
2124
+ if (await path_exists(temp_path)) {
2125
+ const files = await sync_directory(temp_path, local_path, dir, sync_options);
2126
+ changed_files.push(...files);
2127
+ }
2128
+ else if (await path_exists(local_path)) {
2129
+ await remove_tracked_path(local_path);
2130
+ changed_files.push(dir);
2131
+ }
2132
+ }
2133
+ // Clean up temp directory
2134
+ await fs.rm(temp_dir, { recursive: true, force: true });
2135
+ // Baseline reflects the on-disk state we just produced. For paths we
2136
+ // skipped (files-win conflict resolution), the local snapshot's value
2137
+ // is correct — using remote_snapshot would re-trigger the conflict on
2138
+ // the next cycle since the file still differs from the CMS state.
2139
+ const post_baseline = new Map(remote_snapshot);
2140
+ for (const skipped of skip_paths) {
2141
+ const local_value = local_snapshot.get(skipped);
2142
+ if (local_value === undefined) {
2143
+ post_baseline.delete(skipped);
2144
+ }
2145
+ else {
2146
+ post_baseline.set(skipped, local_value);
2147
+ }
2148
+ }
2149
+ site_sync_baselines.set(get_site_sync_key(site_dir, config), post_baseline);
2150
+ if (conflict_paths.length > 0) {
2151
+ const site_key = get_site_sync_key(site_dir, config);
2152
+ const conflict_signature = conflict_paths.join('|');
2153
+ if (last_logged_conflicts.get(site_key) !== conflict_signature) {
2154
+ last_logged_conflicts.set(site_key, conflict_signature);
2155
+ if (sync_policy.mode === 'cms') {
2156
+ log_sync_conflict(config.name, 'CMS', 'local and CMS contents differ; CMS values were applied (--author cms; local edits saved to .primo/trash/)', conflict_paths);
2157
+ }
2158
+ else {
2159
+ log_sync_conflict(config.name, 'files', 'local and CMS contents differ; local files were preserved (default policy: files win on conflict; pass --author cms to flip)', conflict_paths);
474
2160
  }
475
2161
  }
476
2162
  }
477
- finally {
478
- is_syncing = false;
2163
+ else {
2164
+ // Cleared up — clear the dedupe key so a fresh conflict re-prints.
2165
+ last_logged_conflicts.delete(get_site_sync_key(site_dir, config));
2166
+ }
2167
+ if (changed_files.length > 0) {
2168
+ for (const file of changed_files) {
2169
+ console.log(chalk.blue(` ↓ ${config.name}: ${file}`));
2170
+ }
2171
+ }
2172
+ }
2173
+ async function sync_library_from_cms(base_dir, api_url) {
2174
+ const response = await fetch_with_timeout(`${api_url}/api/palacms/export-library`, {}, 15000);
2175
+ if (response.status === 404) {
2176
+ throw new Error('Shared library sync is not supported by the current palacms binary/server. Rebuild or update palacms to use library sync.');
2177
+ }
2178
+ if (!response.ok)
2179
+ return;
2180
+ const zip_data = await response.arrayBuffer();
2181
+ const temp_dir = path.join(base_dir, '.primo', 'library-sync-temp');
2182
+ const temp_zip = path.join(temp_dir, 'library.zip');
2183
+ await fs.rm(temp_dir, { recursive: true, force: true });
2184
+ await fs.mkdir(temp_dir, { recursive: true });
2185
+ await fs.writeFile(temp_zip, Buffer.from(zip_data));
2186
+ await extract(temp_zip, { dir: temp_dir });
2187
+ await fs.unlink(temp_zip);
2188
+ const temp_library_path = path.join(temp_dir, LIBRARY_DIR);
2189
+ const local_library_path = path.join(base_dir, LIBRARY_DIR);
2190
+ let changed_files = [];
2191
+ if (await path_exists(temp_library_path)) {
2192
+ changed_files = await sync_directory(temp_library_path, local_library_path, LIBRARY_DIR);
2193
+ }
2194
+ else if (await path_exists(local_library_path)) {
2195
+ await remove_tracked_path(local_library_path);
2196
+ changed_files = [LIBRARY_DIR];
2197
+ }
2198
+ await fs.rm(temp_dir, { recursive: true, force: true });
2199
+ library_snapshot = await scan_library_folders(local_library_path);
2200
+ if (changed_files.length > 0) {
2201
+ for (const file of changed_files) {
2202
+ console.log(chalk.blue(` ↓ library: ${file}`));
2203
+ }
479
2204
  }
480
2205
  }
481
- async function sync_directory(src, dest, relative_path = '') {
2206
+ async function sync_directory(src, dest, relative_path = '', options = {}) {
482
2207
  const changed_files = [];
483
2208
  const entries = await fs.readdir(src, { withFileTypes: true });
2209
+ const source_names = new Set(entries.map(entry => entry.name));
484
2210
  await fs.mkdir(dest, { recursive: true });
485
2211
  for (const entry of entries) {
486
2212
  const src_path = path.join(src, entry.name);
487
2213
  const dest_path = path.join(dest, entry.name);
488
2214
  const file_relative = relative_path ? `${relative_path}/${entry.name}` : entry.name;
489
2215
  if (entry.isDirectory()) {
490
- const nested = await sync_directory(src_path, dest_path, file_relative);
2216
+ const nested = await sync_directory(src_path, dest_path, file_relative, options);
491
2217
  changed_files.push(...nested);
492
2218
  }
493
2219
  else {
494
- const src_content = await fs.readFile(src_path, 'utf-8');
2220
+ // "Files win on conflict" default: caller marked this path as
2221
+ // conflicted, so leave the local file alone and discard the CMS
2222
+ // value silently. Logging happens once at the call site.
2223
+ if (options.skip_paths?.has(file_relative)) {
2224
+ continue;
2225
+ }
2226
+ let src_content = await fs.readFile(src_path, 'utf-8');
2227
+ // Run server-emitted file through the workspace's formatter so
2228
+ // per-user style (tabs, line width, single quotes, etc.) survives
2229
+ // the round-trip. Without this, every CMS export wipes out the
2230
+ // user's formatting and the file watcher fires another reimport.
2231
+ if (options.format_options && options.workspace_dir && should_format(dest_path)) {
2232
+ src_content = await format_file_contents(dest_path, src_content, options.workspace_dir, options.format_options);
2233
+ }
495
2234
  let dest_content = '';
496
2235
  try {
497
2236
  dest_content = await fs.readFile(dest_path, 'utf-8');
@@ -499,8 +2238,25 @@ async function sync_directory(src, dest, relative_path = '') {
499
2238
  catch {
500
2239
  // File doesn't exist locally
501
2240
  }
2241
+ if (should_skip_empty_block_schema_writeback(file_relative, src_content, dest_content, options)) {
2242
+ continue;
2243
+ }
2244
+ if (should_skip_empty_site_writeback(file_relative, src_content, dest_content, options)) {
2245
+ continue;
2246
+ }
502
2247
  // Normalize to handle trailing newline/whitespace differences
503
2248
  if (src_content.trim() !== dest_content.trim()) {
2249
+ // Trash the prior content so the user can recover if this
2250
+ // overwrite was unwanted. Skipped when there was no prior file.
2251
+ // Trashing must never block the sync — failures are logged and ignored.
2252
+ if (dest_content && options.workspace_dir && options.site_name) {
2253
+ try {
2254
+ await trash_existing_file(dest_content, options.workspace_dir, options.site_name, file_relative);
2255
+ }
2256
+ catch {
2257
+ console.log(chalk.dim(` trash failed for ${file_relative}`));
2258
+ }
2259
+ }
504
2260
  // Track this file BEFORE writing to avoid race with watcher
505
2261
  // Use current time as estimate, watcher allows 1 second tolerance
506
2262
  synced_files.set(dest_path, Date.now());
@@ -508,9 +2264,110 @@ async function sync_directory(src, dest, relative_path = '') {
508
2264
  // Update with actual mtime after write
509
2265
  const stat = await fs.stat(dest_path);
510
2266
  synced_files.set(dest_path, stat.mtimeMs);
511
- changed_files.push(file_relative);
2267
+ // Surface shrinkage on the change line itself so a user
2268
+ // scanning the dev log notices when a YAML list silently
2269
+ // loses entries (the failure mode reported during the
2270
+ // column-accounting beta).
2271
+ const shrink_delta = compute_shrink_delta(dest_content, src_content);
2272
+ const annotated = shrink_delta !== null
2273
+ ? `${file_relative} (${shrink_delta} lines, prior in .primo/trash/)`
2274
+ : file_relative;
2275
+ changed_files.push(annotated);
2276
+ }
2277
+ }
2278
+ }
2279
+ const dest_entries = await fs.readdir(dest, { withFileTypes: true });
2280
+ for (const entry of dest_entries) {
2281
+ if (source_names.has(entry.name)) {
2282
+ continue;
2283
+ }
2284
+ const dest_path = path.join(dest, entry.name);
2285
+ const file_relative = relative_path ? `${relative_path}/${entry.name}` : entry.name;
2286
+ // "Files win on conflict": caller marked this path as conflicted,
2287
+ // so leave the local file in place even though the CMS export
2288
+ // dropped it.
2289
+ if (options.skip_paths?.has(file_relative)) {
2290
+ continue;
2291
+ }
2292
+ // Trash the file/tree before removing so a CMS-side delete
2293
+ // (often triggered by an upstream parse error dropping references)
2294
+ // is recoverable from .primo/trash/.
2295
+ if (options.workspace_dir && options.site_name) {
2296
+ try {
2297
+ await trash_path_recursive(dest_path, options.workspace_dir, options.site_name, file_relative);
2298
+ }
2299
+ catch {
2300
+ console.log(chalk.dim(` trash failed for ${file_relative}`));
512
2301
  }
513
2302
  }
2303
+ await remove_tracked_path(dest_path);
2304
+ changed_files.push(`${file_relative} (deleted, prior in .primo/trash/)`);
514
2305
  }
515
2306
  return changed_files;
516
2307
  }
2308
+ async function has_library_content(library_dir) {
2309
+ let entries;
2310
+ try {
2311
+ entries = await fs.readdir(library_dir, { withFileTypes: true });
2312
+ }
2313
+ catch (err) {
2314
+ // Missing library/ is normal in workspaces that haven't been
2315
+ // initialized for library sync — treat as empty rather than crashing
2316
+ // the sync loop.
2317
+ if (err?.code === 'ENOENT')
2318
+ return false;
2319
+ throw err;
2320
+ }
2321
+ for (const entry of entries) {
2322
+ if (entry.name.startsWith('.')) {
2323
+ continue;
2324
+ }
2325
+ return true;
2326
+ }
2327
+ return false;
2328
+ }
2329
+ async function write_created_ids(site_dir, created_ids, server_config, workspace_dir) {
2330
+ const format_options = resolve_format_options(server_config);
2331
+ for (const [relative_path, id_data] of Object.entries(created_ids)) {
2332
+ if (!id_data._id && !Array.isArray(id_data.sections))
2333
+ continue;
2334
+ const file_path = path.join(site_dir, relative_path);
2335
+ try {
2336
+ const content = await fs.readFile(file_path, 'utf-8');
2337
+ const parsed = load_yaml(content);
2338
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2339
+ continue;
2340
+ let data = parsed;
2341
+ let changed = false;
2342
+ if (id_data._id && !data._id && !data.id) {
2343
+ data = { _id: id_data._id, ...data };
2344
+ changed = true;
2345
+ }
2346
+ if (Array.isArray(id_data.sections) && Array.isArray(data.sections)) {
2347
+ const section_ids = id_data.sections;
2348
+ const sections = data.sections.map((section, index) => {
2349
+ if (!section || typeof section !== 'object' || Array.isArray(section))
2350
+ return section;
2351
+ const section_record = section;
2352
+ const section_id = section_ids[index];
2353
+ if (!section_id || section_record._id || section_record.id)
2354
+ return section;
2355
+ changed = true;
2356
+ return { _id: section_id, ...section_record };
2357
+ });
2358
+ if (changed) {
2359
+ data = { ...data, sections };
2360
+ }
2361
+ }
2362
+ if (changed) {
2363
+ const raw = dump_yaml(data, { lineWidth: -1 });
2364
+ const formatted = await format_file_contents(file_path, raw, workspace_dir, format_options);
2365
+ await fs.writeFile(file_path, formatted, 'utf-8');
2366
+ await mark_written_file(file_path);
2367
+ }
2368
+ }
2369
+ catch {
2370
+ // skip if file doesn't exist or can't be read
2371
+ }
2372
+ }
2373
+ }