primo-cli 0.1.23 → 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.
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs/promises';
2
+ import net from 'net';
2
3
  import path from 'path';
3
4
  import { randomInt } from 'crypto';
4
5
  import chalk from 'chalk';
@@ -60,6 +61,34 @@ export async function add_site(target, options) {
60
61
  process.exit(1);
61
62
  }
62
63
  let server_config = await read_server_config(base_dir);
64
+ const port = server_config.port ?? parseInt(options.port, 10);
65
+ // `primo add` and `primo dev`'s sites-root watcher both mint a site_id for a
66
+ // newly-appeared folder. If dev is up when we add, the two race: the watcher
67
+ // adopts the folder under its own id while add writes a different id to
68
+ // site.yaml, leaving disk pointing at an id the CMS doesn't have. Refuse
69
+ // while ANYTHING holds the port so there is exactly one minter — the headless
70
+ // import below. This check runs BEFORE ensure_site_config so we never stamp a
71
+ // site_id we'd then strand. `primo dev` picks the folder up on its next start
72
+ // (or its watcher, if it's already been given an id).
73
+ //
74
+ // The guard is TCP occupancy, not a healthy-Primo response: a server that's
75
+ // still starting, an unhealthy Primo, or any other listener all own the port
76
+ // and would make the headless CMS below fail to bind — after we'd already
77
+ // minted. So we only proceed on a confirmed connection refusal (port free);
78
+ // an accepted connection OR an inconclusive probe both count as occupied. A
79
+ // healthy Primo just gets a more specific hint.
80
+ if (await is_port_occupied(port)) {
81
+ if (await is_server_running(port)) {
82
+ console.log(chalk.red(`A Primo server is running on port ${port}.`));
83
+ console.log(chalk.dim(` Stop it (Ctrl+C in its terminal), then re-run \`primo add ${target}\`.`));
84
+ console.log(chalk.dim(' `primo dev` imports the folder itself once it\'s restarted.'));
85
+ }
86
+ else {
87
+ console.log(chalk.red(`Port ${port} is in use, so \`primo add\` can't start a CMS to import into.`));
88
+ console.log(chalk.dim(` Free the port (stop whatever is on it), then re-run \`primo add ${target}\`.`));
89
+ }
90
+ process.exit(1);
91
+ }
63
92
  const { config, created, minted } = await ensure_site_config(site_dir, folder_name);
64
93
  if (created) {
65
94
  console.log(chalk.dim(` created site.yaml (site_id ${config.site_id})`));
@@ -80,62 +109,11 @@ export async function add_site(target, options) {
80
109
  await write_server_config(base_dir, server_config);
81
110
  }
82
111
  }
83
- const port = server_config.port ?? parseInt(options.port, 10);
84
112
  const api_url = `http://127.0.0.1:${port}`;
85
- if (await is_server_running(port)) {
86
- // A dev server is up ask it to discover and import the site, exactly
87
- // like `primo new` does, so its watchers attach too.
88
- const reload = await request_dev_reload(port);
89
- if (reload.status === 'unreachable') {
90
- // Older dev server or hot reload disabled (port+1 in use). Import
91
- // directly against the running CMS — records land, but the dev
92
- // server won't watch this site until it's restarted.
93
- let timings = null;
94
- try {
95
- timings = await register_site(site_dir, api_url, config, port, server_config, base_dir);
96
- }
97
- catch (err) {
98
- const message = err instanceof Error ? err.message : String(err);
99
- console.log(chalk.red(` ✗ ${config.name} could not be imported: ${message}`));
100
- console.log(chalk.dim(` Fix the problem, then re-run \`primo add ${target}\`.`));
101
- process.exit(1);
102
- }
103
- report_import(config.name, timings);
104
- console.log(chalk.yellow(' The running dev server couldn\'t be reloaded — restart `primo dev` to watch this site.'));
105
- console.log('');
106
- return;
107
- }
108
- // The health check only proves *a* Primo server is on this port — it
109
- // could belong to a different workspace. The reload response's `known`
110
- // list is the only reliable way to tell: PocketBase 404s record reads
111
- // pre-setup even when the record exists, so we can't just probe the
112
- // site_id. (Older dev servers don't send `known` — trust the reload.)
113
- const known = reload.known?.find(site => site.site_id === config.site_id);
114
- if (reload.known && !known) {
115
- console.log(chalk.red(` A Primo server is running on port ${port}, but it isn't serving this workspace.`));
116
- console.log(chalk.dim(' Stop it (or start `primo dev` in this workspace) and re-run `primo add`.'));
117
- process.exit(1);
118
- }
119
- if (known?.blocked || reload.quarantined.includes(config.name)) {
120
- console.log(chalk.yellow(` ${config.name} was found, but the dev server couldn't import it (e.g. duplicate IDs or a missing pages/index.yaml).`));
121
- console.log(chalk.dim(' Check the `primo dev` logs, fix the problem, then re-run `primo add`.'));
122
- process.exit(1);
123
- }
124
- console.log('');
125
- if (reload.loaded === 0 && reload.known) {
126
- console.log(` ${chalk.cyan(config.name)} is already registered with the running dev server.`);
127
- }
128
- else {
129
- console.log(chalk.green(` ✓ ${config.name} registered`));
130
- }
131
- const host = local_dev_host(config.name, port);
132
- console.log(` ${chalk.dim('Edit:')} http://${host}/admin/site`);
133
- console.log(` ${chalk.dim('Preview:')} http://${host}/`);
134
- console.log('');
135
- return;
136
- }
137
- // No dev server running: boot the CMS binary headlessly against the
138
- // workspace database, import, and shut it back down.
113
+ // No server is running (guaranteed — we refused above if one held the port),
114
+ // so boot the CMS binary headlessly against the workspace database, import,
115
+ // and shut it back down. This is the single minter/import path, which is why
116
+ // `primo add` and the dev watcher can't double-mint the same folder.
139
117
  const spinner = ora('Starting CMS for one-time import...').start();
140
118
  const binary_path = await ensure_binary();
141
119
  const data_dir = await ensure_data_dir(base_dir);
@@ -202,14 +180,6 @@ async function register_site(site_dir, api_url, config, port, server_config, bas
202
180
  const use_bootstrap = !await site_exists(api_url, config.site_id);
203
181
  return await import_site_files(site_dir, api_url, config, port, server_config, use_bootstrap, base_dir);
204
182
  }
205
- function report_import(site_name, timings) {
206
- if (timings === null || !timings.ok) {
207
- console.log(chalk.red(` ✗ ${site_name} could not be imported — see the errors above.`));
208
- process.exit(1);
209
- }
210
- console.log(chalk.green(` ✓ ${site_name} registered (${timings.mode})`));
211
- report_warnings(timings);
212
- }
213
183
  function report_warnings(timings) {
214
184
  if (timings.warning_count > 0) {
215
185
  console.log(chalk.yellow(` ⚠ imported with ${timings.warning_count} warning${timings.warning_count === 1 ? '' : 's'} (details above)`));
@@ -291,43 +261,29 @@ async function ensure_site_config(site_dir, folder_name) {
291
261
  }
292
262
  return { config, created, minted };
293
263
  }
294
- async function request_dev_reload(port) {
295
- try {
296
- // Bound the request: the reload handler runs discovery + import
297
- // synchronously before responding.
298
- const controller = new AbortController();
299
- const timeout = setTimeout(() => controller.abort(), 30000);
300
- let res;
301
- try {
302
- res = await fetch(`http://127.0.0.1:${port + 1}/reload`, {
303
- method: 'POST',
304
- signal: controller.signal
305
- });
306
- }
307
- finally {
308
- clearTimeout(timeout);
309
- }
310
- if (!res.ok)
311
- return { status: 'unreachable' };
312
- const result = await res.json().catch(() => null);
313
- if (!result)
314
- return { status: 'unreachable' };
315
- return {
316
- status: 'ok',
317
- loaded: result.loaded ?? 0,
318
- quarantined: result.quarantined ?? [],
319
- known: Array.isArray(result.known) ? result.known : undefined
264
+ // True when something is listening on the port — the guard the headless import
265
+ // actually depends on (the CMS below can't bind an occupied port). Distinct from
266
+ // is_server_running, which only reports whether a *healthy Primo* answered: a
267
+ // starting/unhealthy server or a foreign listener returns false there but still
268
+ // owns the port. Fail safe — an accepted connection means occupied, and any
269
+ // inconclusive result (timeout, unexpected error) is treated as occupied too, so
270
+ // we never mint a site_id ahead of a bind we can't actually make. Only an
271
+ // explicit connection refusal (ECONNREFUSED) counts as free.
272
+ async function is_port_occupied(port) {
273
+ return new Promise(resolve => {
274
+ const socket = new net.Socket();
275
+ const done = (occupied) => {
276
+ socket.destroy();
277
+ resolve(occupied);
320
278
  };
321
- }
322
- catch {
323
- return { status: 'unreachable' };
324
- }
325
- }
326
- // Mirror of dev.ts's local_dev_host so the links printed here match the ones
327
- // `primo dev` prints for the same site.
328
- function local_dev_host(name, port) {
329
- const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'site';
330
- return `${slug}.localhost:${port}`;
279
+ socket.setTimeout(1000);
280
+ socket.once('connect', () => done(true));
281
+ socket.once('timeout', () => done(true));
282
+ socket.once('error', (err) => {
283
+ done(err.code !== 'ECONNREFUSED');
284
+ });
285
+ socket.connect(port, '127.0.0.1');
286
+ });
331
287
  }
332
288
  async function is_server_running(port) {
333
289
  try {
@@ -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.23",
3
+ "version": "0.1.25",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {