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.
- package/README.md +183 -0
- package/dist/commands/build.d.ts +6 -0
- package/dist/commands/build.js +379 -0
- package/dist/commands/deploy.d.ts +6 -0
- package/dist/commands/deploy.js +261 -0
- package/dist/commands/dev.d.ts +6 -0
- package/dist/commands/dev.js +516 -0
- package/dist/commands/export.d.ts +8 -0
- package/dist/commands/export.js +163 -0
- package/dist/commands/import.d.ts +9 -0
- package/dist/commands/import.js +118 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +68 -0
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.js +124 -0
- package/dist/commands/new.d.ts +7 -0
- package/dist/commands/new.js +507 -0
- package/dist/commands/publish.d.ts +6 -0
- package/dist/commands/publish.js +239 -0
- package/dist/commands/pull.d.ts +8 -0
- package/dist/commands/pull.js +243 -0
- package/dist/commands/push.d.ts +9 -0
- package/dist/commands/push.js +118 -0
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +514 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +70 -0
- package/dist/utils/auth.d.ts +2 -0
- package/dist/utils/auth.js +29 -0
- package/dist/utils/binary.d.ts +5 -0
- package/dist/utils/binary.js +129 -0
- package/package.json +53 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { watch } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import archiver from 'archiver';
|
|
8
|
+
import extract from 'extract-zip';
|
|
9
|
+
import { ensure_binary, ensure_data_dir } from '../utils/binary.js';
|
|
10
|
+
import { normalize_site } from './validate.js';
|
|
11
|
+
let cms_process = null;
|
|
12
|
+
let watchers = [];
|
|
13
|
+
let reimport_timeout = null;
|
|
14
|
+
let sync_interval = null;
|
|
15
|
+
let is_syncing = false;
|
|
16
|
+
let is_importing = false;
|
|
17
|
+
let is_cleaning_up = false;
|
|
18
|
+
// Track files written by sync to prevent watcher from re-pushing them
|
|
19
|
+
// Map of filepath -> mtime (ms) when we wrote it
|
|
20
|
+
const synced_files = new Map();
|
|
21
|
+
// Check if a port is in use
|
|
22
|
+
async function is_port_in_use(port) {
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
|
25
|
+
method: 'GET',
|
|
26
|
+
signal: AbortSignal.timeout(500)
|
|
27
|
+
});
|
|
28
|
+
return response.ok;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Fetch with timeout helper
|
|
35
|
+
async function fetch_with_timeout(url, options = {}, timeout_ms = 10000) {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const timeout_id = setTimeout(() => controller.abort(), timeout_ms);
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
40
|
+
return response;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
clearTimeout(timeout_id);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Kill process with escalation to SIGKILL
|
|
47
|
+
async function kill_process(proc) {
|
|
48
|
+
if (!proc || proc.killed)
|
|
49
|
+
return;
|
|
50
|
+
proc.kill('SIGTERM');
|
|
51
|
+
// Wait up to 3 seconds for graceful shutdown
|
|
52
|
+
const start = Date.now();
|
|
53
|
+
while (Date.now() - start < 3000) {
|
|
54
|
+
if (proc.killed || proc.exitCode !== null)
|
|
55
|
+
return;
|
|
56
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
57
|
+
}
|
|
58
|
+
// Force kill if still running
|
|
59
|
+
if (!proc.killed && proc.exitCode === null) {
|
|
60
|
+
proc.kill('SIGKILL');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function dev_server(options) {
|
|
64
|
+
const spinner = ora('Starting Pala...').start();
|
|
65
|
+
try {
|
|
66
|
+
const base_dir = path.resolve(options.dir);
|
|
67
|
+
// Check for server.json (multi-site mode) or primo.json (single-site mode)
|
|
68
|
+
const server_config_path = path.join(base_dir, 'server.json');
|
|
69
|
+
const site_config_path = path.join(base_dir, 'primo.json');
|
|
70
|
+
let server_config = {};
|
|
71
|
+
let sites = [];
|
|
72
|
+
let is_server_mode = false;
|
|
73
|
+
try {
|
|
74
|
+
const server_data = await fs.readFile(server_config_path, 'utf-8');
|
|
75
|
+
server_config = JSON.parse(server_data);
|
|
76
|
+
is_server_mode = true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// No server.json, check for primo.json
|
|
80
|
+
}
|
|
81
|
+
let port = server_config.port || parseInt(options.port, 10);
|
|
82
|
+
// Find an available port
|
|
83
|
+
const max_port_attempts = 10;
|
|
84
|
+
for (let i = 0; i < max_port_attempts; i++) {
|
|
85
|
+
if (!await is_port_in_use(port))
|
|
86
|
+
break;
|
|
87
|
+
port++;
|
|
88
|
+
if (i === max_port_attempts - 1) {
|
|
89
|
+
spinner.fail(`Ports ${port - max_port_attempts + 1}-${port} are all in use`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (is_server_mode) {
|
|
94
|
+
// Auto-discover sites in subdirectories
|
|
95
|
+
spinner.text = 'Discovering sites...';
|
|
96
|
+
sites = await discover_sites(base_dir);
|
|
97
|
+
// Sites can be empty - the dashboard will show the site creation screen
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Single site mode
|
|
101
|
+
try {
|
|
102
|
+
const config_data = await fs.readFile(site_config_path, 'utf-8');
|
|
103
|
+
const config = JSON.parse(config_data);
|
|
104
|
+
sites = [{ dir: base_dir, config }];
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
spinner.fail('No server.json or primo.json found. Run `primo new` first.');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Ensure binary is installed
|
|
112
|
+
spinner.text = 'Checking palacms...';
|
|
113
|
+
const binary_path = await ensure_binary();
|
|
114
|
+
// Create data directory in project folder
|
|
115
|
+
const data_dir = await ensure_data_dir(base_dir);
|
|
116
|
+
spinner.text = 'Starting CMS...';
|
|
117
|
+
// Start the CMS binary with dev mode enabled
|
|
118
|
+
cms_process = spawn(binary_path, ['serve', '--http', `127.0.0.1:${port}`, '--dir', data_dir], {
|
|
119
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
|
+
env: { ...process.env, PALA_DEV_MODE: '1' }
|
|
121
|
+
});
|
|
122
|
+
// Capture stderr for errors
|
|
123
|
+
let stderr_output = '';
|
|
124
|
+
cms_process.stderr?.on('data', (data) => {
|
|
125
|
+
stderr_output += data.toString();
|
|
126
|
+
});
|
|
127
|
+
// Wait for CMS to be ready
|
|
128
|
+
const ready = await wait_for_ready(`http://127.0.0.1:${port}`, 30000);
|
|
129
|
+
if (!ready) {
|
|
130
|
+
spinner.fail('CMS failed to start');
|
|
131
|
+
if (stderr_output) {
|
|
132
|
+
console.log(chalk.red(stderr_output));
|
|
133
|
+
}
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
// Normalize and load all sites
|
|
137
|
+
spinner.text = `Loading ${sites.length} site${sites.length > 1 ? 's' : ''}...`;
|
|
138
|
+
const api_url = `http://127.0.0.1:${port}`;
|
|
139
|
+
for (const site of sites) {
|
|
140
|
+
await normalize_site(site.dir);
|
|
141
|
+
await import_site_files(site.dir, api_url, site.config, port);
|
|
142
|
+
}
|
|
143
|
+
// Verify all sites are accessible before proceeding
|
|
144
|
+
spinner.text = 'Verifying sites...';
|
|
145
|
+
for (const site of sites) {
|
|
146
|
+
await verify_site_ready(api_url, site.config.site_id);
|
|
147
|
+
}
|
|
148
|
+
spinner.succeed('Pala running');
|
|
149
|
+
console.log('');
|
|
150
|
+
if (is_server_mode) {
|
|
151
|
+
console.log(` ${chalk.cyan('Dashboard:')} http://127.0.0.1:${port}/admin/dashboard`);
|
|
152
|
+
console.log('');
|
|
153
|
+
}
|
|
154
|
+
for (const site of sites) {
|
|
155
|
+
const host = site.config.host || `${site.config.name.toLowerCase().replace(/\s+/g, '-')}.localhost:${port}`;
|
|
156
|
+
console.log(` ${chalk.cyan(site.config.name)}`);
|
|
157
|
+
console.log(` ${chalk.dim('Edit:')} http://${host}/admin/site`);
|
|
158
|
+
console.log(` ${chalk.dim('Preview:')} http://${host}/`);
|
|
159
|
+
}
|
|
160
|
+
if (sites.length > 0 || !is_server_mode) {
|
|
161
|
+
console.log('');
|
|
162
|
+
}
|
|
163
|
+
// Start watching for file changes
|
|
164
|
+
const dirs_to_watch = ['blocks', 'page-types', 'pages', 'site'];
|
|
165
|
+
const known_sites = new Set(sites.map(s => s.dir));
|
|
166
|
+
const setup_site_watchers = (site) => {
|
|
167
|
+
const schedule_reimport = () => {
|
|
168
|
+
if (reimport_timeout) {
|
|
169
|
+
clearTimeout(reimport_timeout);
|
|
170
|
+
}
|
|
171
|
+
reimport_timeout = setTimeout(async () => {
|
|
172
|
+
try {
|
|
173
|
+
is_importing = true;
|
|
174
|
+
await normalize_site(site.dir);
|
|
175
|
+
await import_site_files(site.dir, api_url, site.config, port);
|
|
176
|
+
console.log(chalk.green(` ✓ ${site.config.name} pushed`));
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
console.log(chalk.red(` ✗ ${site.config.name} push failed: ${err}`));
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
is_importing = false;
|
|
183
|
+
}
|
|
184
|
+
}, 300);
|
|
185
|
+
};
|
|
186
|
+
for (const dir of dirs_to_watch) {
|
|
187
|
+
const watch_path = path.join(site.dir, dir);
|
|
188
|
+
try {
|
|
189
|
+
const watcher = watch(watch_path, { recursive: true }, async (event, filename) => {
|
|
190
|
+
if (!filename || filename.startsWith('.'))
|
|
191
|
+
return;
|
|
192
|
+
// Check if this file was just written by sync
|
|
193
|
+
const full_path = path.join(watch_path, filename);
|
|
194
|
+
const synced_mtime = synced_files.get(full_path);
|
|
195
|
+
if (synced_mtime) {
|
|
196
|
+
try {
|
|
197
|
+
const stat = await fs.stat(full_path);
|
|
198
|
+
// If mtime matches what we wrote, skip this event
|
|
199
|
+
if (Math.abs(stat.mtimeMs - synced_mtime) < 1000) {
|
|
200
|
+
synced_files.delete(full_path);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// File might have been deleted
|
|
206
|
+
}
|
|
207
|
+
synced_files.delete(full_path);
|
|
208
|
+
}
|
|
209
|
+
console.log(chalk.dim(` ${site.config.name}: ${dir}/${filename}`));
|
|
210
|
+
schedule_reimport();
|
|
211
|
+
});
|
|
212
|
+
watchers.push(watcher);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Directory might not exist
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
// Set up watchers for existing sites
|
|
220
|
+
for (const site of sites) {
|
|
221
|
+
setup_site_watchers(site);
|
|
222
|
+
}
|
|
223
|
+
// Simple HTTP server for reload requests (only in server mode)
|
|
224
|
+
if (is_server_mode) {
|
|
225
|
+
const http = await import('http');
|
|
226
|
+
const reload_server = http.createServer(async (req, res) => {
|
|
227
|
+
if (req.method !== 'POST' || req.url !== '/reload') {
|
|
228
|
+
res.writeHead(404);
|
|
229
|
+
res.end();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const new_sites = await discover_sites(base_dir);
|
|
233
|
+
for (const site of new_sites) {
|
|
234
|
+
if (known_sites.has(site.dir))
|
|
235
|
+
continue;
|
|
236
|
+
known_sites.add(site.dir);
|
|
237
|
+
sites.push(site);
|
|
238
|
+
await normalize_site(site.dir);
|
|
239
|
+
await import_site_files(site.dir, api_url, site.config, port);
|
|
240
|
+
setup_site_watchers(site);
|
|
241
|
+
const host = site.config.host || `${path.basename(site.dir).toLowerCase().replace(/\s+/g, '-')}.localhost:${port}`;
|
|
242
|
+
console.log(chalk.green(` ✓ New site loaded: ${site.config.name}`));
|
|
243
|
+
console.log(` ${chalk.dim('Edit:')} http://${host}/admin/site`);
|
|
244
|
+
console.log(` ${chalk.dim('Preview:')} http://${host}/`);
|
|
245
|
+
}
|
|
246
|
+
res.writeHead(200);
|
|
247
|
+
res.end('ok');
|
|
248
|
+
});
|
|
249
|
+
reload_server.listen(port + 1, '127.0.0.1');
|
|
250
|
+
}
|
|
251
|
+
// Start polling for CMS changes (sync back to local files)
|
|
252
|
+
sync_interval = setInterval(async () => {
|
|
253
|
+
if (is_syncing || is_importing)
|
|
254
|
+
return;
|
|
255
|
+
for (const site of sites) {
|
|
256
|
+
try {
|
|
257
|
+
await sync_from_cms(site.dir, api_url, site.config);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Silently ignore sync errors
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}, 5000);
|
|
264
|
+
console.log(chalk.dim(' Watching for changes...'));
|
|
265
|
+
console.log(chalk.dim(' Press Ctrl+C to stop'));
|
|
266
|
+
// Handle cleanup
|
|
267
|
+
const cleanup = async () => {
|
|
268
|
+
if (is_cleaning_up)
|
|
269
|
+
return;
|
|
270
|
+
is_cleaning_up = true;
|
|
271
|
+
console.log(chalk.dim('\n Shutting down...'));
|
|
272
|
+
for (const watcher of watchers) {
|
|
273
|
+
try {
|
|
274
|
+
watcher.close();
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// Ignore watcher close errors
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
watchers = [];
|
|
281
|
+
if (reimport_timeout) {
|
|
282
|
+
clearTimeout(reimport_timeout);
|
|
283
|
+
reimport_timeout = null;
|
|
284
|
+
}
|
|
285
|
+
if (sync_interval) {
|
|
286
|
+
clearInterval(sync_interval);
|
|
287
|
+
sync_interval = null;
|
|
288
|
+
}
|
|
289
|
+
if (cms_process) {
|
|
290
|
+
await kill_process(cms_process);
|
|
291
|
+
cms_process = null;
|
|
292
|
+
}
|
|
293
|
+
process.exit(0);
|
|
294
|
+
};
|
|
295
|
+
process.on('SIGINT', cleanup);
|
|
296
|
+
process.on('SIGTERM', cleanup);
|
|
297
|
+
process.on('uncaughtException', (err) => {
|
|
298
|
+
console.error(chalk.red(`\n Uncaught exception: ${err.message}`));
|
|
299
|
+
cleanup();
|
|
300
|
+
});
|
|
301
|
+
process.on('unhandledRejection', (reason) => {
|
|
302
|
+
console.error(chalk.red(`\n Unhandled rejection: ${reason}`));
|
|
303
|
+
cleanup();
|
|
304
|
+
});
|
|
305
|
+
// Keep process alive
|
|
306
|
+
await new Promise(() => { });
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
spinner.fail(`Failed to start: ${error instanceof Error ? error.message : error}`);
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async function discover_sites(base_dir) {
|
|
314
|
+
const sites = [];
|
|
315
|
+
const entries = await fs.readdir(base_dir, { withFileTypes: true });
|
|
316
|
+
for (const entry of entries) {
|
|
317
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
318
|
+
const site_dir = path.join(base_dir, entry.name);
|
|
319
|
+
const config_path = path.join(site_dir, 'primo.json');
|
|
320
|
+
try {
|
|
321
|
+
const config_data = await fs.readFile(config_path, 'utf-8');
|
|
322
|
+
const config = JSON.parse(config_data);
|
|
323
|
+
sites.push({ dir: site_dir, config });
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// Not a site directory
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return sites;
|
|
331
|
+
}
|
|
332
|
+
async function wait_for_ready(url, timeout_ms) {
|
|
333
|
+
const start = Date.now();
|
|
334
|
+
const health_url = `${url}/api/health`;
|
|
335
|
+
while (Date.now() - start < timeout_ms) {
|
|
336
|
+
try {
|
|
337
|
+
const response = await fetch_with_timeout(health_url, {}, 2000);
|
|
338
|
+
if (response.ok) {
|
|
339
|
+
// Health check passed, but collections may not be ready yet
|
|
340
|
+
// Give PocketBase a moment to finish initializing
|
|
341
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
// Server not ready yet
|
|
347
|
+
}
|
|
348
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
349
|
+
}
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
async function verify_site_ready(api_url, site_id) {
|
|
353
|
+
const max_attempts = 20;
|
|
354
|
+
const delay_ms = 100;
|
|
355
|
+
for (let i = 0; i < max_attempts; i++) {
|
|
356
|
+
try {
|
|
357
|
+
const response = await fetch_with_timeout(`${api_url}/api/collections/sites/records/${site_id}`, {}, 5000);
|
|
358
|
+
if (response.ok) {
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// Site not ready yet
|
|
364
|
+
}
|
|
365
|
+
await new Promise(resolve => setTimeout(resolve, delay_ms));
|
|
366
|
+
}
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
async function import_site_files(site_dir, api_url, config, port) {
|
|
370
|
+
const site_name = config.name || 'My Site';
|
|
371
|
+
const site_id = config.site_id;
|
|
372
|
+
// Create ZIP of site files
|
|
373
|
+
const zip_buffer = await create_site_zip(site_dir);
|
|
374
|
+
// Use hostname from config, or generate from folder name
|
|
375
|
+
const folder_name = path.basename(site_dir);
|
|
376
|
+
const host = config.host || (folder_name.includes('.')
|
|
377
|
+
? `${folder_name}:${port}` // Looks like a domain
|
|
378
|
+
: `${folder_name.toLowerCase().replace(/\s+/g, '-')}.localhost:${port}`);
|
|
379
|
+
// Retry bootstrap up to 3 times (collections may not be ready immediately)
|
|
380
|
+
const max_retries = 3;
|
|
381
|
+
for (let attempt = 1; attempt <= max_retries; attempt++) {
|
|
382
|
+
const form_data = new FormData();
|
|
383
|
+
form_data.append('site_id', site_id);
|
|
384
|
+
form_data.append('name', site_name);
|
|
385
|
+
form_data.append('host', host);
|
|
386
|
+
form_data.append('file', new Blob([zip_buffer]), 'site.zip');
|
|
387
|
+
try {
|
|
388
|
+
const bootstrap_response = await fetch_with_timeout(`${api_url}/api/palacms/bootstrap`, {
|
|
389
|
+
method: 'POST',
|
|
390
|
+
body: form_data
|
|
391
|
+
}, 30000); // 30s timeout for imports
|
|
392
|
+
if (bootstrap_response.ok) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const error_text = await bootstrap_response.text();
|
|
396
|
+
// Check if it's a collection not found error (timing issue)
|
|
397
|
+
if (error_text.includes('collection') && attempt < max_retries) {
|
|
398
|
+
await new Promise(resolve => setTimeout(resolve, 500 * attempt));
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
console.log(chalk.yellow(` Bootstrap failed (${bootstrap_response.status}): ${error_text}`));
|
|
402
|
+
// Bootstrap failed, try regular import
|
|
403
|
+
const import_form = new FormData();
|
|
404
|
+
import_form.append('file', new Blob([zip_buffer]), 'site.zip');
|
|
405
|
+
const import_response = await fetch_with_timeout(`${api_url}/api/palacms/import/${site_id}`, {
|
|
406
|
+
method: 'POST',
|
|
407
|
+
body: import_form
|
|
408
|
+
}, 30000); // 30s timeout for imports
|
|
409
|
+
if (!import_response.ok) {
|
|
410
|
+
const import_error = await import_response.text();
|
|
411
|
+
console.log(chalk.yellow(` Import failed (${import_response.status}): ${import_error}`));
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
if (attempt < max_retries) {
|
|
417
|
+
await new Promise(resolve => setTimeout(resolve, 500 * attempt));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
console.log(chalk.yellow(` Import error: ${err}`));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
async function create_site_zip(dir) {
|
|
425
|
+
return new Promise((resolve, reject) => {
|
|
426
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
427
|
+
const chunks = [];
|
|
428
|
+
archive.on('data', chunk => chunks.push(chunk));
|
|
429
|
+
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
|
430
|
+
archive.on('error', reject);
|
|
431
|
+
const dirs_to_include = ['blocks', 'page-types', 'pages', 'site', 'uploads'];
|
|
432
|
+
for (const subdir of dirs_to_include) {
|
|
433
|
+
const full_path = path.join(dir, subdir);
|
|
434
|
+
archive.directory(full_path, subdir);
|
|
435
|
+
}
|
|
436
|
+
const primo_json = path.join(dir, 'primo.json');
|
|
437
|
+
archive.file(primo_json, { name: 'primo.json' });
|
|
438
|
+
archive.finalize();
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
async function sync_from_cms(site_dir, api_url, config) {
|
|
442
|
+
is_syncing = true;
|
|
443
|
+
try {
|
|
444
|
+
const response = await fetch_with_timeout(`${api_url}/api/palacms/export/${config.site_id}`, {}, 15000);
|
|
445
|
+
if (!response.ok)
|
|
446
|
+
return;
|
|
447
|
+
const zip_data = await response.arrayBuffer();
|
|
448
|
+
// Extract to temp directory
|
|
449
|
+
const temp_dir = path.join(site_dir, '.primo', 'sync-temp');
|
|
450
|
+
const temp_zip = path.join(temp_dir, 'export.zip');
|
|
451
|
+
await fs.mkdir(temp_dir, { recursive: true });
|
|
452
|
+
await fs.writeFile(temp_zip, Buffer.from(zip_data));
|
|
453
|
+
await extract(temp_zip, { dir: temp_dir });
|
|
454
|
+
await fs.unlink(temp_zip);
|
|
455
|
+
// Compare and sync files
|
|
456
|
+
const dirs_to_sync = ['blocks', 'page-types', 'pages', 'site'];
|
|
457
|
+
const changed_files = [];
|
|
458
|
+
for (const dir of dirs_to_sync) {
|
|
459
|
+
const temp_path = path.join(temp_dir, dir);
|
|
460
|
+
const local_path = path.join(site_dir, dir);
|
|
461
|
+
try {
|
|
462
|
+
const files = await sync_directory(temp_path, local_path, dir);
|
|
463
|
+
changed_files.push(...files);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// Directory might not exist in export
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Clean up temp directory
|
|
470
|
+
await fs.rm(temp_dir, { recursive: true, force: true });
|
|
471
|
+
if (changed_files.length > 0) {
|
|
472
|
+
for (const file of changed_files) {
|
|
473
|
+
console.log(chalk.blue(` ↓ ${config.name}: ${file}`));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
is_syncing = false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async function sync_directory(src, dest, relative_path = '') {
|
|
482
|
+
const changed_files = [];
|
|
483
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
484
|
+
await fs.mkdir(dest, { recursive: true });
|
|
485
|
+
for (const entry of entries) {
|
|
486
|
+
const src_path = path.join(src, entry.name);
|
|
487
|
+
const dest_path = path.join(dest, entry.name);
|
|
488
|
+
const file_relative = relative_path ? `${relative_path}/${entry.name}` : entry.name;
|
|
489
|
+
if (entry.isDirectory()) {
|
|
490
|
+
const nested = await sync_directory(src_path, dest_path, file_relative);
|
|
491
|
+
changed_files.push(...nested);
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
const src_content = await fs.readFile(src_path, 'utf-8');
|
|
495
|
+
let dest_content = '';
|
|
496
|
+
try {
|
|
497
|
+
dest_content = await fs.readFile(dest_path, 'utf-8');
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
// File doesn't exist locally
|
|
501
|
+
}
|
|
502
|
+
// Normalize to handle trailing newline/whitespace differences
|
|
503
|
+
if (src_content.trim() !== dest_content.trim()) {
|
|
504
|
+
// Track this file BEFORE writing to avoid race with watcher
|
|
505
|
+
// Use current time as estimate, watcher allows 1 second tolerance
|
|
506
|
+
synced_files.set(dest_path, Date.now());
|
|
507
|
+
await fs.writeFile(dest_path, src_content);
|
|
508
|
+
// Update with actual mtime after write
|
|
509
|
+
const stat = await fs.stat(dest_path);
|
|
510
|
+
synced_files.set(dest_path, stat.mtimeMs);
|
|
511
|
+
changed_files.push(file_relative);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return changed_files;
|
|
516
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
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 { get_auth_token } from '../utils/auth.js';
|
|
7
|
+
export async function export_site(options) {
|
|
8
|
+
const spinner = ora('Connecting to server...').start();
|
|
9
|
+
try {
|
|
10
|
+
// Get auth token
|
|
11
|
+
const token = options.token || await get_auth_token(options.server);
|
|
12
|
+
if (!token) {
|
|
13
|
+
spinner.fail('Authentication required. Use --token or run `pala login` first.');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
// Create output directory
|
|
17
|
+
const output_dir = path.resolve(options.output);
|
|
18
|
+
await fs.mkdir(output_dir, { recursive: true });
|
|
19
|
+
// Fetch the export
|
|
20
|
+
spinner.text = 'Exporting site...';
|
|
21
|
+
const response = await fetch(`${options.server}/api/palacms/export/${options.site}`, {
|
|
22
|
+
headers: {
|
|
23
|
+
'Authorization': `Bearer ${token}`
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const error = await response.text();
|
|
28
|
+
spinner.fail(`Export failed: ${error}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
// Save ZIP temporarily
|
|
32
|
+
const zip_data = await response.arrayBuffer();
|
|
33
|
+
const temp_zip = path.join(output_dir, '.pala-export.zip');
|
|
34
|
+
await fs.writeFile(temp_zip, Buffer.from(zip_data));
|
|
35
|
+
// Extract ZIP
|
|
36
|
+
spinner.text = 'Extracting files...';
|
|
37
|
+
await extract(temp_zip, { dir: output_dir });
|
|
38
|
+
// Clean up temp ZIP
|
|
39
|
+
await fs.unlink(temp_zip);
|
|
40
|
+
// Copy JSON schemas
|
|
41
|
+
spinner.text = 'Adding JSON schemas...';
|
|
42
|
+
await copy_schemas(output_dir);
|
|
43
|
+
// Add $schema references
|
|
44
|
+
await add_schema_references(output_dir);
|
|
45
|
+
spinner.succeed(`Site exported to ${chalk.cyan(output_dir)}`);
|
|
46
|
+
// Show summary
|
|
47
|
+
const files = await count_files(output_dir);
|
|
48
|
+
console.log('');
|
|
49
|
+
console.log(chalk.dim(' Files exported:'));
|
|
50
|
+
console.log(chalk.dim(` blocks/ ${files.blocks} blocks`));
|
|
51
|
+
console.log(chalk.dim(` page-types/ ${files.page_types} page types`));
|
|
52
|
+
console.log(chalk.dim(` pages/ ${files.pages} pages`));
|
|
53
|
+
console.log('');
|
|
54
|
+
console.log(chalk.green(' Ready for local development!'));
|
|
55
|
+
console.log(chalk.dim(' Run `pala dev` to start the local server'));
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
spinner.fail(`Export failed: ${error instanceof Error ? error.message : error}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function count_files(dir) {
|
|
63
|
+
const counts = { blocks: 0, page_types: 0, pages: 0 };
|
|
64
|
+
try {
|
|
65
|
+
const blocks_dir = path.join(dir, 'blocks');
|
|
66
|
+
const entries = await fs.readdir(blocks_dir, { withFileTypes: true });
|
|
67
|
+
counts.blocks = entries.filter(e => e.isDirectory()).length;
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
try {
|
|
71
|
+
const pt_dir = path.join(dir, 'page-types');
|
|
72
|
+
const entries = await fs.readdir(pt_dir, { withFileTypes: true });
|
|
73
|
+
counts.page_types = entries.filter(e => e.isDirectory()).length;
|
|
74
|
+
}
|
|
75
|
+
catch { }
|
|
76
|
+
try {
|
|
77
|
+
const pages_dir = path.join(dir, 'pages');
|
|
78
|
+
counts.pages = await count_json_files(pages_dir);
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
return counts;
|
|
82
|
+
}
|
|
83
|
+
async function count_json_files(dir) {
|
|
84
|
+
let count = 0;
|
|
85
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (entry.isDirectory()) {
|
|
88
|
+
count += await count_json_files(path.join(dir, entry.name));
|
|
89
|
+
}
|
|
90
|
+
else if (entry.name.endsWith('.json')) {
|
|
91
|
+
count++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return count;
|
|
95
|
+
}
|
|
96
|
+
async function copy_schemas(output_dir) {
|
|
97
|
+
// Get path to schemas directory relative to compiled dist file
|
|
98
|
+
const current_file = new URL(import.meta.url).pathname;
|
|
99
|
+
const dist_dir = path.dirname(path.dirname(current_file)); // dist/
|
|
100
|
+
const project_root = path.dirname(dist_dir); // project root
|
|
101
|
+
const schemas_src = path.join(project_root, 'schemas');
|
|
102
|
+
const schemas_dest = path.join(output_dir, '.schemas');
|
|
103
|
+
await fs.mkdir(schemas_dest, { recursive: true });
|
|
104
|
+
const schema_files = await fs.readdir(schemas_src);
|
|
105
|
+
for (const file of schema_files) {
|
|
106
|
+
if (file.endsWith('.json')) {
|
|
107
|
+
await fs.copyFile(path.join(schemas_src, file), path.join(schemas_dest, file));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function add_schema_references(output_dir) {
|
|
112
|
+
// Add $schema to block fields.json
|
|
113
|
+
const blocks_dir = path.join(output_dir, 'blocks');
|
|
114
|
+
try {
|
|
115
|
+
const blocks = await fs.readdir(blocks_dir, { withFileTypes: true });
|
|
116
|
+
for (const block of blocks) {
|
|
117
|
+
if (block.isDirectory()) {
|
|
118
|
+
const fields_path = path.join(blocks_dir, block.name, 'fields.json');
|
|
119
|
+
try {
|
|
120
|
+
const fields = JSON.parse(await fs.readFile(fields_path, 'utf-8'));
|
|
121
|
+
// Create new object with $schema first
|
|
122
|
+
const with_schema = {
|
|
123
|
+
$schema: '../../.schemas/fields.schema.json',
|
|
124
|
+
...fields
|
|
125
|
+
};
|
|
126
|
+
await fs.writeFile(fields_path, JSON.stringify(with_schema, null, 2) + '\n');
|
|
127
|
+
}
|
|
128
|
+
catch { }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch { }
|
|
133
|
+
// Add $schema to page-type config.json
|
|
134
|
+
const page_types_dir = path.join(output_dir, 'page-types');
|
|
135
|
+
try {
|
|
136
|
+
const page_types = await fs.readdir(page_types_dir, { withFileTypes: true });
|
|
137
|
+
for (const page_type of page_types) {
|
|
138
|
+
if (page_type.isDirectory()) {
|
|
139
|
+
const config_path = path.join(page_types_dir, page_type.name, 'config.json');
|
|
140
|
+
try {
|
|
141
|
+
const config = JSON.parse(await fs.readFile(config_path, 'utf-8'));
|
|
142
|
+
// Create new object with $schema first
|
|
143
|
+
const with_schema = {
|
|
144
|
+
$schema: '../../.schemas/page-type-config.schema.json',
|
|
145
|
+
...config
|
|
146
|
+
};
|
|
147
|
+
await fs.writeFile(config_path, JSON.stringify(with_schema, null, 2) + '\n');
|
|
148
|
+
}
|
|
149
|
+
catch { }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch { }
|
|
154
|
+
// Add $schema to site fields.json
|
|
155
|
+
const site_fields_path = path.join(output_dir, 'site/fields.json');
|
|
156
|
+
try {
|
|
157
|
+
const site_fields = JSON.parse(await fs.readFile(site_fields_path, 'utf-8'));
|
|
158
|
+
// Site fields is an array, so we need to add $schema differently
|
|
159
|
+
// Since JSON Schema doesn't support $schema in arrays, we'll skip this for now
|
|
160
|
+
// IDEs can still use the schema if users manually add it via settings
|
|
161
|
+
}
|
|
162
|
+
catch { }
|
|
163
|
+
}
|