primo-cli 0.1.21 → 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
+ }
@@ -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.21",
3
+ "version": "0.1.23",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {