primo-cli 0.1.2 → 0.1.4
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.
- package/README.md +113 -41
- package/dist/commands/build.js +488 -272
- package/dist/commands/deploy.d.ts +1 -1
- package/dist/commands/deploy.js +293 -141
- package/dist/commands/dev.d.ts +2 -0
- package/dist/commands/dev.js +2007 -150
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.js +65 -43
- package/dist/commands/login.d.ts +1 -2
- package/dist/commands/login.js +24 -6
- package/dist/commands/new.js +161 -274
- package/dist/commands/pull-library.d.ts +7 -0
- package/dist/commands/pull-library.js +92 -0
- package/dist/commands/pull.d.ts +0 -1
- package/dist/commands/pull.js +160 -165
- package/dist/commands/push-library.d.ts +7 -0
- package/dist/commands/push-library.js +88 -0
- package/dist/commands/push.d.ts +2 -0
- package/dist/commands/push.js +358 -51
- package/dist/commands/validate.d.ts +1 -1
- package/dist/commands/validate.js +379 -161
- package/dist/index.js +110 -20
- package/dist/utils/binary.js +1 -1
- package/dist/utils/format.d.ts +12 -0
- package/dist/utils/format.js +98 -0
- package/dist/utils/head-svelte.d.ts +2 -0
- package/dist/utils/head-svelte.js +53 -0
- package/dist/utils/server-config.d.ts +19 -0
- package/dist/utils/server-config.js +49 -0
- package/dist/utils/site-config.d.ts +11 -0
- package/dist/utils/site-config.js +14 -0
- package/package.json +9 -5
- package/scripts/postinstall.js +1 -1
- package/dist/commands/export.d.ts +0 -8
- package/dist/commands/export.js +0 -163
- package/dist/commands/import.d.ts +0 -9
- package/dist/commands/import.js +0 -118
- package/dist/commands/publish.d.ts +0 -6
- package/dist/commands/publish.js +0 -239
package/dist/commands/dev.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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(
|
|
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('
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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, {
|
|
190
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
|
239
|
-
|
|
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.
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
964
|
+
finally {
|
|
965
|
+
is_syncing = false;
|
|
261
966
|
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
463
|
-
|
|
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
|
-
|
|
2073
|
+
catch (error) {
|
|
2074
|
+
reject(error);
|
|
467
2075
|
}
|
|
468
|
-
}
|
|
469
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|