primo-cli 0.1.3 → 0.1.5

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.
Files changed (38) hide show
  1. package/README.md +111 -39
  2. package/dist/commands/build.js +488 -272
  3. package/dist/commands/deploy.d.ts +1 -1
  4. package/dist/commands/deploy.js +293 -141
  5. package/dist/commands/dev.d.ts +2 -0
  6. package/dist/commands/dev.js +2007 -150
  7. package/dist/commands/init.d.ts +2 -2
  8. package/dist/commands/init.js +65 -43
  9. package/dist/commands/login.d.ts +1 -2
  10. package/dist/commands/login.js +24 -6
  11. package/dist/commands/new.js +161 -274
  12. package/dist/commands/pull-library.d.ts +7 -0
  13. package/dist/commands/pull-library.js +92 -0
  14. package/dist/commands/pull.d.ts +0 -1
  15. package/dist/commands/pull.js +160 -165
  16. package/dist/commands/push-library.d.ts +7 -0
  17. package/dist/commands/push-library.js +88 -0
  18. package/dist/commands/push.d.ts +2 -0
  19. package/dist/commands/push.js +358 -51
  20. package/dist/commands/validate.d.ts +1 -1
  21. package/dist/commands/validate.js +379 -161
  22. package/dist/index.js +110 -20
  23. package/dist/utils/binary.js +1 -1
  24. package/dist/utils/format.d.ts +12 -0
  25. package/dist/utils/format.js +98 -0
  26. package/dist/utils/head-svelte.d.ts +2 -0
  27. package/dist/utils/head-svelte.js +53 -0
  28. package/dist/utils/server-config.d.ts +19 -0
  29. package/dist/utils/server-config.js +49 -0
  30. package/dist/utils/site-config.d.ts +11 -0
  31. package/dist/utils/site-config.js +14 -0
  32. package/package.json +8 -4
  33. package/dist/commands/export.d.ts +0 -8
  34. package/dist/commands/export.js +0 -163
  35. package/dist/commands/import.d.ts +0 -9
  36. package/dist/commands/import.js +0 -118
  37. package/dist/commands/publish.d.ts +0 -6
  38. package/dist/commands/publish.js +0 -239
@@ -3,10 +3,11 @@ import path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import extract from 'extract-zip';
6
- import inquirer from 'inquirer';
6
+ import { dump as dump_yaml, load as load_yaml } from 'js-yaml';
7
7
  import { get_auth_token } from '../utils/auth.js';
