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,239 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import inquirer from 'inquirer';
6
+ import { execSync, spawn } from 'child_process';
7
+ export async function publish(options) {
8
+ const spinner = ora('Preparing deployment...').start();
9
+ try {
10
+ const site_dir = path.resolve(options.dir);
11
+ // Read primo.json
12
+ const config_path = path.join(site_dir, 'primo.json');
13
+ let config;
14
+ try {
15
+ const config_data = await fs.readFile(config_path, 'utf-8');
16
+ config = JSON.parse(config_data);
17
+ }
18
+ catch {
19
+ spinner.fail('No primo.json found. Run `primo new` first.');
20
+ process.exit(1);
21
+ }
22
+ spinner.stop();
23
+ // Determine provider
24
+ let provider = options.provider;
25
+ if (!provider) {
26
+ const { selected_provider } = await inquirer.prompt([{
27
+ type: 'list',
28
+ name: 'selected_provider',
29
+ message: 'Where do you want to deploy?',
30
+ choices: [
31
+ { name: 'Railway', value: 'railway' },
32
+ { name: 'Fly.io', value: 'fly' }
33
+ ]
34
+ }]);
35
+ provider = selected_provider;
36
+ }
37
+ // Check if provider CLI is installed
38
+ const cli_installed = await check_provider_cli(provider);
39
+ if (!cli_installed) {
40
+ console.log('');
41
+ console.log(chalk.yellow(`${provider} CLI not found. Install it first:`));
42
+ if (provider === 'railway') {
43
+ console.log(chalk.dim(' npm install -g @railway/cli'));
44
+ console.log(chalk.dim(' railway login'));
45
+ }
46
+ else {
47
+ console.log(chalk.dim(' curl -L https://fly.io/install.sh | sh'));
48
+ console.log(chalk.dim(' fly auth login'));
49
+ }
50
+ process.exit(1);
51
+ }
52
+ // Generate deployment files
53
+ spinner.start('Generating deployment files...');
54
+ await generate_dockerfile(site_dir, config);
55
+ if (provider === 'fly') {
56
+ await generate_fly_toml(site_dir, config);
57
+ }
58
+ spinner.succeed('Deployment files generated');
59
+ // Deploy
60
+ if (provider === 'railway') {
61
+ await deploy_to_railway(site_dir, config);
62
+ }
63
+ else {
64
+ await deploy_to_fly(site_dir, config);
65
+ }
66
+ }
67
+ catch (error) {
68
+ spinner.fail(`Deployment failed: ${error instanceof Error ? error.message : error}`);
69
+ process.exit(1);
70
+ }
71
+ }
72
+ async function check_provider_cli(provider) {
73
+ try {
74
+ if (provider === 'railway') {
75
+ execSync('railway --version', { stdio: 'ignore' });
76
+ }
77
+ else {
78
+ execSync('fly version', { stdio: 'ignore' });
79
+ }
80
+ return true;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ async function generate_dockerfile(site_dir, config) {
87
+ const dockerfile = `# Pala CMS Deployment
88
+ FROM golang:1.22-alpine AS builder
89
+
90
+ RUN apk add --no-cache git
91
+
92
+ WORKDIR /build
93
+ RUN git clone https://github.com/palacms/palacms.git . && \\
94
+ go build -o palacms .
95
+
96
+ FROM alpine:3.19
97
+
98
+ RUN apk add --no-cache ca-certificates
99
+
100
+ WORKDIR /app
101
+
102
+ COPY --from=builder /build/palacms /app/palacms
103
+
104
+ COPY blocks/ /app/pb_data/blocks/
105
+ COPY pages/ /app/pb_data/pages/
106
+ COPY page-types/ /app/pb_data/page-types/
107
+ COPY site/ /app/pb_data/site/
108
+ COPY uploads/ /app/pb_data/uploads/ 2>/dev/null || true
109
+ COPY primo.json /app/pb_data/
110
+
111
+ RUN chmod +x /app/palacms
112
+
113
+ ENV PB_DATA_DIR=/app/pb_data
114
+
115
+ EXPOSE 8080
116
+
117
+ CMD ["/app/palacms", "serve", "--http", "0.0.0.0:8080"]
118
+ `;
119
+ await fs.writeFile(path.join(site_dir, 'Dockerfile'), dockerfile);
120
+ const dockerignore = `node_modules/
121
+ .git/
122
+ .primo/
123
+ *.log
124
+ .DS_Store
125
+ `;
126
+ await fs.writeFile(path.join(site_dir, '.dockerignore'), dockerignore);
127
+ }
128
+ async function generate_fly_toml(site_dir, config) {
129
+ const app_name = config.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
130
+ const fly_toml = `app = "${app_name}"
131
+ primary_region = "sjc"
132
+
133
+ [build]
134
+
135
+ [env]
136
+ PB_DATA_DIR = "/app/pb_data"
137
+
138
+ [http_service]
139
+ internal_port = 8080
140
+ force_https = true
141
+ auto_stop_machines = true
142
+ auto_start_machines = true
143
+ min_machines_running = 0
144
+
145
+ [[vm]]
146
+ memory = "512mb"
147
+ cpu_kind = "shared"
148
+ cpus = 1
149
+
150
+ [mounts]
151
+ source = "pb_data"
152
+ destination = "/app/pb_data"
153
+ `;
154
+ await fs.writeFile(path.join(site_dir, 'fly.toml'), fly_toml);
155
+ }
156
+ async function deploy_to_railway(site_dir, config) {
157
+ console.log('');
158
+ console.log(chalk.cyan('Deploying to Railway...'));
159
+ const spinner = ora('Setting up Railway project...').start();
160
+ try {
161
+ try {
162
+ execSync('railway status', { cwd: site_dir, stdio: 'ignore' });
163
+ spinner.succeed('Linked to existing Railway project');
164
+ }
165
+ catch {
166
+ spinner.text = 'Creating new Railway project...';
167
+ execSync(`railway init --name "${config.name}"`, { cwd: site_dir, stdio: 'inherit' });
168
+ spinner.succeed('Created new Railway project');
169
+ }
170
+ }
171
+ catch (error) {
172
+ spinner.fail('Failed to set up Railway project');
173
+ throw error;
174
+ }
175
+ console.log('');
176
+ console.log(chalk.dim('Building and deploying...'));
177
+ console.log('');
178
+ const deploy_process = spawn('railway', ['up', '--detach'], {
179
+ cwd: site_dir,
180
+ stdio: 'inherit'
181
+ });
182
+ return new Promise((resolve, reject) => {
183
+ deploy_process.on('close', (code) => {
184
+ if (code === 0) {
185
+ console.log('');
186
+ console.log(chalk.green('✓ Deployment started'));
187
+ console.log('');
188
+ console.log(chalk.dim(' railway open - view deployment'));
189
+ console.log(chalk.dim(' railway logs - view logs'));
190
+ resolve();
191
+ }
192
+ else {
193
+ reject(new Error(`Railway deployment failed with code ${code}`));
194
+ }
195
+ });
196
+ });
197
+ }
198
+ async function deploy_to_fly(site_dir, config) {
199
+ console.log('');
200
+ console.log(chalk.cyan('Deploying to Fly.io...'));
201
+ const app_name = config.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
202
+ const spinner = ora('Checking Fly.io app...').start();
203
+ try {
204
+ execSync(`fly status --app ${app_name}`, { cwd: site_dir, stdio: 'ignore' });
205
+ spinner.succeed(`Found existing app: ${app_name}`);
206
+ }
207
+ catch {
208
+ spinner.text = 'Creating Fly.io app...';
209
+ execSync(`fly apps create ${app_name}`, { cwd: site_dir, stdio: 'inherit' });
210
+ spinner.text = 'Creating persistent volume...';
211
+ execSync(`fly volumes create pb_data --size 1 --region sjc --app ${app_name}`, {
212
+ cwd: site_dir,
213
+ stdio: 'inherit'
214
+ });
215
+ spinner.succeed(`Created app: ${app_name}`);
216
+ }
217
+ console.log('');
218
+ console.log(chalk.dim('Building and deploying...'));
219
+ console.log('');
220
+ const deploy_process = spawn('fly', ['deploy'], {
221
+ cwd: site_dir,
222
+ stdio: 'inherit'
223
+ });
224
+ return new Promise((resolve, reject) => {
225
+ deploy_process.on('close', (code) => {
226
+ if (code === 0) {
227
+ console.log('');
228
+ console.log(chalk.green('✓ Deployed'));
229
+ console.log('');
230
+ console.log(chalk.cyan(` https://${app_name}.fly.dev`));
231
+ console.log(chalk.cyan(` https://${app_name}.fly.dev/_/ (admin)`));
232
+ resolve();
233
+ }
234
+ else {
235
+ reject(new Error(`Fly deployment failed with code ${code}`));
236
+ }
237
+ });
238
+ });
239
+ }
@@ -0,0 +1,8 @@
1
+ interface PullOptions {
2
+ server?: string;
3
+ site?: string;
4
+ output: string;
5
+ token?: string;
6
+ }
7
+ export declare function pull_site(options: PullOptions): Promise<void>;
8
+ export {};
@@ -0,0 +1,243 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import extract from 'extract-zip';
6
+ import inquirer from 'inquirer';
7
+ import { get_auth_token } from '../utils/auth.js';
8
+ async function detect_server() {
9
+ // Check common local ports
10
+ const ports = [3000, 8080, 5173];
11
+ for (const port of ports) {
12
+ try {
13
+ const url = `http://127.0.0.1:${port}`;
14
+ const response = await fetch(`${url}/api/health`, {
15
+ signal: AbortSignal.timeout(500)
16
+ });
17
+ if (response.ok) {
18
+ return url;
19
+ }
20
+ }
21
+ catch {
22
+ // Not running on this port
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+ export async function pull_site(options) {
28
+ const spinner = ora('Connecting...').start();
29
+ try {
30
+ // Detect or use provided server
31
+ let server;
32
+ if (options.server) {
33
+ server = options.server;
34
+ }
35
+ else {
36
+ spinner.text = 'Looking for local server...';
37
+ const detected = await detect_server();
38
+ // Default to localhost:3000 if no server detected
39
+ server = detected || 'http://localhost:3000';
40
+ spinner.text = `Using ${server}`;
41
+ }
42
+ // Get auth token (may not be needed for local)
43
+ const token = options.token || await get_auth_token(server);
44
+ // Local servers may not require auth
45
+ const headers = {};
46
+ if (token) {
47
+ headers['Authorization'] = `Bearer ${token}`;
48
+ }
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...');
80
+ }
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;
90
+ }
91
+ }
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);
98
+ }
99
+ }
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
+ });
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);
128
+ 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`));
133
+ console.log('');
134
+ console.log(chalk.green(' Ready for local development!'));
135
+ console.log(chalk.dim(' Run `primo dev` to start the local server'));
136
+ }
137
+ catch (error) {
138
+ spinner.fail(`Export failed: ${error instanceof Error ? error.message : error}`);
139
+ process.exit(1);
140
+ }
141
+ }
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;
148
+ }
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;
154
+ }
155
+ catch { }
156
+ try {
157
+ const pages_dir = path.join(dir, 'pages');
158
+ counts.pages = await count_json_files(pages_dir);
159
+ }
160
+ catch { }
161
+ return counts;
162
+ }
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
+ }
174
+ return count;
175
+ }
176
+ async function copy_schemas(output_dir) {
177
+ // Get path to schemas directory relative to compiled dist file
178
+ 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
181
+ const schemas_src = path.join(project_root, 'schemas');
182
+ const schemas_dest = path.join(output_dir, '.schemas');
183
+ await fs.mkdir(schemas_dest, { recursive: true });
184
+ const schema_files = await fs.readdir(schemas_src);
185
+ for (const file of schema_files) {
186
+ if (file.endsWith('.json')) {
187
+ await fs.copyFile(path.join(schemas_src, file), path.join(schemas_dest, file));
188
+ }
189
+ }
190
+ }
191
+ 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');
215
+ 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 { }
230
+ }
231
+ }
232
+ }
233
+ 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
+ }
@@ -0,0 +1,9 @@
1
+ interface PushOptions {
2
+ server?: string;
3
+ site?: string;
4
+ dir: string;
5
+ token?: string;
6
+ preview?: boolean;
7
+ }
8
+ export declare function push_site(options: PushOptions): Promise<void>;
9
+ export {};
@@ -0,0 +1,118 @@
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
+ export async function push_site(options) {
8
+ const spinner = ora('Reading local files...').start();
9
+ try {
10
+ const site_dir = path.resolve(options.dir);
11
+ // Read primo.json for server/site info
12
+ const config_path = path.join(site_dir, 'primo.json');
13
+ let config = null;
14
+ try {
15
+ const config_data = await fs.readFile(config_path, 'utf-8');
16
+ config = JSON.parse(config_data);
17
+ }
18
+ catch {
19
+ // No config file, must provide options
20
+ }
21
+ const server = options.server || config?.server;
22
+ const site_id = options.site || config?.site_id;
23
+ if (!server) {
24
+ spinner.fail('Server URL required. Use --server or add server field to primo.json.');
25
+ process.exit(1);
26
+ }
27
+ if (!site_id) {
28
+ spinner.fail('Site ID required. Use --site or add site_id field to primo.json.');
29
+ process.exit(1);
30
+ }
31
+ // Get auth token
32
+ const token = options.token || await get_auth_token(server);
33
+ if (!token) {
34
+ spinner.fail('Authentication required. Use --token or run `primo login` first.');
35
+ process.exit(1);
36
+ }
37
+ // Create ZIP of the site directory
38
+ spinner.text = 'Packaging files...';
39
+ const zip_buffer = await create_zip(site_dir);
40
+ // Send to server
41
+ const endpoint = options.preview
42
+ ? `${server}/api/palacms/import/${site_id}/preview`
43
+ : `${server}/api/palacms/import/${site_id}`;
44
+ spinner.text = options.preview ? 'Previewing changes...' : 'Pushing changes...';
45
+ const form_data = new FormData();
46
+ form_data.append('file', new Blob([zip_buffer]), 'site.zip');
47
+ const response = await fetch(endpoint, {
48
+ method: 'POST',
49
+ headers: {
50
+ 'Authorization': `Bearer ${token}`
51
+ },
52
+ body: form_data
53
+ });
54
+ if (!response.ok) {
55
+ const error = await response.text();
56
+ spinner.fail(`Push failed: ${error}`);
57
+ process.exit(1);
58
+ }
59
+ const result = await response.json();
60
+ if (options.preview) {
61
+ spinner.succeed('Preview complete');
62
+ console.log('');
63
+ print_diff(result.diff);
64
+ console.log('');
65
+ console.log(chalk.dim(' Run without --preview to apply these changes'));
66
+ }
67
+ else {
68
+ spinner.succeed('Push complete');
69
+ console.log('');
70
+ print_diff(result.diff);
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_zip(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
+ // Add directories
86
+ const dirs_to_include = ['blocks', 'page-types', 'pages', 'site', 'uploads'];
87
+ for (const subdir of dirs_to_include) {
88
+ const full_path = path.join(dir, subdir);
89
+ archive.directory(full_path, subdir);
90
+ }
91
+ // Add primo.json
92
+ archive.file(path.join(dir, 'primo.json'), { name: 'primo.json' });
93
+ archive.finalize();
94
+ });
95
+ }
96
+ function print_diff(diff) {
97
+ let has_changes = false;
98
+ for (const [section, changes] of Object.entries(diff)) {
99
+ const { added, modified, deleted } = changes;
100
+ if (added.length === 0 && modified.length === 0 && deleted.length === 0) {
101
+ continue;
102
+ }
103
+ has_changes = true;
104
+ console.log(chalk.bold(` ${section}:`));
105
+ for (const item of added) {
106
+ console.log(chalk.green(` + ${item}`));
107
+ }
108
+ for (const item of modified) {
109
+ console.log(chalk.yellow(` ~ ${item}`));
110
+ }
111
+ for (const item of deleted) {
112
+ console.log(chalk.red(` - ${item}`));
113
+ }
114
+ }
115
+ if (!has_changes) {
116
+ console.log(chalk.dim(' No changes detected'));
117
+ }
118
+ }
@@ -0,0 +1,7 @@
1
+ export declare function normalize_site(site_dir: string): Promise<void>;
2
+ interface ValidateOptions {
3
+ dir: string;
4
+ strict?: boolean;
5
+ }
6
+ export declare function validate_site(options: ValidateOptions): Promise<void>;
7
+ export {};