primo-cli 0.1.0

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,514 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ // Auto-fix common issues without full validation output
5
+ export async function normalize_site(site_dir) {
6
+ const pages_dir = path.join(site_dir, 'pages');
7
+ const index_path = path.join(pages_dir, 'index.yaml');
8
+ try {
9
+ let content = await fs.readFile(index_path, 'utf-8');
10
+ const slug_index_pattern = /^slug:\s*index\s*$/m;
11
+ if (slug_index_pattern.test(content)) {
12
+ content = content.replace(slug_index_pattern, "slug: ''");
13
+ await fs.writeFile(index_path, content, 'utf-8');
14
+ }
15
+ }
16
+ catch {
17
+ // File doesn't exist, skip
18
+ }
19
+ }
20
+ const VALID_FIELD_TYPES = [
21
+ 'text',
22
+ 'rich-text',
23
+ 'markdown',
24
+ 'image',
25
+ 'link',
26
+ 'url',
27
+ 'icon',
28
+ 'number',
29
+ 'switch',
30
+ 'select',
31
+ 'repeater',
32
+ 'group',
33
+ 'page',
34
+ 'page-list',
35
+ 'page-field',
36
+ 'site-field',
37
+ 'slider',
38
+ 'date',
39
+ 'info'
40
+ ];
41
+ export async function validate_site(options) {
42
+ const errors = [];
43
+ const warnings = [];
44
+ const site_dir = path.resolve(options.dir);
45
+ console.log(chalk.bold('\nšŸ” Validating site structure...\n'));
46
+ try {
47
+ // Check if directory exists
48
+ await fs.access(site_dir);
49
+ }
50
+ catch {
51
+ console.log(chalk.red(`āœ– Directory not found: ${site_dir}`));
52
+ process.exit(1);
53
+ }
54
+ // Validate blocks
55
+ const blocks_errors = await validate_blocks(site_dir);
56
+ errors.push(...blocks_errors.filter(e => e.severity === 'error'));
57
+ warnings.push(...blocks_errors.filter(e => e.severity === 'warning'));
58
+ // Validate page types
59
+ const page_types_errors = await validate_page_types(site_dir);
60
+ errors.push(...page_types_errors.filter(e => e.severity === 'error'));
61
+ warnings.push(...page_types_errors.filter(e => e.severity === 'warning'));
62
+ // Validate site fields
63
+ const site_errors = await validate_site_fields(site_dir);
64
+ errors.push(...site_errors.filter(e => e.severity === 'error'));
65
+ warnings.push(...site_errors.filter(e => e.severity === 'warning'));
66
+ // Validate pages
67
+ const pages_errors = await validate_pages(site_dir);
68
+ errors.push(...pages_errors.filter(e => e.severity === 'error'));
69
+ warnings.push(...pages_errors.filter(e => e.severity === 'warning'));
70
+ // Print results
71
+ if (errors.length === 0 && warnings.length === 0) {
72
+ console.log(chalk.green('āœ“ All validations passed!\n'));
73
+ return;
74
+ }
75
+ if (warnings.length > 0) {
76
+ console.log(chalk.yellow(`⚠ ${warnings.length} warning${warnings.length > 1 ? 's' : ''}:\n`));
77
+ for (const warning of warnings) {
78
+ print_error(warning);
79
+ }
80
+ console.log('');
81
+ }
82
+ if (errors.length > 0) {
83
+ console.log(chalk.red(`āœ– ${errors.length} error${errors.length > 1 ? 's' : ''}:\n`));
84
+ for (const error of errors) {
85
+ print_error(error);
86
+ }
87
+ console.log('');
88
+ process.exit(1);
89
+ }
90
+ }
91
+ async function validate_blocks(site_dir) {
92
+ const errors = [];
93
+ const blocks_dir = path.join(site_dir, 'blocks');
94
+ try {
95
+ await fs.access(blocks_dir);
96
+ }
97
+ catch {
98
+ return [{
99
+ file: 'blocks/',
100
+ message: 'Blocks directory not found',
101
+ severity: 'error'
102
+ }];
103
+ }
104
+ const block_names = await fs.readdir(blocks_dir);
105
+ for (const block_name of block_names) {
106
+ if (block_name.startsWith('.'))
107
+ continue;
108
+ const block_dir = path.join(blocks_dir, block_name);
109
+ const stat = await fs.stat(block_dir);
110
+ if (!stat.isDirectory())
111
+ continue;
112
+ // Check for required files - read actual directory listing to handle case-insensitive filesystems
113
+ const fields_path = path.join(block_dir, 'fields.json');
114
+ const block_files = await fs.readdir(block_dir);
115
+ // Check for component.svelte with correct casing
116
+ const component_file = block_files.find(f => f.toLowerCase() === 'component.svelte');
117
+ if (!component_file) {
118
+ errors.push({
119
+ file: `blocks/${block_name}/`,
120
+ message: 'Missing component.svelte file',
121
+ severity: 'error'
122
+ });
123
+ }
124
+ else if (component_file !== 'component.svelte') {
125
+ errors.push({
126
+ file: `blocks/${block_name}/${component_file}`,
127
+ message: `Filename must be lowercase: "component.svelte", not "${component_file}". The import will silently skip this block.`,
128
+ severity: 'error'
129
+ });
130
+ }
131
+ try {
132
+ const fields_data = await fs.readFile(fields_path, 'utf-8');
133
+ let fields_json;
134
+ try {
135
+ fields_json = JSON.parse(fields_data);
136
+ }
137
+ catch {
138
+ errors.push({
139
+ file: `blocks/${block_name}/fields.json`,
140
+ message: 'Invalid JSON syntax',
141
+ severity: 'error'
142
+ });
143
+ continue;
144
+ }
145
+ // Validate fields structure
146
+ const field_errors = validate_fields(fields_json, `blocks/${block_name}/fields.json`);
147
+ errors.push(...field_errors);
148
+ }
149
+ catch {
150
+ errors.push({
151
+ file: `blocks/${block_name}/`,
152
+ message: 'Missing fields.json file',
153
+ severity: 'error'
154
+ });
155
+ }
156
+ }
157
+ return errors;
158
+ }
159
+ async function validate_page_types(site_dir) {
160
+ const errors = [];
161
+ const page_types_dir = path.join(site_dir, 'page-types');
162
+ try {
163
+ await fs.access(page_types_dir);
164
+ }
165
+ catch {
166
+ return [{
167
+ file: 'page-types/',
168
+ message: 'Page types directory not found',
169
+ severity: 'warning'
170
+ }];
171
+ }
172
+ const page_type_names = await fs.readdir(page_types_dir);
173
+ for (const page_type_name of page_type_names) {
174
+ if (page_type_name.startsWith('.'))
175
+ continue;
176
+ const page_type_dir = path.join(page_types_dir, page_type_name);
177
+ const stat = await fs.stat(page_type_dir);
178
+ if (!stat.isDirectory())
179
+ continue;
180
+ const config_path = path.join(page_type_dir, 'config.json');
181
+ try {
182
+ const config_data = await fs.readFile(config_path, 'utf-8');
183
+ let config;
184
+ try {
185
+ config = JSON.parse(config_data);
186
+ }
187
+ catch {
188
+ errors.push({
189
+ file: `page-types/${page_type_name}/config.json`,
190
+ message: 'Invalid JSON syntax',
191
+ severity: 'error'
192
+ });
193
+ continue;
194
+ }
195
+ // Validate config has required fields
196
+ if (!config.name) {
197
+ errors.push({
198
+ file: `page-types/${page_type_name}/config.json`,
199
+ message: 'Missing "name" field',
200
+ severity: 'error'
201
+ });
202
+ }
203
+ // Validate page type fields if they exist
204
+ if (config.fields) {
205
+ const field_errors = validate_fields({ fields: config.fields }, `page-types/${page_type_name}/config.json`);
206
+ errors.push(...field_errors);
207
+ }
208
+ }
209
+ catch {
210
+ errors.push({
211
+ file: `page-types/${page_type_name}/`,
212
+ message: 'Missing config.json file',
213
+ severity: 'error'
214
+ });
215
+ }
216
+ }
217
+ return errors;
218
+ }
219
+ async function validate_site_fields(site_dir) {
220
+ const errors = [];
221
+ const site_fields_path = path.join(site_dir, 'site/fields.json');
222
+ try {
223
+ const fields_data = await fs.readFile(site_fields_path, 'utf-8');
224
+ let fields_json;
225
+ try {
226
+ fields_json = JSON.parse(fields_data);
227
+ }
228
+ catch {
229
+ errors.push({
230
+ file: 'site/fields.json',
231
+ message: 'Invalid JSON syntax',
232
+ severity: 'error'
233
+ });
234
+ return errors;
235
+ }
236
+ // site/fields.json must be a plain array, not wrapped in an object
237
+ if (!Array.isArray(fields_json)) {
238
+ errors.push({
239
+ file: 'site/fields.json',
240
+ message: 'Must be a plain array (e.g., []) not wrapped in an object. The import will fail.',
241
+ severity: 'error'
242
+ });
243
+ return errors;
244
+ }
245
+ // Validate as array of fields
246
+ if (fields_json.length > 0) {
247
+ const field_errors = validate_fields({ fields: fields_json }, 'site/fields.json');
248
+ errors.push(...field_errors);
249
+ }
250
+ }
251
+ catch {
252
+ errors.push({
253
+ file: 'site/',
254
+ message: 'Missing fields.json file',
255
+ severity: 'warning'
256
+ });
257
+ }
258
+ return errors;
259
+ }
260
+ function validate_fields(fields_json, file_path) {
261
+ const errors = [];
262
+ if (!fields_json.fields || !Array.isArray(fields_json.fields)) {
263
+ errors.push({
264
+ file: file_path,
265
+ message: 'Missing or invalid "fields" array',
266
+ severity: 'error'
267
+ });
268
+ return errors;
269
+ }
270
+ const field_ids = new Set();
271
+ const field_names = new Set();
272
+ const parent_names = new Set();
273
+ // Collect all field names for parent validation
274
+ for (const field of fields_json.fields) {
275
+ if (field.name) {
276
+ field_names.add(field.name);
277
+ }
278
+ }
279
+ for (const field of fields_json.fields) {
280
+ const field_name = field.name || field.id || '(unnamed)';
281
+ // Check required properties
282
+ if (!field.id) {
283
+ errors.push({
284
+ file: file_path,
285
+ field: field_name,
286
+ message: 'Missing required "id" field',
287
+ severity: 'error'
288
+ });
289
+ }
290
+ else {
291
+ // Check for duplicate IDs
292
+ if (field_ids.has(field.id)) {
293
+ errors.push({
294
+ file: file_path,
295
+ field: field_name,
296
+ message: `Duplicate field ID: "${field.id}"`,
297
+ severity: 'error'
298
+ });
299
+ }
300
+ field_ids.add(field.id);
301
+ }
302
+ if (!field.name) {
303
+ errors.push({
304
+ file: file_path,
305
+ field: field_name,
306
+ message: 'Missing required "name" field',
307
+ severity: 'error'
308
+ });
309
+ }
310
+ if (!field.label && field.type !== 'info') {
311
+ errors.push({
312
+ file: file_path,
313
+ field: field_name,
314
+ message: 'Missing "label" field',
315
+ severity: 'warning'
316
+ });
317
+ }
318
+ if (!field.type) {
319
+ errors.push({
320
+ file: file_path,
321
+ field: field_name,
322
+ message: 'Missing required "type" field',
323
+ severity: 'error'
324
+ });
325
+ }
326
+ else {
327
+ // Check valid field type
328
+ if (!VALID_FIELD_TYPES.includes(field.type)) {
329
+ errors.push({
330
+ file: file_path,
331
+ field: field_name,
332
+ message: `Invalid field type: "${field.type}". Valid types: ${VALID_FIELD_TYPES.join(', ')}`,
333
+ severity: 'error'
334
+ });
335
+ }
336
+ // Type-specific validation
337
+ if (field.type === 'select') {
338
+ const select_errors = validate_select_field(field, field_name, file_path);
339
+ errors.push(...select_errors);
340
+ }
341
+ if (field.type === 'repeater' || field.type === 'group') {
342
+ parent_names.add(field.name);
343
+ }
344
+ }
345
+ // Validate parent reference
346
+ if (field.parent) {
347
+ if (!field_names.has(field.parent)) {
348
+ errors.push({
349
+ file: file_path,
350
+ field: field_name,
351
+ message: `Parent field "${field.parent}" not found`,
352
+ severity: 'error'
353
+ });
354
+ }
355
+ }
356
+ }
357
+ return errors;
358
+ }
359
+ function validate_select_field(field, field_name, file_path) {
360
+ const errors = [];
361
+ if (!field.options || !field.options.options) {
362
+ errors.push({
363
+ file: file_path,
364
+ field: field_name,
365
+ message: 'Select field missing "options.options" array',
366
+ severity: 'error'
367
+ });
368
+ return errors;
369
+ }
370
+ if (!Array.isArray(field.options.options)) {
371
+ errors.push({
372
+ file: file_path,
373
+ field: field_name,
374
+ message: 'Select field "options.options" must be an array',
375
+ severity: 'error'
376
+ });
377
+ return errors;
378
+ }
379
+ for (let i = 0; i < field.options.options.length; i++) {
380
+ const option = field.options.options[i];
381
+ if (!option.label) {
382
+ errors.push({
383
+ file: file_path,
384
+ field: field_name,
385
+ message: `Option ${i + 1} missing "label" property`,
386
+ severity: 'error'
387
+ });
388
+ }
389
+ if (!option.value) {
390
+ errors.push({
391
+ file: file_path,
392
+ field: field_name,
393
+ message: `Option ${i + 1} missing "value" property`,
394
+ severity: 'error'
395
+ });
396
+ }
397
+ if (!('icon' in option)) {
398
+ errors.push({
399
+ file: file_path,
400
+ field: field_name,
401
+ message: `Option ${i + 1} missing "icon" property (can be empty string)`,
402
+ severity: 'error'
403
+ });
404
+ }
405
+ }
406
+ return errors;
407
+ }
408
+ async function validate_pages(site_dir) {
409
+ const errors = [];
410
+ const pages_dir = path.join(site_dir, 'pages');
411
+ try {
412
+ await fs.access(pages_dir);
413
+ }
414
+ catch {
415
+ return [{
416
+ file: 'pages/',
417
+ message: 'Pages directory not found',
418
+ severity: 'warning'
419
+ }];
420
+ }
421
+ // Recursively find all YAML files in pages directory
422
+ const yaml_files = await find_yaml_files(pages_dir, 'pages');
423
+ for (const yaml_file of yaml_files) {
424
+ const file_path = path.join(site_dir, yaml_file);
425
+ try {
426
+ let content = await fs.readFile(file_path, 'utf-8');
427
+ // Auto-fix: normalize homepage slug from "index" to ""
428
+ if (yaml_file === 'pages/index.yaml' || yaml_file === 'pages\\index.yaml') {
429
+ const slug_index_pattern = /^slug:\s*index\s*$/m;
430
+ if (slug_index_pattern.test(content)) {
431
+ content = content.replace(slug_index_pattern, "slug: ''");
432
+ await fs.writeFile(file_path, content, 'utf-8');
433
+ }
434
+ }
435
+ // Check for common mistakes
436
+ if (content.includes('\nblocks:') || content.match(/^blocks:/m)) {
437
+ errors.push({
438
+ file: yaml_file,
439
+ message: 'Page uses "blocks:" but should use "sections:" with nested "content" objects. The import will fail.',
440
+ severity: 'error'
441
+ });
442
+ }
443
+ // Check that sections have the correct structure
444
+ if (content.includes('\nsections:') || content.match(/^sections:/m)) {
445
+ // Basic check: sections should have "block:" and "content:" keys
446
+ const lines = content.split('\n');
447
+ let in_sections = false;
448
+ let found_block = false;
449
+ let found_content = false;
450
+ for (const line of lines) {
451
+ if (line.match(/^sections:/)) {
452
+ in_sections = true;
453
+ }
454
+ else if (in_sections && line.match(/^\s+- block:/)) {
455
+ found_block = true;
456
+ }
457
+ else if (in_sections && line.match(/^\s+content:/)) {
458
+ found_content = true;
459
+ }
460
+ else if (in_sections && line.match(/^[a-z]/)) {
461
+ // New top-level key, end of sections
462
+ in_sections = false;
463
+ }
464
+ }
465
+ if (found_block && !found_content) {
466
+ errors.push({
467
+ file: yaml_file,
468
+ message: 'Sections missing "content:" key. Each section should have "block:" and "content:" keys.',
469
+ severity: 'warning'
470
+ });
471
+ }
472
+ }
473
+ }
474
+ catch (err) {
475
+ errors.push({
476
+ file: yaml_file,
477
+ message: `Failed to read file: ${err}`,
478
+ severity: 'error'
479
+ });
480
+ }
481
+ }
482
+ return errors;
483
+ }
484
+ async function find_yaml_files(dir, relative_path) {
485
+ const files = [];
486
+ try {
487
+ const entries = await fs.readdir(dir, { withFileTypes: true });
488
+ for (const entry of entries) {
489
+ if (entry.name.startsWith('.'))
490
+ continue;
491
+ const full_path = path.join(dir, entry.name);
492
+ const rel_path = path.join(relative_path, entry.name);
493
+ if (entry.isDirectory()) {
494
+ const nested = await find_yaml_files(full_path, rel_path);
495
+ files.push(...nested);
496
+ }
497
+ else if (entry.name.endsWith('.yaml') || entry.name.endsWith('.yml')) {
498
+ files.push(rel_path);
499
+ }
500
+ }
501
+ }
502
+ catch {
503
+ // Directory doesn't exist or not readable
504
+ }
505
+ return files;
506
+ }
507
+ function print_error(error) {
508
+ const icon = error.severity === 'error' ? chalk.red('āœ–') : chalk.yellow('⚠');
509
+ const location = error.field
510
+ ? `${error.file} → ${chalk.bold(error.field)}`
511
+ : error.file;
512
+ console.log(` ${icon} ${chalk.dim(location)}`);
513
+ console.log(` ${error.message}`);
514
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { new_site } from './commands/new.js';
4
+ import { pull_site } from './commands/pull.js';
5
+ import { push_site } from './commands/push.js';
6
+ import { dev_server } from './commands/dev.js';
7
+ import { login } from './commands/login.js';
8
+ import { validate_site } from './commands/validate.js';
9
+ import { publish } from './commands/publish.js';
10
+ import { build_site } from './commands/build.js';
11
+ const program = new Command();
12
+ program
13
+ .name('primo')
14
+ .description('Build sites visually, edit them anywhere')
15
+ .version('0.1.0');
16
+ program
17
+ .command('new [name]')
18
+ .description('Create a new site and start local CMS')
19
+ .option('-t, --template <template>', 'Starter template')
20
+ .option('--skip-dev', 'Create files only, don\'t start CMS')
21
+ .action((name, options) => new_site({ name, ...options }));
22
+ program
23
+ .command('dev')
24
+ .description('Start local CMS')
25
+ .option('-d, --dir <dir>', 'Site directory', '.')
26
+ .option('-p, --port <port>', 'Port', '3000')
27
+ .action(dev_server);
28
+ program
29
+ .command('publish')
30
+ .description('Deploy site with CMS')
31
+ .option('-d, --dir <dir>', 'Site directory', '.')
32
+ .option('-p, --provider <provider>', 'Provider (railway, fly)')
33
+ .action(publish);
34
+ program
35
+ .command('push')
36
+ .description('Push local files to hosted CMS')
37
+ .option('-s, --server <url>', 'Server URL')
38
+ .option('--site <id>', 'Site ID')
39
+ .option('-d, --dir <dir>', 'Directory', '.')
40
+ .option('-t, --token <token>', 'Auth token')
41
+ .option('--preview', 'Preview only')
42
+ .action(push_site);
43
+ program
44
+ .command('pull')
45
+ .description('Pull from hosted CMS to local files')
46
+ .option('-s, --server <url>', 'Server URL (auto-detects local)')
47
+ .option('--site <id>', 'Site ID (interactive if not provided)')
48
+ .option('-o, --output <dir>', 'Output directory', '.')
49
+ .option('-t, --token <token>', 'Auth token')
50
+ .action(pull_site);
51
+ program
52
+ .command('login')
53
+ .description('Login to hosted CMS')
54
+ .argument('<server>', 'Server URL')
55
+ .option('-e, --email <email>', 'Email')
56
+ .option('-p, --password <password>', 'Password')
57
+ .action((server, options) => login({ server, ...options }));
58
+ program
59
+ .command('validate')
60
+ .description('Validate site structure')
61
+ .option('-d, --dir <dir>', 'Directory', '.')
62
+ .option('--strict', 'Strict mode')
63
+ .action(validate_site);
64
+ program
65
+ .command('build')
66
+ .description('Build static site')
67
+ .option('-d, --dir <dir>', 'Site directory', '.')
68
+ .option('-o, --output <dir>', 'Output directory', 'dist')
69
+ .action(build_site);
70
+ program.parse();
@@ -0,0 +1,2 @@
1
+ export declare function get_auth_token(server: string): Promise<string | null>;
2
+ export declare function save_auth_token(server: string, token: string): Promise<void>;
@@ -0,0 +1,29 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ const CONFIG_DIR = path.join(os.homedir(), '.primo');
5
+ const TOKEN_FILE = path.join(CONFIG_DIR, 'tokens.json');
6
+ export async function get_auth_token(server) {
7
+ try {
8
+ const data = await fs.readFile(TOKEN_FILE, 'utf-8');
9
+ const tokens = JSON.parse(data);
10
+ return tokens[normalize_server(server)] || null;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ export async function save_auth_token(server, token) {
17
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
18
+ let tokens = {};
19
+ try {
20
+ const data = await fs.readFile(TOKEN_FILE, 'utf-8');
21
+ tokens = JSON.parse(data);
22
+ }
23
+ catch { }
24
+ tokens[normalize_server(server)] = token;
25
+ await fs.writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2));
26
+ }
27
+ function normalize_server(server) {
28
+ return server.replace(/\/+$/, '').toLowerCase();
29
+ }
@@ -0,0 +1,5 @@
1
+ export declare function get_binary_path(): Promise<string>;
2
+ export declare function ensure_data_dir(base_dir: string): Promise<string>;
3
+ export declare function is_binary_installed(): Promise<boolean>;
4
+ export declare function ensure_binary(): Promise<string>;
5
+ export declare function get_binary_version(): Promise<string | null>;