8
+ import { write_site_config } from '../utils/site-config.js';
9
+ import { write_server_config } from '../utils/server-config.js';
8
10
  async function detect_server() {
9
- // Check common local ports
10
11
  const ports = [3000, 8080, 5173];
11
12
  for (const port of ports) {
12
13
  try {
@@ -24,164 +25,185 @@ async function detect_server() {
24
25
  }
25
26
  return null;
26
27
  }
28
+ function slugify(value) {
29
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'site';
30
+ }
31
+ function server_folder_name(server) {
32
+ try {
33
+ return new URL(server).hostname || 'primo-server';
34
+ }
35
+ catch {
36
+ return 'primo-server';
37
+ }
38
+ }
27
39
  export async function pull_site(options) {
28
40
  const spinner = ora('Connecting...').start();
29
41
  try {
30
- // Detect or use provided server
42
+ // Resolve server (flag > local detect)
31
43
  let server;
32
44
  if (options.server) {
33
- server = options.server;
45
+ server = options.server.replace(/\/+$/, '');
34
46
  }
35
47
  else {
36
48
  spinner.text = 'Looking for local server...';
37
49
  const detected = await detect_server();
38
- // Default to localhost:3000 if no server detected
39
- server = detected || 'http://localhost:3000';
50
+ server = (detected || 'http://localhost:3000').replace(/\/+$/, '');
40
51
  spinner.text = `Using ${server}`;
41
52
  }
42
- // Get auth token (may not be needed for local)
53
+ // Auth (optional for local)
43
54
  const token = options.token || await get_auth_token(server);
44
- // Local servers may not require auth
45
55
  const headers = {};
46
56
  if (token) {
47
57
  headers['Authorization'] = `Bearer ${token}`;
48
58
  }
49
- let site_id = options.site;
50
- let site_host;
51
- // If no site specified, show interactive selection
52
- if (!site_id) {
53
- spinner.text = 'Fetching sites...';
54
- const sites_response = await fetch(`${server}/api/collections/sites/records`, {
55
- headers
56
- });
57
- if (!sites_response.ok) {
58
- spinner.fail('Failed to fetch sites');
59
- process.exit(1);
60
- }
61
- const sites_data = await sites_response.json();
62
- const sites = sites_data.items || [];
63
- if (sites.length === 0) {
64
- spinner.fail('No sites found on this server');
65
- process.exit(1);
66
- }
67
- spinner.stop();
68
- const { selected_site } = await inquirer.prompt([{
69
- type: 'list',
70
- name: 'selected_site',
71
- message: 'Select a site to pull:',
72
- choices: sites.map(site => ({
73
- name: `${site.name} ${chalk.dim(`(${site.host})`)}`,
74
- value: site
75
- }))
76
- }]);
77
- site_id = selected_site.id;
78
- site_host = selected_site.host;
79
- spinner.start('Exporting site...');
59
+ // Decide root dir: if --output is default ('.'), nest under server hostname.
60
+ // Otherwise use --output verbatim.
61
+ const root_dir = options.output === '.'
62
+ ? path.resolve(server_folder_name(server))
63
+ : path.resolve(options.output);
64
+ await fs.mkdir(root_dir, { recursive: true });
65
+ // List all sites
66
+ spinner.text = 'Fetching sites...';
67
+ const sites_response = await fetch(`${server}/api/collections/sites/records?perPage=200`, {
68
+ headers
69
+ });
70
+ if (!sites_response.ok) {
71
+ spinner.fail(`Failed to fetch sites (${sites_response.status})`);
72
+ process.exit(1);
80
73
  }
81
- else {
82
- // Fetch site info to get hostname for folder name
83
- spinner.text = 'Fetching site info...';
84
- const site_response = await fetch(`${server}/api/collections/sites/records/${site_id}`, {
85
- headers
86
- });
87
- if (site_response.ok) {
88
- const site_data = await site_response.json();
89
- site_host = site_data.host;
74
+ const sites_data = await sites_response.json();
75
+ const sites = sites_data.items || [];
76
+ if (sites.length === 0) {
77
+ if (!token) {
78
+ spinner.fail(`Not authenticated. Run \`primo login ${server}\` first.`);
79
+ }
80
+ else {
81
+ spinner.fail('No sites visible your token may be expired. Try `primo login` again.');
90
82
  }
83
+ process.exit(1);
91
84
  }
92
- let output_dir = path.resolve(options.output);
93
- // If output is default (.), use hostname as folder name
94
- if (options.output === '.' && site_host) {
95
- const hostname = site_host.split(':')[0];
96
- if (hostname && hostname !== 'localhost') {
97
- output_dir = path.resolve(hostname);
85
+ // Pull library (best-effort — older servers may not support it)
86
+ const library_pulled = await pull_library_into(server, headers, root_dir, spinner);
87
+ // Pull each site into sites/<slug>/
88
+ const sites_root = path.join(root_dir, 'sites');
89
+ await fs.mkdir(sites_root, { recursive: true });
90
+ const used_slugs = new Set();
91
+ const pulled_sites = [];
92
+ for (const site of sites) {
93
+ const base_slug = slugify(site.name || site.host || site.id);
94
+ let slug = base_slug;
95
+ let n = 2;
96
+ while (used_slugs.has(slug)) {
97
+ slug = `${base_slug}-${n++}`;
98
98
  }
99
+ used_slugs.add(slug);
100
+ const site_dir = path.join(sites_root, slug);
101
+ spinner.start(`Pulling ${chalk.cyan(site.name)}...`);
102
+ await pull_one_site(server, headers, site, site_dir, spinner);
103
+ pulled_sites.push({ slug, site });
99
104
  }
100
- await fs.mkdir(output_dir, { recursive: true });
101
- // Fetch the export
102
- spinner.text = 'Exporting site...';
103
- const response = await fetch(`${server}/api/palacms/export/${site_id}`, {
104
- headers
105
+ // Fetch site groups so server.yaml has them
106
+ const site_groups = await fetch_site_groups(server, headers);
107
+ // Write minimal server.yaml so MCP registration + dev work at the root
108
+ await write_server_config(root_dir, {
109
+ port: 3000,
110
+ site_groups: site_groups.length > 0 ? site_groups : undefined
105
111
  });
106
- if (!response.ok) {
107
- const error = await response.text();
108
- spinner.fail(`Export failed: ${error}`);
109
- process.exit(1);
110
- }
111
- // Save ZIP temporarily
112
- const zip_data = await response.arrayBuffer();
113
- const temp_zip = path.join(output_dir, '.primo-export.zip');
114
- await fs.writeFile(temp_zip, Buffer.from(zip_data));
115
- // Extract ZIP
116
- spinner.text = 'Extracting files...';
117
- await extract(temp_zip, { dir: output_dir });
118
- // Clean up temp ZIP
119
- await fs.unlink(temp_zip);
120
- // Copy JSON schemas
121
- spinner.text = 'Adding JSON schemas...';
122
- await copy_schemas(output_dir);
123
- // Add $schema references
124
- await add_schema_references(output_dir);
125
- spinner.succeed(`Site exported to ${chalk.cyan(output_dir)}`);
126
- // Show summary
127
- const files = await count_files(output_dir);
112
+ spinner.succeed(`Server pulled to ${chalk.cyan(root_dir)}`);
128
113
  console.log('');
129
- console.log(chalk.dim(' Files exported:'));
130
- console.log(chalk.dim(` blocks/ ${files.blocks} blocks`));
131
- console.log(chalk.dim(` page-types/ ${files.page_types} page types`));
132
- console.log(chalk.dim(` pages/ ${files.pages} pages`));
114
+ console.log(chalk.dim(' Sites:'));
115
+ for (const { slug, site } of pulled_sites) {
116
+ console.log(chalk.dim(` sites/${slug}/ ${chalk.dim(`(${site.name})`)}`));
117
+ }
118
+ if (library_pulled) {
119
+ console.log(chalk.dim(' library/'));
120
+ }
121
+ console.log(chalk.dim(` server.yaml`));
133
122
  console.log('');
134
123
  console.log(chalk.green(' Ready for local development!'));
135
- console.log(chalk.dim(' Run `primo dev` to start the local server'));
124
+ console.log(chalk.dim(` cd ${path.relative(process.cwd(), root_dir) || '.'} && primo dev`));
136
125
  }
137
126
  catch (error) {
138
- spinner.fail(`Export failed: ${error instanceof Error ? error.message : error}`);
127
+ spinner.fail(`Pull failed: ${error instanceof Error ? error.message : error}`);
139
128
  process.exit(1);
140
129
  }
141
130
  }
142
- async function count_files(dir) {
143
- const counts = { blocks: 0, page_types: 0, pages: 0 };
144
- try {
145
- const blocks_dir = path.join(dir, 'blocks');
146
- const entries = await fs.readdir(blocks_dir, { withFileTypes: true });
147
- counts.blocks = entries.filter(e => e.isDirectory()).length;
131
+ async function pull_one_site(server, headers, site, site_dir, spinner) {
132
+ await fs.mkdir(site_dir, { recursive: true });
133
+ spinner.text = `Exporting ${site.name}...`;
134
+ const response = await fetch(`${server}/api/palacms/export/${site.id}`, { headers });
135
+ if (!response.ok) {
136
+ const error = await response.text();
137
+ throw new Error(`Export failed for ${site.name}: ${error}`);
148
138
  }
149
- catch { }
150
- try {
151
- const pt_dir = path.join(dir, 'page-types');
152
- const entries = await fs.readdir(pt_dir, { withFileTypes: true });
153
- counts.page_types = entries.filter(e => e.isDirectory()).length;
139
+ const zip_data = await response.arrayBuffer();
140
+ const temp_zip = path.join(site_dir, '.primo-export.zip');
141
+ await fs.writeFile(temp_zip, Buffer.from(zip_data));
142
+ spinner.text = `Extracting ${site.name}...`;
143
+ await extract(temp_zip, { dir: site_dir });
144
+ await fs.unlink(temp_zip);
145
+ await write_site_config(site_dir, {
146
+ name: site.name || 'Imported Site',
147
+ host: site.host || '',
148
+ site_id: site.id,
149
+ server,
150
+ group: site.group
151
+ });
152
+ await copy_schemas(site_dir);
153
+ await add_schema_references(site_dir);
154
+ }
155
+ async function pull_library_into(server, headers, root_dir, spinner) {
156
+ spinner.start('Pulling library...');
157
+ const response = await fetch(`${server}/api/palacms/export-library`, { headers });
158
+ if (response.status === 404) {
159
+ spinner.warn('Library export not supported by this server — skipping');
160
+ return false;
154
161
  }
155
- catch { }
156
- try {
157
- const pages_dir = path.join(dir, 'pages');
158
- counts.pages = await count_json_files(pages_dir);
162
+ if (!response.ok) {
163
+ spinner.warn(`Library export failed (${response.status}) — skipping`);
164
+ return false;
159
165
  }
160
- catch { }
161
- return counts;
166
+ const zip_data = await response.arrayBuffer();
167
+ const temp_zip = path.join(root_dir, '.primo-library-export.zip');
168
+ await fs.writeFile(temp_zip, Buffer.from(zip_data));
169
+ await extract(temp_zip, { dir: root_dir });
170
+ await fs.unlink(temp_zip);
171
+ return true;
162
172
  }
163
- async function count_json_files(dir) {
164
- let count = 0;
165
- const entries = await fs.readdir(dir, { withFileTypes: true });
166
- for (const entry of entries) {
167
- if (entry.isDirectory()) {
168
- count += await count_json_files(path.join(dir, entry.name));
169
- }
170
- else if (entry.name.endsWith('.json')) {
171
- count++;
172
- }
173
+ async function fetch_site_groups(server, headers) {
174
+ try {
175
+ const response = await fetch(`${server}/api/collections/site_groups/records?perPage=200`, {
176
+ headers
177
+ });
178
+ if (!response.ok)
179
+ return [];
180
+ const data = await response.json();
181
+ const items = data.items || [];
182
+ return items.map((g, i) => ({
183
+ id: g.id,
184
+ name: g.name || g.id,
185
+ index: typeof g.index === 'number' ? g.index : i
186
+ }));
187
+ }
188
+ catch {
189
+ return [];
173
190
  }
174
- return count;
175
191
  }
176
192
  async function copy_schemas(output_dir) {
177
- // Get path to schemas directory relative to compiled dist file
178
193
  const current_file = new URL(import.meta.url).pathname;
179
- const dist_dir = path.dirname(path.dirname(current_file)); // dist/
180
- const project_root = path.dirname(dist_dir); // project root
194
+ const dist_dir = path.dirname(path.dirname(current_file));
195
+ const project_root = path.dirname(dist_dir);
181
196
  const schemas_src = path.join(project_root, 'schemas');
182
197
  const schemas_dest = path.join(output_dir, '.schemas');
198
+ let schema_files;
199
+ try {
200
+ schema_files = await fs.readdir(schemas_src);
201
+ }
202
+ catch {
203
+ // Schemas not bundled with this CLI install — skip silently
204
+ return;
205
+ }
183
206
  await fs.mkdir(schemas_dest, { recursive: true });
184
- const schema_files = await fs.readdir(schemas_src);
185
207
  for (const file of schema_files) {
186
208
  if (file.endsWith('.json')) {
187
209
  await fs.copyFile(path.join(schemas_src, file), path.join(schemas_dest, file));
@@ -189,55 +211,28 @@ async function copy_schemas(output_dir) {
189
211
  }
190
212
  }
191
213
  async function add_schema_references(output_dir) {
192
- // Add $schema to block fields.json
193
- const blocks_dir = path.join(output_dir, 'blocks');
194
- try {
195
- const blocks = await fs.readdir(blocks_dir, { withFileTypes: true });
196
- for (const block of blocks) {
197
- if (block.isDirectory()) {
198
- const fields_path = path.join(blocks_dir, block.name, 'fields.json');
199
- try {
200
- const fields = JSON.parse(await fs.readFile(fields_path, 'utf-8'));
201
- // Create new object with $schema first
202
- const with_schema = {
203
- $schema: '../../.schemas/fields.schema.json',
204
- ...fields
205
- };
206
- await fs.writeFile(fields_path, JSON.stringify(with_schema, null, 2) + '\n');
207
- }
208
- catch { }
209
- }
210
- }
211
- }
212
- catch { }
213
- // Add $schema to page-type config.json
214
- const page_types_dir = path.join(output_dir, 'page-types');
214
+ await stamp_schema_on_dir_configs(path.join(output_dir, 'page-types'), 'config.yaml', '../../.schemas/page-type-config.schema.json');
215
+ await stamp_schema_on_dir_configs(path.join(output_dir, 'blocks'), 'config.yaml', '../../.schemas/block-config.schema.json');
216
+ }
217
+ async function stamp_schema_on_dir_configs(parent_dir, file_name, schema_ref) {
215
218
  try {
216
- const page_types = await fs.readdir(page_types_dir, { withFileTypes: true });
217
- for (const page_type of page_types) {
218
- if (page_type.isDirectory()) {
219
- const config_path = path.join(page_types_dir, page_type.name, 'config.json');
220
- try {
221
- const config = JSON.parse(await fs.readFile(config_path, 'utf-8'));
222
- // Create new object with $schema first
223
- const with_schema = {
224
- $schema: '../../.schemas/page-type-config.schema.json',
225
- ...config
226
- };
227
- await fs.writeFile(config_path, JSON.stringify(with_schema, null, 2) + '\n');
228
- }
229
- catch { }
219
+ const entries = await fs.readdir(parent_dir, { withFileTypes: true });
220
+ for (const entry of entries) {
221
+ if (!entry.isDirectory())
222
+ continue;
223
+ const config_path = path.join(parent_dir, entry.name, file_name);
224
+ try {
225
+ const config = load_yaml(await fs.readFile(config_path, 'utf-8'));
226
+ if (!config || typeof config !== 'object' || Array.isArray(config))
227
+ continue;
228
+ const with_schema = {
229
+ $schema: schema_ref,
230
+ ...config
231
+ };
232
+ await fs.writeFile(config_path, dump_yaml(with_schema, { lineWidth: -1, noRefs: true }));
230
233
  }
234
+ catch { }
231
235
  }
232
236
  }
233
237
  catch { }
234
- // Add $schema to site fields.json
235
- const site_fields_path = path.join(output_dir, 'site/fields.json');
236
- try {
237
- const site_fields = JSON.parse(await fs.readFile(site_fields_path, 'utf-8'));
238
- // Site fields is an array, so we need to add $schema differently
239
- // Since JSON Schema doesn't support $schema in arrays, we'll skip this for now
240
- // IDEs can still use the schema if users manually add it via settings
241
- }
242
- catch { }
243
238
  }
@@ -0,0 +1,7 @@
1
+ interface PushLibraryOptions {
2
+ server?: string;
3
+ dir: string;
4
+ token?: string;
5
+ }
6
+ export declare function push_library(options: PushLibraryOptions): Promise<void>;
7
+ export {};
@@ -0,0 +1,88 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import archiver from 'archiver';
6
+ import { get_auth_token } from '../utils/auth.js';
7
+ function is_local_server(server) {
8
+ try {
9
+ const url = new URL(server);
10
+ return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ export async function push_library(options) {
17
+ const spinner = ora('Reading local library...').start();
18
+ try {
19
+ const workspace_dir = path.resolve(options.dir);
20
+ const library_dir = path.join(workspace_dir, 'library');
21
+ try {
22
+ const stat = await fs.stat(library_dir);
23
+ if (!stat.isDirectory()) {
24
+ throw new Error('not a directory');
25
+ }
26
+ }
27
+ catch {
28
+ spinner.fail(`Library directory not found at ${chalk.cyan(library_dir)}.`);
29
+ process.exit(1);
30
+ }
31
+ const server = options.server?.replace(/\/+$/, '');
32
+ if (!server) {
33
+ spinner.fail('Server URL required. Pass it as the first argument or use --server.');
34
+ process.exit(1);
35
+ }
36
+ const token = options.token || await get_auth_token(server);
37
+ if (!token && !is_local_server(server)) {
38
+ spinner.fail('Authentication required. Use --token or run `primo login` first.');
39
+ process.exit(1);
40
+ }
41
+ spinner.text = 'Packaging library...';
42
+ const zip_buffer = await create_library_zip(workspace_dir);
43
+ spinner.text = 'Pushing library...';
44
+ const form_data = new FormData();
45
+ form_data.append('file', new Blob([zip_buffer]), 'library.zip');
46
+ const headers = {};
47
+ if (token) {
48
+ headers.Authorization = `Bearer ${token}`;
49
+ }
50
+ const response = await fetch(`${server}/api/palacms/import-library`, {
51
+ method: 'POST',
52
+ headers,
53
+ body: form_data
54
+ });
55
+ if (response.status === 404) {
56
+ spinner.fail('Shared library sync is not supported by this palacms server. Update the server before using `primo library push`.');
57
+ process.exit(1);
58
+ }
59
+ if (!response.ok) {
60
+ const error = await response.text();
61
+ spinner.fail(`Push failed: ${error}`);
62
+ process.exit(1);
63
+ }
64
+ const result = await response.json();
65
+ spinner.succeed('Library push complete');
66
+ if (result.summary) {
67
+ console.log('');
68
+ console.log(chalk.dim(' Library imported:'));
69
+ console.log(chalk.dim(` groups/ ${result.summary.groups}`));
70
+ console.log(chalk.dim(` blocks/ ${result.summary.blocks}`));
71
+ }
72
+ }
73
+ catch (error) {
74
+ spinner.fail(`Push failed: ${error instanceof Error ? error.message : error}`);
75
+ process.exit(1);
76
+ }
77
+ }
78
+ async function create_library_zip(workspace_dir) {
79
+ return new Promise((resolve, reject) => {
80
+ const archive = archiver('zip', { zlib: { level: 9 } });
81
+ const chunks = [];
82
+ archive.on('data', (chunk) => chunks.push(chunk));
83
+ archive.on('end', () => resolve(Buffer.concat(chunks)));
84
+ archive.on('error', reject);
85
+ archive.directory(path.join(workspace_dir, 'library'), 'library');
86
+ archive.finalize();
87
+ });
88
+ }
@@ -1,9 +1,11 @@
1
1
  interface PushOptions {
2
2
  server?: string;
3
3
  site?: string;
4
+ only?: string;
4
5
  dir: string;
5
6
  token?: string;
6
7
  preview?: boolean;
8
+ dryRun?: boolean;
7
9
  }
8
10
  export declare function push_site(options: PushOptions): Promise<void>;
9
11
  export {};