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.
- package/dist/commands/add.d.ts +6 -0
- package/dist/commands/add.js +345 -0
- package/dist/commands/dev.d.ts +24 -0
- package/dist/commands/dev.js +127 -43
- package/dist/commands/new.d.ts +1 -0
- package/dist/commands/new.js +2 -1
- package/dist/commands/pull.js +13 -0
- package/dist/index.js +26 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/commands/dev.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/commands/dev.js
CHANGED
|
@@ -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
|
-
|
|
1084
|
-
await
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
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
|
|
1108
|
-
await
|
|
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
|
-
|
|
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
|
|
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);
|
package/dist/commands/new.d.ts
CHANGED
package/dist/commands/new.js
CHANGED
|
@@ -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
|
package/dist/commands/pull.js
CHANGED
|
@@ -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
|
}
|