primo-cli 0.1.24 → 0.1.25

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.
@@ -3,9 +3,11 @@ import path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import archiver from 'archiver';
6
+ import { dump as dump_yaml, load as load_yaml } from 'js-yaml';
6
7
  import { get_auth_token } from '../utils/auth.js';
7
8
  import { read_site_config, get_site_config_path, SITE_CONFIG_FILE } from '../utils/site-config.js';
8
- import { get_server_config_path, read_server_config, normalize_server_url } from '../utils/server-config.js';
9
+ import { get_server_config_path, read_server_config, resolve_format_options, normalize_server_url } from '../utils/server-config.js';
10
+ import { format_file_contents } from '../utils/format.js';
9
11
  async function path_exists(p) {
10
12
  try {
11
13
  await fs.access(p);
@@ -39,6 +41,206 @@ async function resolve_group_name(site_dir, group_id) {
39
41
  }
40
42
  return undefined;
41
43
  }
44
+ // Best-effort workspace root for a site dir, used to resolve the workspace
45
+ // prettier install when formatting rewritten yaml. Walks up looking for a
46
+ // server.yaml; falls back to the site dir if none is found (single-site checkout).
47
+ async function root_dir_for(site_dir) {
48
+ const candidates = [path.dirname(path.dirname(site_dir)), path.dirname(site_dir), site_dir];
49
+ for (const dir of candidates) {
50
+ if (await path_exists(get_server_config_path(dir)))
51
+ return dir;
52
+ }
53
+ return site_dir;
54
+ }
55
+ // Converge the local site with the upload ids the server minted on push. The
56
+ // server creates its own site_uploads record per file (it can't honor a foreign
57
+ // CMS's id), renames files to a canonical suffixed name, and returns the
58
+ // symbolic->{id,canonical} map. `primo dev` applies this writeback via its
59
+ // watcher; `primo push` previously dropped it, so local refs stayed symbolic (or
60
+ // worse, kept a stale id from another CMS) and drifted from the server forever.
61
+ //
62
+ // This mirrors write_upload_writeback in dev.ts but without the chokidar
63
+ // bookkeeping (nothing is watching during a push). Best-effort throughout: a
64
+ // push has already succeeded by the time we get here, so a writeback hiccup
65
+ // should never fail the command.
66
+ async function apply_upload_writeback(site_dir, workspace_dir, created_ids) {
67
+ const manifest = created_ids?.['uploads/.manifest.json'];
68
+ const uploads_map = manifest && typeof manifest._uploads === 'object' && manifest._uploads !== null
69
+ ? manifest._uploads
70
+ : null;
71
+ if (!uploads_map)
72
+ return;
73
+ // Build symbolic-filename -> record-id map and the rename list. Both
74
+ // symbolic and canonical are treated as untrusted (they come from the
75
+ // server response): reject anything that isn't a plain basename so a
76
+ // crafted `../` can't make a later path.join escape uploads/ (CWE-22).
77
+ const symbolic_to_id = new Map();
78
+ const rename_for = new Map();
79
+ for (const [symbolic, raw] of Object.entries(uploads_map)) {
80
+ if (!raw || typeof raw !== 'object')
81
+ continue;
82
+ if (!is_safe_basename(symbolic))
83
+ continue;
84
+ const entry = raw;
85
+ if (typeof entry.id !== 'string' || !entry.id)
86
+ continue;
87
+ symbolic_to_id.set(symbolic, entry.id);
88
+ const canonical = typeof entry.canonical === 'string' ? entry.canonical : '';
89
+ if (canonical && canonical !== symbolic && is_safe_basename(canonical)) {
90
+ rename_for.set(symbolic, canonical);
91
+ }
92
+ }
93
+ if (symbolic_to_id.size === 0)
94
+ return;
95
+ // Rewrite refs FIRST, renames second — and only rename files whose ref
96
+ // rewrite actually succeeded. Renaming before rewriting risks leaving a file
97
+ // at its canonical name while the yaml still points at the old symbolic path
98
+ // (if the rewrite is skipped/unreadable/unwritable), which the next push
99
+ // would resend as a stale ref. So we rewrite yaml, track which symbolic keys
100
+ // were applied, and rename only those — keeping disk and yaml consistent even
101
+ // when some files can't be processed.
102
+ let format_options;
103
+ try {
104
+ format_options = resolve_format_options(await read_server_config(workspace_dir));
105
+ }
106
+ catch {
107
+ format_options = resolve_format_options({});
108
+ }
109
+ // Track, per symbolic name, whether it was successfully rewritten in at least
110
+ // one file (`applied`) and whether ANY file that references it could not be
111
+ // persisted (`failed`). The same `upload: uploads/<name>` ref can appear in
112
+ // multiple files, so a name is only safe to rename once EVERY file bearing it
113
+ // is written — otherwise a skipped/failed file keeps the old symbolic ref
114
+ // while the file has already moved to its canonical name.
115
+ const applied = new Set();
116
+ const failed = new Set();
117
+ // Note which symbolic names a raw file mentions, so a file that fails before
118
+ // (or during) parse still marks its refs failed rather than silently passing.
119
+ const referenced_names = (raw) => [...symbolic_to_id.keys()].filter(name => raw.includes(`uploads/${name}`));
120
+ for (const subdir of UPLOAD_REF_DIRS) {
121
+ const files = await walk_yaml_files(path.join(site_dir, subdir));
122
+ for (const file_path of files) {
123
+ let content;
124
+ try {
125
+ content = await fs.readFile(file_path, 'utf-8');
126
+ }
127
+ catch {
128
+ continue; // unreadable file references nothing we can see; skip
129
+ }
130
+ if (!content.includes('uploads/'))
131
+ continue;
132
+ const names_here = referenced_names(content);
133
+ if (names_here.length === 0)
134
+ continue;
135
+ try {
136
+ const parsed = load_yaml(content);
137
+ if (!parsed || typeof parsed !== 'object') {
138
+ names_here.forEach(n => failed.add(n));
139
+ continue;
140
+ }
141
+ const { value: rewritten, applied: file_applied } = rewrite_symbolic_upload_refs(parsed, symbolic_to_id);
142
+ if (file_applied.size === 0)
143
+ continue;
144
+ const raw = dump_yaml(rewritten, { lineWidth: -1 });
145
+ const formatted = await format_file_contents(file_path, raw, workspace_dir, format_options);
146
+ await fs.writeFile(file_path, formatted, 'utf-8');
147
+ for (const key of file_applied)
148
+ applied.add(key);
149
+ }
150
+ catch {
151
+ // parse/marshal/write failure — this file keeps its symbolic ref,
152
+ // so its name(s) must NOT be renamed even if another file succeeded.
153
+ names_here.forEach(n => failed.add(n));
154
+ }
155
+ }
156
+ }
157
+ // Rename on-disk upload files to their canonical (server-suffixed) names,
158
+ // but only for names that were rewritten somewhere AND had no unpersisted
159
+ // reference anywhere. Missing source = already renamed or user-removed; skip.
160
+ const uploads_dir = path.join(site_dir, 'uploads');
161
+ for (const [symbolic, canonical] of rename_for) {
162
+ if (!applied.has(symbolic) || failed.has(symbolic))
163
+ continue;
164
+ try {
165
+ await fs.rename(path.join(uploads_dir, symbolic), path.join(uploads_dir, canonical));
166
+ }
167
+ catch {
168
+ // missing source or permission issue — skip
169
+ }
170
+ }
171
+ }
172
+ // Guard against path traversal / absolute paths in server-supplied upload
173
+ // names. Only accept a plain filename (no separators, no `..`, no leading dot-dot).
174
+ function is_safe_basename(name) {
175
+ if (!name || name === '.' || name === '..')
176
+ return false;
177
+ if (name.includes('/') || name.includes('\\') || name.includes('\0'))
178
+ return false;
179
+ if (path.isAbsolute(name))
180
+ return false;
181
+ return path.basename(name) === name;
182
+ }
183
+ // Recursively collect .yaml files under a directory (dotfiles/dirs skipped).
184
+ async function walk_yaml_files(root) {
185
+ const out = [];
186
+ const stack = [root];
187
+ while (stack.length > 0) {
188
+ const dir = stack.pop();
189
+ let entries;
190
+ try {
191
+ entries = await fs.readdir(dir, { withFileTypes: true });
192
+ }
193
+ catch {
194
+ continue;
195
+ }
196
+ for (const entry of entries) {
197
+ if (entry.name.startsWith('.'))
198
+ continue;
199
+ const full = path.join(dir, entry.name);
200
+ if (entry.isDirectory())
201
+ stack.push(full);
202
+ else if (entry.isFile() && entry.name.endsWith('.yaml'))
203
+ out.push(full);
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+ // Rewrite `upload: "uploads/<file>"` values to the server record id. Mirrors the
209
+ // server's rewriteUploadRefs (and dev.ts's copy). Returns the set of symbolic
210
+ // filenames it actually remapped so the caller can rename exactly those files
211
+ // (and only after the yaml write succeeds). Note this only remaps symbolic
212
+ // paths — a bare id that matches no entry is left as-is (see PR notes on the
213
+ // separate stale-cross-CMS-id repair still needed for already-broken refs).
214
+ function rewrite_symbolic_upload_refs(value, map) {
215
+ const applied = new Set();
216
+ const walk = (v) => {
217
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
218
+ const obj = v;
219
+ const result = {};
220
+ for (const [k, val] of Object.entries(obj)) {
221
+ if (k === 'upload' && typeof val === 'string' && val.startsWith('uploads/')) {
222
+ const symbolic = val.substring('uploads/'.length);
223
+ const id = map.get(symbolic);
224
+ if (id) {
225
+ result[k] = id;
226
+ applied.add(symbolic);
227
+ continue;
228
+ }
229
+ }
230
+ result[k] = walk(val);
231
+ }
232
+ return result;
233
+ }
234
+ if (Array.isArray(v))
235
+ return v.map(walk);
236
+ return v;
237
+ };
238
+ const rewritten = walk(value);
239
+ return { value: rewritten, applied };
240
+ }
241
+ // Subdirs whose yaml may carry `upload: uploads/...` refs. Mirrors the server's
242
+ // import scope (uploads/ itself holds binaries, not refs, so it's excluded).
243
+ const UPLOAD_REF_DIRS = ['blocks', 'page-types', 'pages', 'site'];
42
244
  // Returns the labels (site slugs / 'library') that failed to push so callers
43
245
  // like `primo deploy` can tell a clean run from a partial one. Empty = success.
44
246
  export async function push_site(options) {
@@ -291,6 +493,9 @@ async function push_single_site(site_dir, options, spinner) {
291
493
  console.log('');
292
494
  console.log(chalk.dim(' Site created on server and content uploaded.'));
293
495
  console.log(chalk.dim(' Run `primo login` and re-push to update content later.'));
496
+ // Converge local upload refs/filenames with the ids the server minted,
497
+ // same as the other push paths.
498
+ await apply_upload_writeback(site_dir, await root_dir_for(site_dir), bootstrap_result.created_ids);
294
499
  return;
295
500
  }
296
501
  throw new Error(bootstrap_result.error);
@@ -321,6 +526,9 @@ async function push_single_site(site_dir, options, spinner) {
321
526
  console.log('');
322
527
  console.log(chalk.dim(' Site created on server and content uploaded.'));
323
528
  console.log(chalk.dim(' Subsequent pushes will use the import endpoint.'));
529
+ // Converge local upload refs/filenames with the ids the server minted
530
+ // (see the import path below for why).
531
+ await apply_upload_writeback(site_dir, await root_dir_for(site_dir), bootstrap_result.created_ids);
324
532
  return;
325
533
  }
326
534
  throw new Error(bootstrap_result.error);
@@ -341,6 +549,17 @@ async function push_single_site(site_dir, options, spinner) {
341
549
  spinner.succeed(`Pushed ${label}`);
342
550
  console.log('');
343
551
  print_diff(result.diff);
552
+ // The server mints its own record id for each uploaded file and returns
553
+ // the symbolic->id map in created_ids. Converge the local copy so refs
554
+ // point at the server's id and files carry their canonical names — the
555
+ // same writeback `primo dev` does. Without this, local and server drift,
556
+ // and a symbolic ref baked to a *different* CMS's id dangles forever.
557
+ await apply_upload_writeback(site_dir, await root_dir_for(site_dir), result.created_ids);
558
+ // NOTE: push intentionally does NOT republish the served site. It syncs
559
+ // content into the CMS; regenerating the published output stays a
560
+ // separate, deliberate step (the editor's Publish action / the
561
+ // /api/primo/generate endpoint), so pushing content and choosing when it
562
+ // goes live remain decoupled.
344
563
  }
345
564
  }
346
565
  async function try_bootstrap_site(server, token, zip_buffer, config, site_id, group_name) {
@@ -367,8 +586,10 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id, gr
367
586
  headers,
368
587
  body: form
369
588
  });
370
- if (response.ok)
371
- return { ok: true };
589
+ if (response.ok) {
590
+ const body = await response.json().catch(() => ({}));
591
+ return { ok: true, created_ids: body.created_ids };
592
+ }
372
593
  if (response.status === 403) {
373
594
  return {
374
595
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {