primo-cli 0.1.20 → 0.1.23

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.
@@ -0,0 +1,6 @@
1
+ interface AddOptions {
2
+ dir: string;
3
+ port: string;
4
+ }
5
+ export declare function add_site(target: string, options: AddOptions): Promise<void>;
6
+ export {};
@@ -0,0 +1,345 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { randomInt } from 'crypto';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ import { spawn } from 'child_process';
7
+ import { ensure_binary, ensure_data_dir } from '../utils/binary.js';
8
+ import { read_site_config, write_site_config } from '../utils/site-config.js';
9
+ import { SERVER_CONFIG_FILE, read_server_config, write_server_config } from '../utils/server-config.js';
10
+ import { normalize_site } from './validate.js';
11
+ import { import_site_files, site_exists, wait_for_ready, kill_process } from './dev.js';
12
+ const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
13
+ function generate_id() {
14
+ let id = '';
15
+ for (let i = 0; i < 15; i++) {
16
+ id += ID_ALPHABET[randomInt(ID_ALPHABET.length)];
17
+ }
18
+ return id;
19
+ }
20
+ // Mirror of new.ts's display-name derivation so `primo add maison-verde`
21
+ // and `primo new maison-verde` produce the same site name.
22
+ function derive_display_name(folder_name) {
23
+ return folder_name.includes('.')
24
+ ? folder_name.split('.')[0].charAt(0).toUpperCase() + folder_name.split('.')[0].slice(1)
25
+ : folder_name.charAt(0).toUpperCase() + folder_name.slice(1).replace(/-/g, ' ');
26
+ }
27
+ // Register an existing sites/<name> folder with the workspace CMS: ensure
28
+ // site.yaml has a site_id, then import the site's records into the workspace
29
+ // database — via the running dev server when there is one, or a short-lived
30
+ // headless CMS process otherwise. One-time action; exits when done.
31
+ export async function add_site(target, options) {
32
+ const base_dir = path.resolve(options.dir);
33
+ try {
34
+ await fs.access(path.join(base_dir, SERVER_CONFIG_FILE));
35
+ }
36
+ catch {
37
+ console.log(chalk.red(`No ${SERVER_CONFIG_FILE} found in ${base_dir}.`));
38
+ console.log(chalk.dim('Run `primo add` from the workspace root, or pass --dir <workspace>.'));
39
+ process.exit(1);
40
+ }
41
+ const sites_root = path.join(base_dir, 'sites');
42
+ const site_dir = resolve_site_dir(base_dir, sites_root, target);
43
+ const folder_name = path.basename(site_dir);
44
+ let stat;
45
+ try {
46
+ stat = await fs.stat(site_dir);
47
+ }
48
+ catch {
49
+ console.log(chalk.red(`sites/${folder_name} not found.`));
50
+ console.log(chalk.dim('Create the folder first, or use `primo new` to scaffold a fresh site.'));
51
+ process.exit(1);
52
+ }
53
+ if (!stat.isDirectory()) {
54
+ console.log(chalk.red(`sites/${folder_name} is not a directory.`));
55
+ process.exit(1);
56
+ }
57
+ if (!await looks_like_site(site_dir)) {
58
+ console.log(chalk.red(`sites/${folder_name} doesn't look like a Primo site.`));
59
+ console.log(chalk.dim('Expected at least one of: site.yaml, pages/, blocks/, page-types/, site/'));
60
+ process.exit(1);
61
+ }
62
+ let server_config = await read_server_config(base_dir);
63
+ const { config, created, minted } = await ensure_site_config(site_dir, folder_name);
64
+ if (created) {
65
+ console.log(chalk.dim(` created site.yaml (site_id ${config.site_id})`));
66
+ }
67
+ else if (minted) {
68
+ console.log(chalk.dim(` stamped site_id ${config.site_id} into site.yaml`));
69
+ }
70
+ // `primo new` guarantees the default group exists in server.yaml; when we
71
+ // assign group: default ourselves, give it the same guarantee so the
72
+ // dashboard doesn't invent an ad-hoc group id for it.
73
+ if (config.group === 'default') {
74
+ const site_groups = server_config.site_groups ?? [];
75
+ if (!site_groups.some((group) => group.id === 'default')) {
76
+ server_config = {
77
+ ...server_config,
78
+ site_groups: [...site_groups, { id: 'default', name: 'Default', index: site_groups.length }]
79
+ };
80
+ await write_server_config(base_dir, server_config);
81
+ }
82
+ }
83
+ const port = server_config.port ?? parseInt(options.port, 10);
84
+ 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.
139
+ const spinner = ora('Starting CMS for one-time import...').start();
140
+ const binary_path = await ensure_binary();
141
+ const data_dir = await ensure_data_dir(base_dir);
142
+ const cms_process = spawn(binary_path, ['serve', '--http', `127.0.0.1:${port}`, '--dir', data_dir], {
143
+ stdio: ['pipe', 'pipe', 'pipe'],
144
+ env: { ...process.env, PRIMO_DEV_MODE: '1', PRIMO_AUTHOR_MODE: 'files' }
145
+ });
146
+ let stderr_output = '';
147
+ cms_process.stderr?.on('data', (data) => {
148
+ stderr_output += data.toString();
149
+ });
150
+ let timings = null;
151
+ let boot_failed = false;
152
+ let import_error = null;
153
+ try {
154
+ const ready = await wait_for_ready(api_url, 30000);
155
+ // A ready health check only proves *something* answered on the port.
156
+ // If our child lost the bind race to another CMS and exited, that
157
+ // other server is answering — importing would land this site's
158
+ // records in a different workspace's database. Require our own
159
+ // process to still be alive before trusting the port.
160
+ if (!ready || cms_process.exitCode !== null || cms_process.killed) {
161
+ boot_failed = true;
162
+ }
163
+ else {
164
+ spinner.text = `Importing ${config.name}...`;
165
+ try {
166
+ timings = await register_site(site_dir, api_url, config, port, server_config, base_dir);
167
+ }
168
+ catch (err) {
169
+ import_error = err instanceof Error ? err.message : String(err);
170
+ }
171
+ }
172
+ }
173
+ finally {
174
+ // Always tear the CMS down before exiting — process.exit skips
175
+ // finally blocks, so no exit() may appear inside this try.
176
+ await kill_process(cms_process);
177
+ }
178
+ if (boot_failed) {
179
+ spinner.fail('CMS failed to start');
180
+ if (stderr_output)
181
+ console.log(chalk.red(stderr_output));
182
+ process.exit(1);
183
+ }
184
+ if (import_error !== null) {
185
+ spinner.fail(`${config.name} could not be imported: ${import_error}`);
186
+ console.log(chalk.dim(` Fix the problem, then re-run \`primo add ${target}\`.`));
187
+ process.exit(1);
188
+ }
189
+ if (timings === null || !timings.ok) {
190
+ // Duplicate _ids or a failed bootstrap+import — details already printed.
191
+ spinner.fail(`${config.name} could not be imported — see the errors above.`);
192
+ process.exit(1);
193
+ }
194
+ spinner.succeed(`${config.name} registered (${timings.mode})`);
195
+ report_warnings(timings);
196
+ console.log('');
197
+ console.log(chalk.dim(' Run `primo dev` to serve it.'));
198
+ console.log('');
199
+ }
200
+ async function register_site(site_dir, api_url, config, port, server_config, base_dir) {
201
+ await normalize_site(site_dir);
202
+ const use_bootstrap = !await site_exists(api_url, config.site_id);
203
+ return await import_site_files(site_dir, api_url, config, port, server_config, use_bootstrap, base_dir);
204
+ }
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
+ function report_warnings(timings) {
214
+ if (timings.warning_count > 0) {
215
+ console.log(chalk.yellow(` ⚠ imported with ${timings.warning_count} warning${timings.warning_count === 1 ? '' : 's'} (details above)`));
216
+ }
217
+ }
218
+ function resolve_site_dir(base_dir, sites_root, target) {
219
+ // A bare name resolves under sites/; anything with a separator is taken
220
+ // as a path. Either way the result must be directly under sites/ — that's
221
+ // the only directory site discovery and the dashboard scan, so registering
222
+ // a folder anywhere else would import records no later `primo dev` run
223
+ // can match back to files.
224
+ const resolved = !target.includes('/') && !target.includes(path.sep)
225
+ ? path.resolve(sites_root, target)
226
+ : path.resolve(base_dir, target);
227
+ if (path.dirname(resolved) !== sites_root) {
228
+ console.log(chalk.red(`Sites must live directly under sites/ — got "${target}".`));
229
+ process.exit(1);
230
+ }
231
+ return resolved;
232
+ }
233
+ async function looks_like_site(site_dir) {
234
+ for (const marker of ['site.yaml', 'pages', 'blocks', 'page-types', 'site']) {
235
+ try {
236
+ await fs.stat(path.join(site_dir, marker));
237
+ return true;
238
+ }
239
+ catch {
240
+ // keep looking
241
+ }
242
+ }
243
+ return false;
244
+ }
245
+ // Make sure site.yaml exists with a name and a site_id, minting what's
246
+ // missing. The site_id is the registration key: discovery, import, and the
247
+ // dashboard all match records by it, so a folder without one can never come
248
+ // online no matter how many times the dev server scans it.
249
+ async function ensure_site_config(site_dir, folder_name) {
250
+ let existing = null;
251
+ try {
252
+ const parsed = await read_site_config(site_dir);
253
+ if (parsed && typeof parsed === 'object') {
254
+ existing = parsed;
255
+ }
256
+ else if (parsed !== null && parsed !== undefined) {
257
+ // site.yaml exists but isn't a mapping (e.g. a bare string).
258
+ // Overwriting it would silently discard whatever the user meant
259
+ // to keep — an empty file is the only non-mapping we fill in.
260
+ console.log(chalk.red(`sites/${folder_name}/site.yaml is malformed (expected key: value mappings).`));
261
+ console.log(chalk.dim('Fix or delete it, then re-run `primo add`.'));
262
+ process.exit(1);
263
+ }
264
+ }
265
+ catch (error) {
266
+ if (error.code !== 'ENOENT') {
267
+ // Unreadable or unparseable site.yaml — never overwrite it with a
268
+ // fresh config; that would mint a new site_id and drop the user's
269
+ // server/group for a site that may already be registered.
270
+ const message = error instanceof Error ? error.message : String(error);
271
+ console.log(chalk.red(`sites/${folder_name}/site.yaml could not be read: ${message}`));
272
+ console.log(chalk.dim('Fix or delete it, then re-run `primo add`.'));
273
+ process.exit(1);
274
+ }
275
+ // ENOENT — no site.yaml yet; create one below.
276
+ }
277
+ const created = existing === null;
278
+ const config = existing ?? { group: 'default' };
279
+ let changed = created;
280
+ if (!config.name) {
281
+ config.name = derive_display_name(folder_name);
282
+ changed = true;
283
+ }
284
+ const minted = !config.site_id;
285
+ if (minted) {
286
+ config.site_id = generate_id();
287
+ changed = true;
288
+ }
289
+ if (changed) {
290
+ await write_site_config(site_dir, config);
291
+ }
292
+ return { config, created, minted };
293
+ }
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
320
+ };
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}`;
331
+ }
332
+ async function is_server_running(port) {
333
+ try {
334
+ const controller = new AbortController();
335
+ const timeout = setTimeout(() => controller.abort(), 1000);
336
+ const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
337
+ signal: controller.signal
338
+ });
339
+ clearTimeout(timeout);
340
+ return response.ok;
341
+ }
342
+ catch {
343
+ return false;
344
+ }
345
+ }
@@ -48,6 +48,18 @@ export async function build_site(options) {
48
48
  }
49
49
  // No head.svelte, that's fine
50
50
  }
51
+ // site/foot.html is verbatim HTML appended before </body> on every page —
52
+ // no templating, matching server publish. page-types/*/foot.html is
53
+ // intentionally not included (it isn't in server publish either).
54
+ let foot_content = '';
55
+ try {
56
+ foot_content = await fs.readFile(path.join(site_dir, 'site', 'foot.html'), 'utf-8');
57
+ }
58
+ catch (error) {
59
+ if (error?.code !== 'ENOENT') {
60
+ throw error;
61
+ }
62
+ }
51
63
  // Find all pages
52
64
  const pages_dir = path.join(site_dir, 'pages');
53
65
  const page_files = await find_pages(pages_dir);
@@ -78,6 +90,7 @@ export async function build_site(options) {
78
90
  site_dir,
79
91
  temp_dir,
80
92
  head_content,
93
+ foot_content,
81
94
  site_name: config.name,
82
95
  block_cache,
83
96
  layout_cache,
@@ -125,6 +138,23 @@ export async function build_site(options) {
125
138
  process.exit(1);
126
139
  }
127
140
  }
141
+ // Field keys exposed to head fragments as bare identifiers. Anything that
142
+ // can't be a `let` binding (or would collide with the page component's own
143
+ // props) is skipped — the field just isn't available in head scope.
144
+ const RESERVED_HEAD_KEYS = new Set([
145
+ 'arguments', 'eval', 'implements', 'interface', 'package', 'private', 'protected', 'public',
146
+ 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default',
147
+ 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for',
148
+ 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'null', 'return',
149
+ 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var',
150
+ 'void', 'while', 'with', 'yield', 'await', 'head_props'
151
+ ]);
152
+ function head_identifier_keys(keys) {
153
+ return [...new Set(keys)].filter((key) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) &&
154
+ !RESERVED_HEAD_KEYS.has(key) &&
155
+ !/^section_\d+_props$/.test(key) &&
156
+ !/^Section_\d+/.test(key));
157
+ }
128
158
  // Lazily load and validate a page type's head.svelte. Cached value is the raw
129
159
  // fragment (may include <style>); empty string means "no head file present".
130
160
  async function load_page_type_head(site_dir, page_type, cache) {
@@ -146,7 +176,7 @@ async function load_page_type_head(site_dir, page_type, cache) {
146
176
  return content;
147
177
  }
148
178
  async function build_page(options) {
149
- const { page, page_path, site_dir, temp_dir, head_content, site_name, block_cache, layout_cache, page_type_head_cache, site_data, page_url_map } = options;
179
+ const { page, page_path, site_dir, temp_dir, head_content, foot_content, site_name, block_cache, layout_cache, page_type_head_cache, site_data, page_url_map } = options;
150
180
  try {
151
181
  const page_build_id = safe_temp_id(page._id || page.id || page_path || page.name || 'page');
152
182
  // Load layout for this page type
@@ -164,11 +194,19 @@ async function build_page(options) {
164
194
  const header_sections = await resolve_layout_sections(layout.header || [], site_dir, site_data, page_url_map);
165
195
  const footer_sections = await resolve_layout_sections(layout.footer || [], site_dir, site_data, page_url_map);
166
196
  const page_sections = await resolve_page_sections(page.sections || [], site_dir, site_data, page_url_map);
167
- const all_sections = [...header_sections, ...page_sections, ...footer_sections];
168
- if (all_sections.length === 0) {
169
- return { html: generate_empty_page(site_name, page.name, combined_head_content) };
170
- }
171
- const sections = all_sections;
197
+ const sections = [...header_sections, ...page_sections, ...footer_sections];
198
+ // Head fragments see site fields merged with the page's own fields (page
199
+ // wins), each pre-declared as a bare identifier — same scope as server
200
+ // publish. Every DEFINED field key is declared even when no value is set
201
+ // (binding to undefined), so `{seo_title || fallback}` works on pages
202
+ // that leave the field empty instead of throwing ReferenceError.
203
+ const head_data = { ...site_data.content, ...(page.fields || {}) };
204
+ const page_type_fields = await load_page_type_fields(site_dir, page_type);
205
+ const head_keys = head_identifier_keys([
206
+ ...site_data.fields.map((field) => field.name),
207
+ ...page_type_fields.map((field) => field.name),
208
+ ...Object.keys(head_data)
209
+ ]);
172
210
  // Compile each block and collect CSS
173
211
  const all_css = [];
174
212
  const section_components = [];
@@ -192,8 +230,11 @@ async function build_page(options) {
192
230
  props: section.content || {}
193
231
  });
194
232
  }
195
- // Create a page component that renders all sections
196
- const page_component = generate_page_component(section_components, sections);
233
+ // Create a page component that renders all sections and the head. The
234
+ // head rides through <svelte:head> so its Svelte syntax ({expression},
235
+ // {@html}, {#if}) is actually evaluated — pasting the fragment into the
236
+ // output verbatim leaked raw template syntax into deployed pages.
237
+ const page_component = generate_page_component(section_components, sections, combined_head_content, head_keys);
197
238
  const page_component_path = path.join(temp_dir, `page_${page_build_id}.svelte`);
198
239
  await fs.writeFile(page_component_path, page_component);
199
240
  // Compile the page component
@@ -244,43 +285,36 @@ async function build_page(options) {
244
285
  const { default: PageComponent } = await import(bundle_url);
245
286
  // Import render from svelte/server
246
287
  const { render } = await import('svelte/server');
247
- // Build props for all sections
288
+ // Build props for all sections + the head fragment's field data
248
289
  const props = {};
249
290
  sections.forEach((section, i) => {
250
291
  props[`section_${i}_props`] = section.content || {};
251
292
  });
293
+ props.head_props = head_data;
252
294
  const rendered = render(PageComponent, { props });
253
- // Extract CSS from combined head fragments (site + page-type).
254
- let head_css = '';
255
- let head_html = combined_head_content;
256
- const style_matches = combined_head_content.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi);
257
- const head_css_parts = [];
258
- for (const m of style_matches) {
259
- head_css_parts.push(m[1]);
260
- }
261
- if (head_css_parts.length > 0) {
262
- head_css = head_css_parts.join('\n');
263
- head_html = combined_head_content.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
295
+ // No automatic <title> server publish emits none, and an injected
296
+ // title would suppress any title a page-type head renders (Svelte keeps
297
+ // the first <title> it encounters). Warn so the omission is visible.
298
+ if (!/<title[\s>]/i.test(rendered.head || '')) {
299
+ console.log(chalk.yellow(` Warning: ${page.name || page_path || 'home'}: no <title> — render one from a head fragment (see the head-and-seo doc)`));
264
300
  }
265
- // Combine all CSS (reset first, then head, then blocks)
266
- const combined_css = [CSS_RESET, head_css, ...all_css].filter(Boolean).join('\n');
267
- // Generate final HTML
268
- const title = page.name === 'Home' ? site_name : `${page.name} | ${site_name}`;
301
+ // Head <style> tags flow through rendered.head as real global style
302
+ // elements (matching server publish). Reset first so head styles can
303
+ // override it; block CSS last, as component styles land during render.
304
+ const block_css = all_css.filter(Boolean).join('\n');
269
305
  const html = `<!DOCTYPE html>
270
306
  <html lang="en">
271
307
  <head>
272
308
  <meta charset="UTF-8">
273
309
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
274
- <title>${escape_html(title)}</title>
275
- ${head_html}
276
310
  <style>
277
- ${combined_css}
311
+ ${CSS_RESET}
278
312
  </style>
279
313
  ${rendered.head || ''}
280
- </head>
314
+ ${block_css ? ` <style>\n${block_css}\n </style>\n` : ''}</head>
281
315
  <body>
282
316
  ${rendered.body || ''}
283
- </body>
317
+ ${foot_content}</body>
284
318
  </html>`;
285
319
  return { html };
286
320
  }
@@ -315,14 +349,18 @@ async function compile_block(site_dir, block_name, temp_dir) {
315
349
  return { js: '', css: '' };
316
350
  }
317
351
  }
318
- function generate_page_component(components, sections) {
352
+ function generate_page_component(components, sections, head_content, head_keys) {
319
353
  const imports = sections.map((section, i) => {
320
354
  const safe_name = section.block.replace(/-/g, '_');
321
355
  return `import Section_${i} from './${section.block}.compiled.js'`;
322
356
  }).join('\n');
323
- const props_declarations = sections.map((_, i) => {
324
- return `section_${i}_props = {}`;
325
- }).join(',\n\t');
357
+ const props_declarations = [
358
+ ...sections.map((_, i) => `section_${i}_props = {}`),
359
+ 'head_props = {}'
360
+ ].join(',\n\t');
361
+ // Bare identifiers for head scope; keys are pre-filtered to valid, safe
362
+ // binding names by head_identifier_keys.
363
+ const head_declarations = head_keys.map((key) => `let ${key} = head_props['${key}']`).join('\n');
326
364
  const section_renders = sections.map((_, i) => {
327
365
  return `<Section_${i} {...section_${i}_props} />`;
328
366
  }).join('\n\t\t');
@@ -334,27 +372,17 @@ ${imports}
334
372
  let {
335
373
  ${props_declarations}
336
374
  } = $props()
375
+ ${head_declarations}
337
376
  </script>
338
377
 
378
+ <svelte:head>
379
+ ${head_content}
380
+ </svelte:head>
381
+
339
382
  <main>
340
383
  ${section_renders}
341
384
  </main>`;
342
385
  }
343
- function generate_empty_page(site_name, page_name, head_content) {
344
- const title = page_name === 'Home' ? site_name : `${page_name} | ${site_name}`;
345
- return `<!DOCTYPE html>
346
- <html lang="en">
347
- <head>
348
- <meta charset="UTF-8">
349
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
350
- <title>${escape_html(title)}</title>
351
- <style>${CSS_RESET}</style>
352
- ${head_content}
353
- </head>
354
- <body>
355
- </body>
356
- </html>`;
357
- }
358
386
  function generate_error_page(site_name, page_name, error, head_content) {
359
387
  const title = page_name === 'Home' ? site_name : `${page_name} | ${site_name}`;
360
388
  // Extract just the CSS from head_content
@@ -590,6 +618,22 @@ async function load_block_fields(site_dir, block_name) {
590
618
  return [];
591
619
  }
592
620
  }
621
+ const page_type_fields_cache = new Map();
622
+ async function load_page_type_fields(site_dir, page_type) {
623
+ const cache_key = `${site_dir}:${page_type}`;
624
+ const cached = page_type_fields_cache.get(cache_key);
625
+ if (cached !== undefined)
626
+ return cached;
627
+ let fields = [];
628
+ try {
629
+ fields = extract_fields_array(await load_fields_file(path.join(site_dir, 'page-types', page_type, 'fields.yaml')));
630
+ }
631
+ catch {
632
+ // No fields file for this page type
633
+ }
634
+ page_type_fields_cache.set(cache_key, fields);
635
+ return fields;
636
+ }
593
637
  async function resolve_site_fields(site_dir, block_name, content, site_data) {
594
638
  // Load block field definitions to check for site-field types
595
639
  const block_fields = await load_block_fields(site_dir, block_name);
@@ -1,8 +1,32 @@
1
+ import { type ChildProcess } from 'child_process';
2
+ import { type SiteConfig } from '../utils/site-config.js';
3
+ import { type ServerConfig } from '../utils/server-config.js';
1
4
  interface DevOptions {
2
5
  dir: string;
3
6
  port: string;
4
7
  force?: boolean;
5
8
  author?: string;
6
9
  }
10
+ export type ImportTimings = {
11
+ zip_ms: number;
12
+ request_ms: number;
13
+ mode: 'bootstrap' | 'import' | 'bootstrap+import';
14
+ ok: boolean;
15
+ warning_count: number;
16
+ dropped_field_count: number;
17
+ warning_details: ImportWarning[];
18
+ };
19
+ export declare function kill_process(proc: ChildProcess): Promise<void>;
7
20
  export declare function dev_server(options: DevOptions): Promise<void>;
21
+ export declare function wait_for_ready(url: string, timeout_ms: number): Promise<boolean>;
22
+ export declare function site_exists(api_url: string, site_id: string): Promise<boolean>;
23
+ export type ImportWarning = {
24
+ kind: string;
25
+ file: string;
26
+ path: string;
27
+ field: string;
28
+ block: string;
29
+ message: string;
30
+ };
31
+ export declare function import_site_files(site_dir: string, api_url: string, config: SiteConfig, port: number, server_config: ServerConfig, use_bootstrap?: boolean, workspace_dir?: string): Promise<ImportTimings | null>;
8
32
  export {};
@@ -554,7 +554,7 @@ async function fetch_with_timeout(url, options = {}, timeout_ms = 10000) {
554
554
  }
555
555
  }
556
556
  // Kill process with escalation to SIGKILL
557
- async function kill_process(proc) {
557
+ export async function kill_process(proc) {
558
558
  if (!proc || proc.killed)
559
559
  return;
560
560
  proc.kill('SIGTERM');
@@ -622,6 +622,13 @@ export async function dev_server(options) {
622
622
  // Single site mode
623
623
  try {
624
624
  const config = await read_site_config(base_dir);
625
+ if (typeof config.site_id !== 'string' || !config.site_id.trim()) {
626
+ // Without a site_id the import posts site_id=undefined and
627
+ // fails cryptically — stop with the actual fix instead.
628
+ spinner.fail(`${SITE_CONFIG_FILE} has no site_id — this site was never registered.`);
629
+ console.log(chalk.dim(` Run \`primo add ${path.basename(base_dir)}\` from the workspace root to register it.`));
630
+ process.exit(1);
631
+ }
625
632
  sites = [{ dir: base_dir, config }];
626
633
  }
627
634
  catch {
@@ -792,8 +799,8 @@ export async function dev_server(options) {
792
799
  }
793
800
  schedule_library_push();
794
801
  };
795
- const on_event = (full_path) => {
796
- if (should_skip_synced_delete(full_path))
802
+ const on_event = (event, full_path) => {
803
+ if (should_skip_synced_delete(full_path, event))
797
804
  return;
798
805
  if (synced_files.has(full_path)) {
799
806
  // We last wrote this file from a CMS pull. If the
@@ -876,11 +883,11 @@ export async function dev_server(options) {
876
883
  }
877
884
  }, LOCAL_PUSH_DEBOUNCE_MS);
878
885
  };
879
- watcher.on('add', on_event);
880
- watcher.on('change', on_event);
881
- watcher.on('unlink', on_event);
882
- watcher.on('addDir', on_event);
883
- watcher.on('unlinkDir', on_event);
886
+ watcher.on('add', p => on_event('add', p));
887
+ watcher.on('change', p => on_event('change', p));
888
+ watcher.on('unlink', p => on_event('unlink', p));
889
+ watcher.on('addDir', p => on_event('addDir', p));
890
+ watcher.on('unlinkDir', p => on_event('unlinkDir', p));
884
891
  watchers.push(watcher);
885
892
  }
886
893
  catch {
@@ -1018,8 +1025,8 @@ export async function dev_server(options) {
1018
1025
  mark_pending_local_change();
1019
1026
  continue_event(full_path);
1020
1027
  };
1021
- const on_event = (full_path) => {
1022
- if (should_skip_synced_delete(full_path))
1028
+ const on_event = (event, full_path) => {
1029
+ if (should_skip_synced_delete(full_path, event))
1023
1030
  return;
1024
1031
  if (synced_files.has(full_path)) {
1025
1032
  // We last wrote this file from a CMS pull. Compare
@@ -1045,11 +1052,11 @@ export async function dev_server(options) {
1045
1052
  }
1046
1053
  push_or_ignore_file_change(full_path);
1047
1054
  };
1048
- watcher.on('add', on_event);
1049
- watcher.on('change', on_event);
1050
- watcher.on('unlink', on_event);
1051
- watcher.on('addDir', on_event);
1052
- watcher.on('unlinkDir', on_event);
1055
+ watcher.on('add', p => on_event('add', p));
1056
+ watcher.on('change', p => on_event('change', p));
1057
+ watcher.on('unlink', p => on_event('unlink', p));
1058
+ watcher.on('addDir', p => on_event('addDir', p));
1059
+ watcher.on('unlinkDir', p => on_event('unlinkDir', p));
1053
1060
  watchers.push(watcher);
1054
1061
  }
1055
1062
  catch {
@@ -1078,37 +1085,52 @@ export async function dev_server(options) {
1078
1085
  continue;
1079
1086
  known_sites.add(site.dir);
1080
1087
  sites.push(site);
1081
- const use_bootstrap = !await site_exists(api_url, site.config.site_id);
1082
1088
  let import_ok = true;
1083
- if (sync_policy.mode === 'cms' && !use_bootstrap) {
1084
- await sync_from_cms(site.dir, api_url, site.config, server_config, base_dir, sync_policy);
1085
- }
1086
- else {
1087
- await normalize_site(site.dir);
1088
- 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));
1089
- if (import_timings === null) {
1090
- // Duplicate _ids on a freshly discovered site — quarantine
1091
- // it from CMS→file polling until a later import succeeds.
1092
- blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
1093
- quarantined.push(site.config.name);
1094
- import_ok = false;
1089
+ try {
1090
+ const use_bootstrap = !await site_exists(api_url, site.config.site_id);
1091
+ if (sync_policy.mode === 'cms' && !use_bootstrap) {
1092
+ await sync_from_cms(site.dir, api_url, site.config, server_config, base_dir, sync_policy);
1095
1093
  }
1096
1094
  else {
1097
- await write_import_sync_status(site.dir, import_timings, dev_location);
1098
- if (!import_timings.ok) {
1099
- // Bootstrap + fallback both failed (only logged). Quarantine
1100
- // and skip the success path so we don't announce a site that
1101
- // didn't actually load and so `primo new` sees it in the
1102
- // /reload body's `quarantined` list.
1095
+ await normalize_site(site.dir);
1096
+ 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));
1097
+ if (import_timings === null) {
1098
+ // Duplicate _ids on a freshly discovered site quarantine
1099
+ // it from CMS→file polling until a later import succeeds.
1103
1100
  blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
1104
1101
  quarantined.push(site.config.name);
1105
1102
  import_ok = false;
1106
1103
  }
1107
- else if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
1108
- await update_site_sync_baseline(site, api_url, server_config, base_dir);
1104
+ else {
1105
+ await write_import_sync_status(site.dir, import_timings, dev_location);
1106
+ if (!import_timings.ok) {
1107
+ // Bootstrap + fallback both failed (only logged). Quarantine
1108
+ // and skip the success path so we don't announce a site that
1109
+ // didn't actually load — and so `primo new` sees it in the
1110
+ // /reload body's `quarantined` list.
1111
+ blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
1112
+ quarantined.push(site.config.name);
1113
+ import_ok = false;
1114
+ }
1115
+ else if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
1116
+ await update_site_sync_baseline(site, api_url, server_config, base_dir);
1117
+ }
1109
1118
  }
1110
1119
  }
1111
1120
  }
1121
+ catch (err) {
1122
+ // A malformed site (e.g. missing pages/index.yaml, which makes
1123
+ // normalize_site throw) must not take the whole dev server down:
1124
+ // before this guard, the throw escaped the handler as an
1125
+ // unhandledRejection and tripped process-level cleanup().
1126
+ // Quarantine the site and keep serving; the watcher retries it
1127
+ // on the next file change.
1128
+ blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
1129
+ quarantined.push(site.config.name);
1130
+ import_ok = false;
1131
+ const message = err instanceof Error ? err.message : String(err);
1132
+ console.log(chalk.red(` ✗ ${site.config.name}: ${message}`));
1133
+ }
1112
1134
  setup_site_watchers(site);
1113
1135
  loaded_count++;
1114
1136
  if (!import_ok) {
@@ -1124,9 +1146,18 @@ export async function dev_server(options) {
1124
1146
  // just scaffolded actually imported. A quarantined site (duplicate
1125
1147
  // _ids) returns 200 with loaded:false so a 2xx no longer implies
1126
1148
  // success — the caller checks the body, not just the status.
1149
+ // `known` lists every site this server tracks so `primo add` can
1150
+ // tell "already registered here" from "this server is serving a
1151
+ // different workspace" — record-level reads can't make that call
1152
+ // (PocketBase 404s them pre-setup even when the record exists).
1127
1153
  const body = JSON.stringify({
1128
1154
  loaded: loaded_count,
1129
- quarantined
1155
+ quarantined,
1156
+ known: sites.map(site => ({
1157
+ name: site.config.name,
1158
+ site_id: site.config.site_id,
1159
+ blocked: blocked_site_keys.has(get_site_sync_key(site.dir, site.config))
1160
+ }))
1130
1161
  });
1131
1162
  res.writeHead(200, { 'Content-Type': 'application/json' });
1132
1163
  res.end(body);
@@ -1279,6 +1310,33 @@ async function register_primo_mcp_server(base_dir) {
1279
1310
  function is_plain_record(value) {
1280
1311
  return !!value && typeof value === 'object' && !Array.isArray(value);
1281
1312
  }
1313
+ // Folders that already got their unregistered/unreadable warning, so
1314
+ // rediscovery (which runs on every /reload) doesn't repeat it.
1315
+ const warned_unregistered_dirs = new Set();
1316
+ // Site-shaped = has any of the content dirs a real site carries. Used to
1317
+ // tell an authored-but-unregistered site (warn) from a random folder that
1318
+ // happens to live under sites/ (ignore silently, as before).
1319
+ async function is_site_shaped(site_dir) {
1320
+ for (const marker of ['pages', 'blocks', 'page-types', 'site']) {
1321
+ try {
1322
+ // Must be a directory — a plain file named e.g. `pages` in a
1323
+ // non-site folder shouldn't earn the unregistered warning.
1324
+ const stat = await fs.stat(path.join(site_dir, marker));
1325
+ if (stat.isDirectory())
1326
+ return true;
1327
+ }
1328
+ catch {
1329
+ // keep looking
1330
+ }
1331
+ }
1332
+ return false;
1333
+ }
1334
+ function warn_once(site_dir, message) {
1335
+ if (warned_unregistered_dirs.has(site_dir))
1336
+ return;
1337
+ warned_unregistered_dirs.add(site_dir);
1338
+ console.log(chalk.yellow(message));
1339
+ }
1282
1340
  async function discover_sites(base_dir) {
1283
1341
  const sites = [];
1284
1342
  const sites_root = await get_sites_root(base_dir);
@@ -1288,10 +1346,26 @@ async function discover_sites(base_dir) {
1288
1346
  const site_dir = path.join(sites_root, entry.name);
1289
1347
  try {
1290
1348
  const config = await read_site_config(site_dir);
1349
+ if (typeof config.site_id !== 'string' || !config.site_id.trim()) {
1350
+ // An authored site with no registration key. Importing it
1351
+ // would post site_id=undefined and die with a buried
1352
+ // error, so skip it and say what to run instead.
1353
+ warn_once(site_dir, ` ⚠ sites/${entry.name} isn't registered — run \`primo add ${entry.name}\` to import it.`);
1354
+ continue;
1355
+ }
1291
1356
  sites.push({ dir: site_dir, config });
1292
1357
  }
1293
- catch {
1294
- // Not a site directory
1358
+ catch (error) {
1359
+ if (error.code === 'ENOENT') {
1360
+ // No site.yaml. Only warn when the folder actually looks
1361
+ // like a site — anything else is not our business.
1362
+ if (await is_site_shaped(site_dir)) {
1363
+ warn_once(site_dir, ` ⚠ sites/${entry.name} isn't registered — run \`primo add ${entry.name}\` to import it.`);
1364
+ }
1365
+ }
1366
+ else {
1367
+ warn_once(site_dir, ` ⚠ sites/${entry.name}/site.yaml could not be read — fix it, then run \`primo add ${entry.name}\`.`);
1368
+ }
1295
1369
  }
1296
1370
  }
1297
1371
  }
@@ -1337,7 +1411,7 @@ function resolve_site_group(config, server_config) {
1337
1411
  index: 0
1338
1412
  });
1339
1413
  }
1340
- async function wait_for_ready(url, timeout_ms) {
1414
+ export async function wait_for_ready(url, timeout_ms) {
1341
1415
  const start = Date.now();
1342
1416
  const health_url = `${url}/api/health`;
1343
1417
  while (Date.now() - start < timeout_ms) {
@@ -1374,7 +1448,7 @@ async function verify_site_ready(api_url, site_id) {
1374
1448
  }
1375
1449
  return false;
1376
1450
  }
1377
- async function site_exists(api_url, site_id) {
1451
+ export async function site_exists(api_url, site_id) {
1378
1452
  // Only 404 means the site genuinely doesn't exist. Any other non-ok status
1379
1453
  // (401/403 from auth, 5xx, rate limits) leaves us uncertain — default to
1380
1454
  // "exists" so we take the additive `import` path instead of the destructive
@@ -1462,11 +1536,21 @@ function mark_written_file(file_path, content) {
1462
1536
  function mark_deleted_path(file_path) {
1463
1537
  synced_deleted_paths.set(file_path, Date.now());
1464
1538
  }
1465
- function should_skip_synced_delete(file_path) {
1539
+ function is_delete_event(event) {
1540
+ return event === 'unlink' || event === 'unlinkDir';
1541
+ }
1542
+ function should_skip_synced_delete(file_path, event) {
1466
1543
  const deleted_at = synced_deleted_paths.get(file_path);
1467
1544
  if (!deleted_at) {
1468
1545
  return false;
1469
1546
  }
1547
+ // A re-creation cancels the pending delete-echo suppression and must be
1548
+ // allowed through to import. Clear the marker so a later real delete of the
1549
+ // same path isn't mistaken for our echo.
1550
+ if (!is_delete_event(event)) {
1551
+ synced_deleted_paths.delete(file_path);
1552
+ return false;
1553
+ }
1470
1554
  if (Date.now() - deleted_at < 10_000) {
1471
1555
  synced_deleted_paths.delete(file_path);
1472
1556
  return true;
@@ -2026,7 +2110,7 @@ function print_import_warnings(site_name, warnings) {
2026
2110
  console.log('');
2027
2111
  return list.length;
2028
2112
  }
2029
- async function import_site_files(site_dir, api_url, config, port, server_config, use_bootstrap = true, workspace_dir = path.dirname(path.dirname(site_dir))) {
2113
+ export async function import_site_files(site_dir, api_url, config, port, server_config, use_bootstrap = true, workspace_dir = path.dirname(path.dirname(site_dir))) {
2030
2114
  const site_name = config.name || 'My Site';
2031
2115
  const site_id = config.site_id;
2032
2116
  const site_group = resolve_site_group(config, server_config);
@@ -4,4 +4,5 @@ interface NewOptions {
4
4
  skipDev?: boolean;
5
5
  }
6
6
  export declare function new_site(options: NewOptions): Promise<void>;
7
+ export declare function generate_agent_md(): string;
7
8
  export {};
@@ -375,7 +375,7 @@ async function is_server_running(port) {
375
375
  return false;
376
376
  }
377
377
  }
378
- function generate_agent_md() {
378
+ export function generate_agent_md() {
379
379
  return `# Primo workspace
380
380
 
381
381
  Primo workspace for local development. Each subdirectory under \`sites/\` is an independent Primo site.
@@ -394,6 +394,7 @@ Without the MCP server, read \`sites/*/blocks/*/fields.yaml\` and \`sites/*/page
394
394
 
395
395
  - \`primo dev\` — start the local CMS and dev server. Run from the workspace root.
396
396
  - \`primo new [name]\` — scaffold a new site under \`sites/\`.
397
+ - \`primo add <name>\` — register an existing \`sites/<name>\` folder with the CMS (mints its site_id and imports its records). Creating the folder alone doesn't register it.
397
398
  - File edits sync automatically while \`primo dev\` is running. Structural changes (block schema, component) may trigger a browser reload.
398
399
 
399
400
  ## Source of truth
@@ -8,6 +8,7 @@ import { get_auth_token } from '../utils/auth.js';
8
8
  import { authenticate_interactively } from './login.js';
9
9
  import { write_site_config } from '../utils/site-config.js';
10
10
  import { read_server_config, write_server_config, normalize_server_url } from '../utils/server-config.js';
11
+ import { generate_agent_md } from './new.js';
11
12
  // Directories owned by the server export. Local files under these that no
12
13
  // longer exist in the export are stale (e.g. a page that gained children
13
14
  // moved from pages/foo.yaml to pages/foo/index.yaml) and get trashed.
@@ -182,6 +183,18 @@ export async function pull_site(options) {
182
183
  site_groups: site_groups.length > 0 ? site_groups : existing?.site_groups,
183
184
  server: existing?.server ?? (is_remote_server(server) ? server : undefined)
184
185
  });
186
+ // Give pulled workspaces the same root AGENTS.md that `primo new`
187
+ // writes — a pulled export is exactly where agents author new site
188
+ // folders and need to know registration is `primo add`, not a side
189
+ // effect of `primo dev`. Never clobber an existing (possibly edited)
190
+ // one.
191
+ const agents_path = path.join(root_dir, 'AGENTS.md');
192
+ try {
193
+ await fs.access(agents_path);
194
+ }
195
+ catch {
196
+ await fs.writeFile(agents_path, generate_agent_md());
197
+ }
185
198
  spinner.succeed(`Server pulled to ${chalk.cyan(root_dir)}`);
186
199
  console.log('');
187
200
  console.log(chalk.dim(' Sites:'));
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { Command } from 'commander';
6
6
  import chalk from 'chalk';
7
7
  import { init_workspace } from './commands/init.js';
8
8
  import { new_site } from './commands/new.js';
9
+ import { add_site } from './commands/add.js';
9
10
  import { pull_site } from './commands/pull.js';
10
11
  import { push_site } from './commands/push.js';
11
12
  import { pull_library } from './commands/pull-library.js';
@@ -28,6 +29,7 @@ program
28
29
  program.addHelpText('before', `
29
30
  ${chalk.bold('Local development')}
30
31
  ${chalk.cyan('primo dev')} Run the local CMS on this workspace
32
+ ${chalk.cyan('primo add')} Register a hand-authored sites/ folder with the CMS
31
33
 
32
34
  ${chalk.bold('Going live — pick one')}
33
35
  Want others to edit content? .................. ${chalk.cyan('primo deploy')}
@@ -44,6 +46,22 @@ program
44
46
  .option('-t, --template <template>', 'Starter template')
45
47
  .option('--skip-dev', 'Create files only, don\'t start CMS')
46
48
  .action((name, options) => new_site({ name, ...options }));
49
+ program
50
+ .command('add <site>')
51
+ .description('Register an existing sites/ folder with the workspace CMS, then exit')
52
+ .option('-d, --dir <dir>', 'Workspace directory', '.')
53
+ .option('-p, --port <port>', 'Port', '3000')
54
+ .addHelpText('after', `
55
+ Creating a folder under ${chalk.cyan('sites/')} isn't enough to make it appear in the
56
+ dashboard — its records must be imported into the workspace database. This
57
+ command does that one-time import (minting a site_id into site.yaml if needed)
58
+ and exits. If ${chalk.cyan('primo dev')} is already running, it picks the site up live.
59
+
60
+ ${chalk.bold('Examples')}
61
+ primo add maison-verde
62
+ primo add sites/maison-verde
63
+ `)
64
+ .action((site, options) => add_site(site, options));
47
65
  program
48
66
  .command('dev')
49
67
  .description('Start local CMS')
@@ -163,6 +181,14 @@ program.on('command:*', (operands) => {
163
181
  console.error('');
164
182
  console.error(` Run: ${chalk.cyan('primo deploy --help')}`);
165
183
  }
184
+ else if (cmd === 'register' || cmd === 'import') {
185
+ // Likely guesses for site registration. Deliberately not aliases:
186
+ // one canonical name keeps docs/transcripts consistent, and leaves
187
+ // `register` free for a future account/signup meaning.
188
+ console.error(` To register an existing sites/ folder with the CMS, run ${chalk.cyan('primo add <site>')}.`);
189
+ console.error('');
190
+ console.error(` Run: ${chalk.cyan('primo add --help')}`);
191
+ }
166
192
  else {
167
193
  console.error(` Run ${chalk.cyan('primo --help')} to see available commands.`);
168
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.20",
3
+ "version": "0.1.23",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {