primo-cli 0.1.23 → 0.1.24

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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {