primo-cli 0.1.8 → 0.1.9

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.
@@ -221,11 +221,11 @@ async function check_provider_auth(provider) {
221
221
  return false;
222
222
  }
223
223
  }
224
- // Pinned to the upstream-published image. palacms's main.yml workflow
224
+ // Pinned to the upstream-published image. primocms's main.yml workflow
225
225
  // publishes branch tags for whitelisted prefixes (main, feature/**, rc/**),
226
226
  // with slashes slugified to dashes (feature/local-dev-cli → :feature-local-dev-cli).
227
- // Bump to a release tag (:v3.0.0) when palacms cuts a stable release.
228
- const PRIMO_SERVER_IMAGE = 'ghcr.io/palacms/palacms:feature-local-dev-cli';
227
+ // Bump to a release tag (:v3.0.0) when primocms cuts a stable release.
228
+ const PRIMO_SERVER_IMAGE = 'ghcr.io/primocms/primo:main';
229
229
  async function generate_dockerfile(inventory) {
230
230
  // One-line Dockerfile: pull the published palacms image and run it
231
231
  // unchanged. Workspace data (server.yaml, sites/, library/) is uploaded
@@ -36,8 +36,12 @@ let site_sync_baselines = new Map();
36
36
  // the same conflict block every pull cycle when palacms's serialization
37
37
  // keeps producing the same divergence (e.g. data-key mangling, key reorder).
38
38
  const last_logged_conflicts = new Map();
39
- // Track files written by sync to prevent watcher from re-pushing them
40
- // Map of filepath -> mtime (ms) when we wrote it
39
+ // Track files written by sync to prevent watcher from re-pushing them.
40
+ // Keyed by absolute path; value is the SHA-256 of the content we wrote.
41
+ // We compare against the file's *current* hash in the watcher, so a user
42
+ // edit that happens within the polling cycle is detected by content
43
+ // divergence rather than mtime±tolerance (which used to drop edits made
44
+ // within 3 seconds of a sync-write).
41
45
  const synced_files = new Map();
42
46
  const synced_deleted_paths = new Map();
43
47
  const warned_empty_schema_writebacks = new Set();
@@ -49,11 +53,25 @@ const MCP_CONFIG_FILE = '.mcp.json';
49
53
  const SITE_SYNC_DIRS = ['blocks', 'page-types', 'pages', 'site'];
50
54
  const LOCAL_PUSH_DEBOUNCE_MS = 150;
51
55
  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;
56
+ function hash_content(content) {
57
+ return createHash('sha256').update(content).digest('hex');
58
+ }
59
+ // Returns true iff the file at `full_path` still has the exact content
60
+ // we last synced to it. Used by watcher event handlers to ignore their
61
+ // own writes without the old mtime-tolerance race that swallowed user
62
+ // edits made within seconds of a sync-write.
63
+ async function event_matches_synced_write(full_path) {
64
+ const expected_hash = synced_files.get(full_path);
65
+ if (!expected_hash)
66
+ return false;
67
+ try {
68
+ const current = await fs.readFile(full_path);
69
+ return hash_content(current) === expected_hash;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
57
75
  // When the user writes a file locally, suppress CMS->local pulls for this
58
76
  // long to prevent a pull that was in-flight before the watcher fired from
59
77
  // stomping the just-written content on arrival.
@@ -636,8 +654,11 @@ export async function dev_server(options) {
636
654
  if (sites.length > 0 || !is_server_mode) {
637
655
  console.log('');
638
656
  }
639
- // Start watching for file changes
640
- const dirs_to_watch = ['blocks', 'page-types', 'pages', 'site'];
657
+ // Start watching for file changes. `uploads` is included so dropping
658
+ // an image into uploads/ triggers a push — the server-side reconcile
659
+ // creates a site_uploads record for it and the writeback renames
660
+ // the local file to the canonical (suffixed) name.
661
+ const dirs_to_watch = ['blocks', 'page-types', 'pages', 'site', 'uploads'];
641
662
  const known_sites = new Set(sites.map(s => s.dir));
642
663
  if (is_server_mode) {
643
664
  const library_path = path.join(base_dir, LIBRARY_DIR);
@@ -676,16 +697,18 @@ export async function dev_server(options) {
676
697
  const on_event = (full_path) => {
677
698
  if (should_skip_synced_delete(full_path))
678
699
  return;
679
- const synced_mtime = synced_files.get(full_path);
680
- if (synced_mtime) {
681
- // Our own sync-write — skip.
682
- fs.stat(full_path)
683
- .then(stat => {
684
- if (Math.abs(stat.mtimeMs - synced_mtime) < SYNC_MTIME_TOLERANCE_MS) {
685
- synced_files.delete(full_path);
686
- return;
687
- }
700
+ if (synced_files.has(full_path)) {
701
+ // We last wrote this file from a CMS pull. If the
702
+ // content on disk still matches our write, the
703
+ // chokidar event is just our own write echoing
704
+ // back — drop it. If it differs, the user edited
705
+ // the file (possibly very shortly after our pull
706
+ // landed) and we must push.
707
+ event_matches_synced_write(full_path)
708
+ .then(matches => {
688
709
  synced_files.delete(full_path);
710
+ if (matches)
711
+ return;
689
712
  push_or_ignore_library_change(full_path);
690
713
  })
691
714
  .catch(() => {
@@ -818,7 +841,16 @@ export async function dev_server(options) {
818
841
  }
819
842
  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` : ''}`));
820
843
  console.log(chalk.green(` ✓ ${site.config.name} pushed`));
821
- await write_sync_status(site.dir, { ok: true });
844
+ if (import_timings.warning_count > 0) {
845
+ await write_sync_status(site.dir, {
846
+ ok: true,
847
+ warnings: import_timings.warning_count,
848
+ warned_at: new Date().toISOString()
849
+ });
850
+ }
851
+ else {
852
+ await write_sync_status(site.dir, { ok: true });
853
+ }
822
854
  }
823
855
  catch (err) {
824
856
  const message = err instanceof Error ? err.message : String(err);
@@ -880,15 +912,20 @@ export async function dev_server(options) {
880
912
  const on_event = (full_path) => {
881
913
  if (should_skip_synced_delete(full_path))
882
914
  return;
883
- const synced_mtime = synced_files.get(full_path);
884
- if (synced_mtime) {
885
- fs.stat(full_path)
886
- .then(stat => {
887
- if (Math.abs(stat.mtimeMs - synced_mtime) < SYNC_MTIME_TOLERANCE_MS) {
888
- synced_files.delete(full_path);
889
- return;
890
- }
915
+ if (synced_files.has(full_path)) {
916
+ // We last wrote this file from a CMS pull. Compare
917
+ // the file's *current* content against the hash we
918
+ // stored: if identical, this watcher event is the
919
+ // echo of our own write and must be ignored; if
920
+ // different, the user edited the file (possibly
921
+ // within ms of our pull) and the edit must push,
922
+ // which is exactly the case the old mtime-tolerance
923
+ // check used to swallow for site/head.svelte.
924
+ event_matches_synced_write(full_path)
925
+ .then(matches => {
891
926
  synced_files.delete(full_path);
927
+ if (matches)
928
+ return;
892
929
  push_or_ignore_file_change(full_path);
893
930
  })
894
931
  .catch(() => {
@@ -1273,15 +1310,8 @@ function track_duplicate(duplicates, category, id, occurrence) {
1273
1310
  existing.push(occurrence);
1274
1311
  by_id.set(id, existing);
1275
1312
  }
1276
- async function mark_written_file(file_path) {
1277
- synced_files.set(file_path, Date.now());
1278
- try {
1279
- const stat = await fs.stat(file_path);
1280
- synced_files.set(file_path, stat.mtimeMs);
1281
- }
1282
- catch {
1283
- // Ignore files that disappeared
1284
- }
1313
+ function mark_written_file(file_path, content) {
1314
+ synced_files.set(file_path, hash_content(content));
1285
1315
  }
1286
1316
  function mark_deleted_path(file_path) {
1287
1317
  synced_deleted_paths.set(file_path, Date.now());
@@ -1831,8 +1861,10 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
1831
1861
  const zip_started = Date.now();
1832
1862
  const zip_buffer = await create_site_zip(site_dir, preparation.excluded_paths);
1833
1863
  const zip_ms = Date.now() - zip_started;
1834
- // Always use a localhost-style host in dev the production host stays in
1835
- // site.yaml for push, but local routing must hit *.localhost
1864
+ // Dev sites route via *.localhost. The host is computed from name+port
1865
+ // here and passed only to bootstrap (which seeds new sites with this
1866
+ // routing host); site.yaml does not carry host at all, and the regular
1867
+ // import path never sets host — so dashboard-managed routing is safe.
1836
1868
  const host = local_dev_host(config.name || path.basename(site_dir), port);
1837
1869
  if (!use_bootstrap) {
1838
1870
  const import_form = new FormData();
@@ -2103,18 +2135,7 @@ async function create_site_zip(dir, excluded_paths = new Set()) {
2103
2135
  }
2104
2136
  const site_json = path.join(dir, SITE_CONFIG_FILE);
2105
2137
  if (!is_excluded_path(SITE_CONFIG_FILE, excluded_paths)) {
2106
- // Strip `host` if present — `site.yaml` no longer carries it,
2107
- // but older dirs pulled by previous CLI versions still do,
2108
- // and the local CMS's /import endpoint persists whatever
2109
- // host the zipped yaml declares, overwriting bootstrap's
2110
- // *.localhost value and breaking preview routing.
2111
- const sanitized = await read_site_yaml_without_host(site_json);
2112
- if (sanitized !== null) {
2113
- archive.append(sanitized, { name: SITE_CONFIG_FILE });
2114
- }
2115
- else {
2116
- archive.file(site_json, { name: SITE_CONFIG_FILE });
2117
- }
2138
+ archive.file(site_json, { name: SITE_CONFIG_FILE });
2118
2139
  }
2119
2140
  await archive.finalize();
2120
2141
  }
@@ -2124,22 +2145,6 @@ async function create_site_zip(dir, excluded_paths = new Set()) {
2124
2145
  })();
2125
2146
  });
2126
2147
  }
2127
- async function read_site_yaml_without_host(site_yaml_path) {
2128
- try {
2129
- const raw = await fs.readFile(site_yaml_path, 'utf-8');
2130
- const parsed = load_yaml(raw);
2131
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2132
- return null;
2133
- const data = parsed;
2134
- if (!('host' in data))
2135
- return null;
2136
- const { host: _, ...rest } = data;
2137
- return dump_yaml(rest, { lineWidth: -1, noRefs: true });
2138
- }
2139
- catch {
2140
- return null;
2141
- }
2142
- }
2143
2148
  async function sync_from_cms(site_dir, api_url, config, server_config, workspace_dir, sync_policy = { mode: 'both' }) {
2144
2149
  const response = await fetch_with_timeout(`${api_url}/api/palacms/export/${config.site_id}`, {}, 15000);
2145
2150
  if (!response.ok)
@@ -2321,13 +2326,12 @@ async function sync_directory(src, dest, relative_path = '', options = {}) {
2321
2326
  console.log(chalk.dim(` trash failed for ${file_relative}`));
2322
2327
  }
2323
2328
  }
2324
- // Track this file BEFORE writing to avoid race with watcher
2325
- // Use current time as estimate, watcher allows 1 second tolerance
2326
- synced_files.set(dest_path, Date.now());
2329
+ // Track this file's content hash BEFORE writing so the
2330
+ // chokidar event our own write produces can be matched
2331
+ // against `synced_files` and dropped, while a genuine user
2332
+ // edit (different content) still falls through and pushes.
2333
+ synced_files.set(dest_path, hash_content(src_content));
2327
2334
  await fs.writeFile(dest_path, src_content);
2328
- // Update with actual mtime after write
2329
- const stat = await fs.stat(dest_path);
2330
- synced_files.set(dest_path, stat.mtimeMs);
2331
2335
  // Surface shrinkage on the change line itself so a user
2332
2336
  // scanning the dev log notices when a YAML list silently
2333
2337
  // loses entries (the failure mode reported during the
@@ -2392,6 +2396,14 @@ async function has_library_content(library_dir) {
2392
2396
  }
2393
2397
  async function write_created_ids(site_dir, created_ids, server_config, workspace_dir) {
2394
2398
  const format_options = resolve_format_options(server_config);
2399
+ // Upload writeback is special: rename local files and rewrite any yaml
2400
+ // that still references symbolic paths. Surfaced under a non-standard
2401
+ // `_uploads` key in created_ids["uploads/.manifest.json"] so the rest of
2402
+ // this loop's `_id`/`sections` logic doesn't get confused by it.
2403
+ const uploads_payload = created_ids['uploads/.manifest.json'];
2404
+ if (uploads_payload && typeof uploads_payload._uploads === 'object' && uploads_payload._uploads !== null) {
2405
+ await write_upload_writeback(site_dir, uploads_payload._uploads, server_config, workspace_dir);
2406
+ }
2395
2407
  for (const [relative_path, id_data] of Object.entries(created_ids)) {
2396
2408
  if (!id_data._id && !Array.isArray(id_data.sections))
2397
2409
  continue;
@@ -2427,7 +2439,7 @@ async function write_created_ids(site_dir, created_ids, server_config, workspace
2427
2439
  const raw = dump_yaml(data, { lineWidth: -1 });
2428
2440
  const formatted = await format_file_contents(file_path, raw, workspace_dir, format_options);
2429
2441
  await fs.writeFile(file_path, formatted, 'utf-8');
2430
- await mark_written_file(file_path);
2442
+ mark_written_file(file_path, formatted);
2431
2443
  }
2432
2444
  }
2433
2445
  catch {
@@ -2435,3 +2447,148 @@ async function write_created_ids(site_dir, created_ids, server_config, workspace
2435
2447
  }
2436
2448
  }
2437
2449
  }
2450
+ // write_upload_writeback reconciles the local uploads/ folder and yaml refs
2451
+ // with what the server actually stored on the latest push.
2452
+ //
2453
+ // For each entry `symbolic -> {id, canonical}`:
2454
+ // - If canonical != symbolic, rename uploads/<symbolic> to uploads/<canonical>
2455
+ // on disk so subsequent pulls/pushes round-trip without churn.
2456
+ // - Rewrite any yaml file that still says `upload: "uploads/<symbolic>"` to
2457
+ // the record id, matching what the server stored. This converges the local
2458
+ // copy with the server's canonical content shape without needing a pull.
2459
+ //
2460
+ // The watcher is told about each rename and rewrite via mark_written_file /
2461
+ // mark_deleted_path so it doesn't echo our own writes back as user edits.
2462
+ async function write_upload_writeback(site_dir, uploads_map, server_config, workspace_dir) {
2463
+ // Build a symbolic -> record-id map for the yaml rewrite pass. Skip
2464
+ // malformed entries instead of failing the whole writeback so a partial
2465
+ // server response can still rename what it can.
2466
+ const symbolic_to_id = new Map();
2467
+ const renames = [];
2468
+ for (const [symbolic, raw_entry] of Object.entries(uploads_map)) {
2469
+ if (!raw_entry || typeof raw_entry !== 'object')
2470
+ continue;
2471
+ const entry = raw_entry;
2472
+ const id = typeof entry.id === 'string' ? entry.id : '';
2473
+ const canonical = typeof entry.canonical === 'string' ? entry.canonical : '';
2474
+ if (!id)
2475
+ continue;
2476
+ symbolic_to_id.set(symbolic, id);
2477
+ if (canonical && canonical !== symbolic) {
2478
+ renames.push({ symbolic, canonical });
2479
+ }
2480
+ }
2481
+ // Rename the on-disk files. Best-effort: if the symbolic file is missing
2482
+ // (already renamed by a prior push, or removed by the user) we silently
2483
+ // move on — the yaml rewrite still keeps content consistent.
2484
+ const uploads_dir = path.join(site_dir, 'uploads');
2485
+ for (const { symbolic, canonical } of renames) {
2486
+ const from = path.join(uploads_dir, symbolic);
2487
+ const to = path.join(uploads_dir, canonical);
2488
+ try {
2489
+ await fs.rename(from, to);
2490
+ mark_deleted_path(from);
2491
+ mark_written_file(to, await fs.readFile(to));
2492
+ }
2493
+ catch {
2494
+ // missing source or permission issue — skip
2495
+ }
2496
+ }
2497
+ // Rewrite symbolic upload references across all yaml under the site dir.
2498
+ // Walking the tree is bounded by site size and only re-marshals files
2499
+ // that mention `uploads/` literally, so the cost is small even on large
2500
+ // sites. Restricting to known sync subdirs avoids touching artifacts in
2501
+ // dot-directories or build output.
2502
+ if (symbolic_to_id.size === 0)
2503
+ return;
2504
+ const format_options = resolve_format_options(server_config);
2505
+ for (const subdir of SITE_SYNC_DIRS) {
2506
+ const root = path.join(site_dir, subdir);
2507
+ const files = await walk_yaml_files(root).catch(() => []);
2508
+ for (const file_path of files) {
2509
+ try {
2510
+ const content = await fs.readFile(file_path, 'utf-8');
2511
+ if (!content.includes('uploads/'))
2512
+ continue;
2513
+ const parsed = load_yaml(content);
2514
+ if (!parsed || typeof parsed !== 'object')
2515
+ continue;
2516
+ const { value: rewritten, changed } = rewrite_symbolic_upload_refs(parsed, symbolic_to_id);
2517
+ if (!changed)
2518
+ continue;
2519
+ const raw = dump_yaml(rewritten, { lineWidth: -1 });
2520
+ const formatted = await format_file_contents(file_path, raw, workspace_dir, format_options);
2521
+ await fs.writeFile(file_path, formatted, 'utf-8');
2522
+ mark_written_file(file_path, formatted);
2523
+ }
2524
+ catch {
2525
+ // skip unreadable / unparseable file
2526
+ }
2527
+ }
2528
+ }
2529
+ }
2530
+ async function walk_yaml_files(root) {
2531
+ const out = [];
2532
+ const stack = [root];
2533
+ while (stack.length > 0) {
2534
+ const dir = stack.pop();
2535
+ let entries;
2536
+ try {
2537
+ entries = await fs.readdir(dir, { withFileTypes: true });
2538
+ }
2539
+ catch {
2540
+ continue;
2541
+ }
2542
+ for (const entry of entries) {
2543
+ if (entry.name.startsWith('.'))
2544
+ continue;
2545
+ const full = path.join(dir, entry.name);
2546
+ if (entry.isDirectory()) {
2547
+ stack.push(full);
2548
+ }
2549
+ else if (entry.isFile() && entry.name.endsWith('.yaml')) {
2550
+ out.push(full);
2551
+ }
2552
+ }
2553
+ }
2554
+ return out;
2555
+ }
2556
+ // rewrite_symbolic_upload_refs is the CLI mirror of the server's rewriteUploadRefs.
2557
+ // Walking on the CLI side handles the case where the server's in-zip rewrite
2558
+ // updated the imported content but the source files on disk still reference
2559
+ // the symbolic path. Without this pass, the next push would resend the
2560
+ // symbolic ref and force the server to re-resolve every time.
2561
+ function rewrite_symbolic_upload_refs(value, map) {
2562
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
2563
+ const obj = value;
2564
+ let changed = false;
2565
+ const result = {};
2566
+ for (const [k, v] of Object.entries(obj)) {
2567
+ if (k === 'upload' && typeof v === 'string' && v.startsWith('uploads/')) {
2568
+ const filename = v.substring('uploads/'.length);
2569
+ const id = map.get(filename);
2570
+ if (id) {
2571
+ result[k] = id;
2572
+ changed = true;
2573
+ continue;
2574
+ }
2575
+ }
2576
+ const child = rewrite_symbolic_upload_refs(v, map);
2577
+ result[k] = child.value;
2578
+ if (child.changed)
2579
+ changed = true;
2580
+ }
2581
+ return { value: result, changed };
2582
+ }
2583
+ if (Array.isArray(value)) {
2584
+ let changed = false;
2585
+ const result = value.map(item => {
2586
+ const child = rewrite_symbolic_upload_refs(item, map);
2587
+ if (child.changed)
2588
+ changed = true;
2589
+ return child.value;
2590
+ });
2591
+ return { value: result, changed };
2592
+ }
2593
+ return { value, changed: false };
2594
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {