primo-cli 0.1.2 → 0.1.4

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 (39) hide show
  1. package/README.md +113 -41
  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 +9 -5
  33. package/scripts/postinstall.js +1 -1
  34. package/dist/commands/export.d.ts +0 -8
  35. package/dist/commands/export.js +0 -163
  36. package/dist/commands/import.d.ts +0 -9
  37. package/dist/commands/import.js +0 -118
  38. package/dist/commands/publish.d.ts +0 -6
  39. package/dist/commands/publish.js +0 -239
@@ -4,75 +4,382 @@ import chalk from 'chalk';
4
4
  import ora from 'ora';
5
5
  import archiver from 'archiver';
6
6
  import { get_auth_token } from '../utils/auth.js';
7
+ import { read_site_config, get_site_config_path, SITE_CONFIG_FILE } from '../utils/site-config.js';
8
+ import { get_server_config_path, read_server_config } from '../utils/server-config.js';
9
+ async function path_exists(p) {
10
+ try {
11
+ await fs.access(p);
12
+ return true;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
7
18
  export async function push_site(options) {
19
+ const root_dir = path.resolve(options.dir);
20
+ const has_site_yaml = await path_exists(get_site_config_path(root_dir));
21
+ const has_server_yaml = await path_exists(get_server_config_path(root_dir));
22
+ // Resolve a workspace-level default server URL (server.yaml). Used as the
23
+ // fallback for sites that don't declare their own `server` in site.yaml,
24
+ // and for the library push.
25
+ let workspace_server;
26
+ if (has_server_yaml) {
27
+ try {
28
+ const server_config = await read_server_config(root_dir);
29
+ workspace_server = server_config.server;
30
+ }
31
+ catch {
32
+ // fall through — bad server.yaml will surface elsewhere
33
+ }
34
+ }
35
+ const effective_options = workspace_server && !options.server
36
+ ? { ...options, server: workspace_server }
37
+ : options;
38
+ if (options.dryRun) {
39
+ await print_push_dry_run(root_dir, has_site_yaml, has_server_yaml, effective_options);
40
+ return;
41
+ }
42
+ // Server-folder mode: walk site subfolders + push library
43
+ if (!has_site_yaml && has_server_yaml) {
44
+ await push_server(root_dir, effective_options);
45
+ return;
46
+ }
47
+ // Single-site mode (cwd is a site folder, or --dir points at one)
8
48
  const spinner = ora('Reading local files...').start();
9
49
  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;
50
+ await push_single_site(root_dir, effective_options, spinner);
51
+ }
52
+ catch (error) {
53
+ spinner.fail(`Push failed: ${error instanceof Error ? error.message : error}`);
54
+ if (is_auth_error(error))
55
+ print_auth_hint();
56
+ process.exit(1);
57
+ }
58
+ }
59
+ function is_auth_error(error) {
60
+ const msg = error instanceof Error ? error.message : String(error);
61
+ return /Authentication required/i.test(msg) || /\b401\b/.test(msg) || /unauthorized/i.test(msg);
62
+ }
63
+ function print_auth_hint() {
64
+ console.log('');
65
+ console.log(chalk.dim(' Run `primo login -s <server-url>` to authenticate, then retry.'));
66
+ console.log('');
67
+ }
68
+ async function print_push_dry_run(root_dir, has_site_yaml, has_server_yaml, options) {
69
+ console.log('');
70
+ console.log(chalk.bold('Push preview (dry-run)'));
71
+ console.log('');
72
+ if (!has_site_yaml && !has_server_yaml) {
73
+ console.log(chalk.red(` No ${SITE_CONFIG_FILE} or ${path.basename(get_server_config_path(root_dir))} found in ${root_dir}.`));
74
+ console.log('');
75
+ return;
76
+ }
77
+ const sites = [];
78
+ let library_present = false;
79
+ let inferred_server;
80
+ if (has_site_yaml) {
14
81
  try {
15
- const config_data = await fs.readFile(config_path, 'utf-8');
16
- config = JSON.parse(config_data);
82
+ const config = await read_site_config(root_dir);
83
+ sites.push({ dir: root_dir, config: config, label: config.name || path.basename(root_dir) });
84
+ inferred_server = config.server;
17
85
  }
18
86
  catch {
19
- // No config file, must provide options
87
+ // fall through
20
88
  }
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);
89
+ }
90
+ else if (has_server_yaml) {
91
+ const sites_root = path.join(root_dir, 'sites');
92
+ if (await path_exists(sites_root)) {
93
+ const entries = await fs.readdir(sites_root, { withFileTypes: true });
94
+ for (const entry of entries) {
95
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
96
+ continue;
97
+ const candidate = path.join(sites_root, entry.name);
98
+ if (!await path_exists(get_site_config_path(candidate)))
99
+ continue;
100
+ try {
101
+ const config = await read_site_config(candidate);
102
+ sites.push({ dir: candidate, config: config, label: config.name || entry.name });
103
+ inferred_server = inferred_server || config.server;
104
+ }
105
+ catch {
106
+ // skip
107
+ }
108
+ }
26
109
  }
27
- if (!site_id) {
28
- spinner.fail('Site ID required. Use --site or add site_id field to primo.json.');
29
- process.exit(1);
110
+ library_present = await path_exists(path.join(root_dir, 'library'));
111
+ }
112
+ const server = (options.server || inferred_server)?.replace(/\/+$/, '');
113
+ console.log(` Target server: ${chalk.cyan(server || '(not set — pass --server or set in site.yaml)')}`);
114
+ console.log('');
115
+ console.log(chalk.bold(' Will sync:'));
116
+ if (sites.length === 0) {
117
+ console.log(chalk.yellow(' (no sites found)'));
118
+ }
119
+ else {
120
+ for (const site of sites) {
121
+ console.log(` ${chalk.green('+')} site: ${site.label} ${chalk.dim(`(${path.basename(site.dir)})`)}`);
30
122
  }
31
- // Get auth token
123
+ }
124
+ if (library_present) {
125
+ console.log(` ${chalk.green('+')} library/`);
126
+ }
127
+ console.log('');
128
+ if (server) {
32
129
  const token = options.token || await get_auth_token(server);
33
- if (!token) {
34
- spinner.fail('Authentication required. Use --token or run `primo login` first.');
130
+ if (token) {
131
+ console.log(chalk.dim(` Auth: token found for ${server}.`));
132
+ }
133
+ else {
134
+ console.log(chalk.yellow(` Auth: not logged in to ${server}. Run \`primo login -s ${server}\`.`));
135
+ }
136
+ }
137
+ if (options.preview) {
138
+ console.log(chalk.dim(' --preview flag set: real push would request a server-side preview only.'));
139
+ }
140
+ console.log('');
141
+ console.log(chalk.dim(' No requests sent. Run without --dry-run to push.'));
142
+ console.log('');
143
+ }
144
+ async function push_server(root_dir, options) {
145
+ // Sites live under sites/<slug>/
146
+ const sites_root = path.join(root_dir, 'sites');
147
+ const site_dirs = [];
148
+ if (await path_exists(sites_root)) {
149
+ const entries = await fs.readdir(sites_root, { withFileTypes: true });
150
+ for (const entry of entries) {
151
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
152
+ continue;
153
+ const candidate = path.join(sites_root, entry.name);
154
+ if (await path_exists(get_site_config_path(candidate))) {
155
+ site_dirs.push(candidate);
156
+ }
157
+ }
158
+ }
159
+ if (site_dirs.length === 0) {
160
+ console.log(chalk.yellow(' No site folders found in this server directory.'));
161
+ process.exit(1);
162
+ }
163
+ // --only <slug>: push just one site folder, skip the library
164
+ if (options.only) {
165
+ const match = site_dirs.find((d) => path.basename(d) === options.only);
166
+ if (!match) {
167
+ const available = site_dirs.map((d) => path.basename(d)).join(', ');
168
+ console.log(chalk.red(` No site folder named "${options.only}" under sites/.`));
169
+ console.log(chalk.dim(` Available: ${available}`));
35
170
  process.exit(1);
36
171
  }
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}`);
172
+ const spinner = ora(`Pushing ${chalk.cyan(path.basename(match))}...`).start();
173
+ try {
174
+ await push_single_site(match, { ...options, dir: match }, spinner);
175
+ }
176
+ catch (error) {
177
+ spinner.fail(`${path.basename(match)}: ${error instanceof Error ? error.message : error}`);
178
+ if (is_auth_error(error))
179
+ print_auth_hint();
57
180
  process.exit(1);
58
181
  }
59
- const result = await response.json();
182
+ return;
183
+ }
184
+ let saw_auth_error = false;
185
+ // Push each site
186
+ for (const site_dir of site_dirs) {
187
+ const spinner = ora(`Pushing ${chalk.cyan(path.basename(site_dir))}...`).start();
188
+ try {
189
+ await push_single_site(site_dir, { ...options, dir: site_dir }, spinner);
190
+ }
191
+ catch (error) {
192
+ spinner.fail(`${path.basename(site_dir)}: ${error instanceof Error ? error.message : error}`);
193
+ if (is_auth_error(error))
194
+ saw_auth_error = true;
195
+ // Continue to remaining sites rather than abort the whole push
196
+ }
197
+ }
198
+ // Push library if present
199
+ const library_dir = path.join(root_dir, 'library');
200
+ if (await path_exists(library_dir)) {
201
+ const spinner = ora('Pushing library...').start();
202
+ try {
203
+ await push_library_dir(root_dir, options, spinner);
204
+ }
205
+ catch (error) {
206
+ spinner.fail(`library: ${error instanceof Error ? error.message : error}`);
207
+ if (is_auth_error(error))
208
+ saw_auth_error = true;
209
+ }
210
+ }
211
+ if (saw_auth_error)
212
+ print_auth_hint();
213
+ }
214
+ async function push_single_site(site_dir, options, spinner) {
215
+ let config = null;
216
+ try {
217
+ config = await read_site_config(site_dir);
218
+ }
219
+ catch {
220
+ // No config file, must provide options
221
+ }
222
+ const server = (options.server || config?.server)?.replace(/\/+$/, '');
223
+ const site_id = options.site || config?.site_id;
224
+ if (!server) {
225
+ throw new Error(`Server URL required. Use --server or add server field to ${SITE_CONFIG_FILE}.`);
226
+ }
227
+ if (!site_id) {
228
+ throw new Error(`Site ID required. Use --site or add site_id field to ${SITE_CONFIG_FILE}.`);
229
+ }
230
+ const token = options.token || await get_auth_token(server);
231
+ spinner.text = 'Packaging files...';
232
+ const zip_buffer = await create_zip(site_dir);
233
+ // If the user isn't logged in yet, skip the import attempt and try
234
+ // bootstrap directly. Bootstrap doesn't require auth (only allowed when
235
+ // the server has zero sites), so it's the right path for first-time
236
+ // setup against a fresh deployment.
237
+ if (!token) {
60
238
  if (options.preview) {
61
- spinner.succeed('Preview complete');
62
- console.log('');
63
- print_diff(result.diff);
239
+ throw new Error('Authentication required for --preview. Run `primo login` first.');
240
+ }
241
+ spinner.text = 'No auth token — attempting bootstrap...';
242
+ const bootstrap_result = await try_bootstrap_site(server, undefined, zip_buffer, config, site_id);
243
+ if (bootstrap_result.ok) {
244
+ spinner.succeed(`Bootstrapped ${config?.name || path.basename(site_dir)}`);
64
245
  console.log('');
65
- console.log(chalk.dim(' Run without --preview to apply these changes'));
246
+ console.log(chalk.dim(' Site created on server and content uploaded.'));
247
+ console.log(chalk.dim(' Run `primo login` and re-push to update content later.'));
248
+ return;
66
249
  }
67
- else {
68
- spinner.succeed('Push complete');
250
+ throw new Error(bootstrap_result.error);
251
+ }
252
+ const endpoint = options.preview
253
+ ? `${server}/api/palacms/import/${site_id}/preview`
254
+ : `${server}/api/palacms/import/${site_id}`;
255
+ spinner.text = options.preview ? 'Previewing changes...' : 'Pushing changes...';
256
+ const form_data = new FormData();
257
+ form_data.append('file', new Blob([zip_buffer]), 'site.zip');
258
+ const response = await fetch(endpoint, {
259
+ method: 'POST',
260
+ headers: { 'Authorization': `Bearer ${token}` },
261
+ body: form_data
262
+ });
263
+ // 404 from import means the site doesn't exist on the server yet. On a
264
+ // freshly-deployed server we can fall back to /api/palacms/bootstrap,
265
+ // which creates the site and ingests the zip in one shot. Bootstrap is
266
+ // only available when the server has zero sites — past the first site,
267
+ // new sites must be created via the dashboard UI.
268
+ if (response.status === 404 && !options.preview) {
269
+ spinner.text = 'Site not found on server — bootstrapping...';
270
+ const bootstrap_result = await try_bootstrap_site(server, token, zip_buffer, config, site_id);
271
+ if (bootstrap_result.ok) {
272
+ spinner.succeed(`Bootstrapped ${config?.name || path.basename(site_dir)}`);
69
273
  console.log('');
70
- print_diff(result.diff);
274
+ console.log(chalk.dim(' Site created on server and content uploaded.'));
275
+ console.log(chalk.dim(' Subsequent pushes will use the import endpoint.'));
276
+ return;
71
277
  }
278
+ throw new Error(bootstrap_result.error);
72
279
  }
73
- catch (error) {
74
- spinner.fail(`Push failed: ${error instanceof Error ? error.message : error}`);
75
- process.exit(1);
280
+ if (!response.ok) {
281
+ throw new Error(await response.text());
282
+ }
283
+ const result = await response.json();
284
+ const label = config?.name || path.basename(site_dir);
285
+ if (options.preview) {
286
+ spinner.succeed(`Preview: ${label}`);
287
+ console.log('');
288
+ print_diff(result.diff);
289
+ console.log('');
290
+ console.log(chalk.dim(' Run without --preview to apply these changes'));
291
+ }
292
+ else {
293
+ spinner.succeed(`Pushed ${label}`);
294
+ console.log('');
295
+ print_diff(result.diff);
296
+ }
297
+ }
298
+ async function try_bootstrap_site(server, token, zip_buffer, config, site_id) {
299
+ const form = new FormData();
300
+ form.append('site_id', site_id);
301
+ if (config?.name)
302
+ form.append('name', config.name);
303
+ if (config?.host)
304
+ form.append('host', config.host);
305
+ if (config?.group)
306
+ form.append('group', config.group);
307
+ form.append('file', new Blob([zip_buffer]), 'site.zip');
308
+ const headers = {};
309
+ if (token)
310
+ headers['Authorization'] = `Bearer ${token}`;
311
+ const response = await fetch(`${server}/api/palacms/bootstrap`, {
312
+ method: 'POST',
313
+ headers,
314
+ body: form
315
+ });
316
+ if (response.ok)
317
+ return { ok: true };
318
+ if (response.status === 403) {
319
+ return {
320
+ ok: false,
321
+ error: `Site '${site_id}' not found on server, and bootstrap is locked ` +
322
+ `(server already has other sites). Create the site in the dashboard first, ` +
323
+ `then update site_id in site.yaml to match.`
324
+ };
325
+ }
326
+ return { ok: false, error: await response.text() };
327
+ }
328
+ async function push_library_dir(root_dir, options, spinner) {
329
+ // Resolve server: --server > any site.yaml's server (they all point at the same server)
330
+ let server = options.server?.replace(/\/+$/, '');
331
+ if (!server) {
332
+ const sites_root = path.join(root_dir, 'sites');
333
+ if (await path_exists(sites_root)) {
334
+ const entries = await fs.readdir(sites_root, { withFileTypes: true });
335
+ for (const entry of entries) {
336
+ if (!entry.isDirectory())
337
+ continue;
338
+ try {
339
+ const config = await read_site_config(path.join(sites_root, entry.name));
340
+ if (config.server) {
341
+ server = config.server.replace(/\/+$/, '');
342
+ break;
343
+ }
344
+ }
345
+ catch { }
346
+ }
347
+ }
348
+ }
349
+ if (!server)
350
+ throw new Error('Server URL required for library push.');
351
+ const token = options.token || await get_auth_token(server);
352
+ if (!token)
353
+ throw new Error('Authentication required. Run `primo login` first.');
354
+ spinner.text = 'Packaging library...';
355
+ const archive = archiver('zip', { zlib: { level: 9 } });
356
+ const chunks = [];
357
+ const zip_buffer = await new Promise((resolve, reject) => {
358
+ archive.on('data', (chunk) => chunks.push(chunk));
359
+ archive.on('end', () => resolve(Buffer.concat(chunks)));
360
+ archive.on('error', reject);
361
+ archive.directory(path.join(root_dir, 'library'), 'library');
362
+ archive.finalize();
363
+ });
364
+ spinner.text = 'Pushing library...';
365
+ const form_data = new FormData();
366
+ form_data.append('file', new Blob([zip_buffer]), 'library.zip');
367
+ const response = await fetch(`${server}/api/palacms/import-library`, {
368
+ method: 'POST',
369
+ headers: { 'Authorization': `Bearer ${token}` },
370
+ body: form_data
371
+ });
372
+ if (response.status === 404) {
373
+ spinner.warn('Library push not supported by this server — skipping');
374
+ return;
375
+ }
376
+ if (!response.ok) {
377
+ throw new Error(await response.text());
378
+ }
379
+ const result = await response.json();
380
+ spinner.succeed('Pushed library');
381
+ if (result.summary) {
382
+ console.log(chalk.dim(` groups/ ${result.summary.groups}, blocks/ ${result.summary.blocks}`));
76
383
  }
77
384
  }
78
385
  async function create_zip(dir) {
@@ -88,8 +395,8 @@ async function create_zip(dir) {
88
395
  const full_path = path.join(dir, subdir);
89
396
  archive.directory(full_path, subdir);
90
397
  }
91
- // Add primo.json
92
- archive.file(path.join(dir, 'primo.json'), { name: 'primo.json' });
398
+ // Add site config
399
+ archive.file(path.join(dir, SITE_CONFIG_FILE), { name: SITE_CONFIG_FILE });
93
400
  archive.finalize();
94
401
  });
95
402
  }
@@ -1,7 +1,7 @@
1
- export declare function normalize_site(site_dir: string): Promise<void>;
2
1
  interface ValidateOptions {
3
2
  dir: string;
4
3
  strict?: boolean;
5
4
  }
5
+ export declare function normalize_site(site_dir: string): Promise<void>;
6
6
  export declare function validate_site(options: ValidateOptions): Promise<void>;
7
7
  export {};