primo-cli 0.1.10 → 0.1.12
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/dist/commands/deploy.d.ts +1 -0
- package/dist/commands/deploy.js +125 -16
- package/dist/commands/dev.js +19 -19
- package/dist/commands/new.js +5 -1
- package/dist/commands/pull-library.js +2 -2
- package/dist/commands/pull.js +2 -2
- package/dist/commands/push-library.js +2 -2
- package/dist/commands/push.js +37 -8
- package/dist/commands/validate.js +2 -2
- package/dist/index.js +1 -0
- package/dist/utils/binary.js +32 -16
- package/package.json +1 -1
package/dist/commands/deploy.js
CHANGED
|
@@ -2,10 +2,11 @@ import fs from 'fs/promises';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import ora from 'ora';
|
|
5
|
-
import { select } from '@inquirer/prompts';
|
|
5
|
+
import { select, confirm } from '@inquirer/prompts';
|
|
6
6
|
import { execSync, spawn } from 'child_process';
|
|
7
7
|
import { read_server_config, write_server_config, get_server_config_path, SERVER_CONFIG_FILE } from '../utils/server-config.js';
|
|
8
8
|
import { read_site_config, get_site_config_path } from '../utils/site-config.js';
|
|
9
|
+
import { push_site } from './push.js';
|
|
9
10
|
async function path_exists(p) {
|
|
10
11
|
try {
|
|
11
12
|
await fs.access(p);
|
|
@@ -135,11 +136,13 @@ export async function deploy(options) {
|
|
|
135
136
|
await generate_fly_toml(inventory);
|
|
136
137
|
}
|
|
137
138
|
spinner.succeed('Deployment files generated');
|
|
139
|
+
// commander's --no-push sets push:false; treat anything else as opt-in.
|
|
140
|
+
const auto_push = options.push !== false;
|
|
138
141
|
if (provider === 'railway') {
|
|
139
|
-
await deploy_to_railway(inventory);
|
|
142
|
+
await deploy_to_railway(inventory, auto_push);
|
|
140
143
|
}
|
|
141
144
|
else {
|
|
142
|
-
await deploy_to_fly(inventory);
|
|
145
|
+
await deploy_to_fly(inventory, auto_push);
|
|
143
146
|
}
|
|
144
147
|
}
|
|
145
148
|
catch (error) {
|
|
@@ -163,7 +166,7 @@ function print_dry_run(inventory, provider) {
|
|
|
163
166
|
}
|
|
164
167
|
console.log(` ${chalk.green('+')} Dockerfile in this workspace`);
|
|
165
168
|
console.log('');
|
|
166
|
-
console.log(chalk.bold(' Will be uploaded
|
|
169
|
+
console.log(chalk.bold(' Will be uploaded automatically once the server is online:'));
|
|
167
170
|
console.log(` ${chalk.dim('—')} ${SERVER_CONFIG_FILE}`);
|
|
168
171
|
if (inventory.has_library) {
|
|
169
172
|
console.log(` ${chalk.dim('—')} library/`);
|
|
@@ -227,10 +230,10 @@ async function check_provider_auth(provider) {
|
|
|
227
230
|
// Bump to a release tag (:v3.0.0) when primocms cuts a stable release.
|
|
228
231
|
const PRIMO_SERVER_IMAGE = 'ghcr.io/primocms/primo:main';
|
|
229
232
|
async function generate_dockerfile(inventory) {
|
|
230
|
-
// One-line Dockerfile: pull the published
|
|
233
|
+
// One-line Dockerfile: pull the published primo image and run it
|
|
231
234
|
// unchanged. Workspace data (server.yaml, sites/, library/) is uploaded
|
|
232
|
-
// after deploy via `primo push`, which calls /api/
|
|
233
|
-
// first push (server with no sites) and /api/
|
|
235
|
+
// after deploy via `primo push`, which calls /api/primo/bootstrap on
|
|
236
|
+
// first push (server with no sites) and /api/primo/import/<id> on
|
|
234
237
|
// subsequent pushes. The volume mounted at /app/pb_data persists the
|
|
235
238
|
// SQLite database between restarts.
|
|
236
239
|
void inventory;
|
|
@@ -252,7 +255,7 @@ EXPOSE 8080
|
|
|
252
255
|
}
|
|
253
256
|
async function generate_fly_toml(inventory) {
|
|
254
257
|
const app_name = workspace_app_name(inventory.root_dir);
|
|
255
|
-
//
|
|
258
|
+
// primo binds 0.0.0.0:8080 in its CMD; mount /app/pb_data on a persistent
|
|
256
259
|
// volume so the SQLite db + uploads survive restarts. Auto-start/stop keeps
|
|
257
260
|
// the small instance free-tier-friendly.
|
|
258
261
|
const fly_toml = `app = "${app_name}"
|
|
@@ -278,7 +281,7 @@ primary_region = "sjc"
|
|
|
278
281
|
`;
|
|
279
282
|
await fs.writeFile(path.join(inventory.root_dir, 'fly.toml'), fly_toml);
|
|
280
283
|
}
|
|
281
|
-
async function deploy_to_railway(inventory) {
|
|
284
|
+
async function deploy_to_railway(inventory, auto_push) {
|
|
282
285
|
console.log('');
|
|
283
286
|
console.log(chalk.cyan('Deploying to Railway...'));
|
|
284
287
|
const spinner = ora('Setting up Railway project...').start();
|
|
@@ -320,11 +323,44 @@ async function deploy_to_railway(inventory) {
|
|
|
320
323
|
if (url) {
|
|
321
324
|
await record_workspace_server(inventory.root_dir, url);
|
|
322
325
|
}
|
|
323
|
-
|
|
326
|
+
// Railway needs a manual volume mount before the server can persist
|
|
327
|
+
// /app/pb_data. Print the instructions, wait for the user, then poll
|
|
328
|
+
// readiness and push. If anything in that chain fails we fall back
|
|
329
|
+
// to the old "next steps" message so the user can finish by hand.
|
|
330
|
+
const pushed = url
|
|
331
|
+
? await finish_railway_deploy(inventory, url, auto_push)
|
|
332
|
+
: false;
|
|
333
|
+
print_post_deploy_next_steps('railway', url, { auto_push, pushed });
|
|
324
334
|
resolve();
|
|
325
335
|
});
|
|
326
336
|
});
|
|
327
337
|
}
|
|
338
|
+
async function finish_railway_deploy(inventory, url, auto_push) {
|
|
339
|
+
console.log('');
|
|
340
|
+
console.log(chalk.bold(' One manual step on Railway'));
|
|
341
|
+
console.log(chalk.dim(' Open the project in the Railway dashboard, then:'));
|
|
342
|
+
console.log(chalk.dim(' Settings → Volumes → mount on /app/pb_data (size 1GB+)'));
|
|
343
|
+
console.log('');
|
|
344
|
+
if (!auto_push)
|
|
345
|
+
return false;
|
|
346
|
+
let confirmed = false;
|
|
347
|
+
try {
|
|
348
|
+
confirmed = await confirm({
|
|
349
|
+
message: 'Mounted the volume? (press enter to upload your workspace)',
|
|
350
|
+
default: true
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Ctrl+C / non-interactive — bail out, user can run primo push later.
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
if (!confirmed)
|
|
358
|
+
return false;
|
|
359
|
+
const ready = await wait_for_ready(url);
|
|
360
|
+
if (!ready)
|
|
361
|
+
return false;
|
|
362
|
+
return await run_auto_push(inventory);
|
|
363
|
+
}
|
|
328
364
|
async function try_railway_domain(cwd) {
|
|
329
365
|
try {
|
|
330
366
|
const out = execSync('railway domain --json', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
@@ -351,28 +387,93 @@ async function record_workspace_server(root_dir, url) {
|
|
|
351
387
|
// the server URL manually.
|
|
352
388
|
}
|
|
353
389
|
}
|
|
354
|
-
function print_post_deploy_next_steps(provider, url) {
|
|
390
|
+
function print_post_deploy_next_steps(provider, url, ctx) {
|
|
355
391
|
console.log('');
|
|
356
|
-
|
|
392
|
+
if (ctx.pushed) {
|
|
393
|
+
console.log(chalk.green('✓ Deployment complete — your workspace is live'));
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
console.log(chalk.green('✓ Server provisioned'));
|
|
397
|
+
}
|
|
357
398
|
console.log('');
|
|
358
399
|
if (url) {
|
|
359
400
|
console.log(chalk.bold(' URL: ') + chalk.cyan(url));
|
|
360
401
|
console.log(chalk.dim(' (saved as `server:` in server.yaml — primo push/login pick it up automatically)'));
|
|
361
402
|
console.log('');
|
|
362
403
|
}
|
|
404
|
+
if (ctx.pushed) {
|
|
405
|
+
console.log(chalk.bold('Next steps'));
|
|
406
|
+
console.log('');
|
|
407
|
+
console.log(chalk.dim(' Open the URL above and create your editor account.'));
|
|
408
|
+
console.log(chalk.dim(' Future edits: `primo push` to upload, `primo pull` to fetch.'));
|
|
409
|
+
console.log('');
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
363
412
|
console.log(chalk.bold('Next steps'));
|
|
364
413
|
console.log('');
|
|
365
|
-
if (provider === 'railway') {
|
|
414
|
+
if (provider === 'railway' && !ctx.auto_push) {
|
|
415
|
+
// User passed --no-push; remind them about the volume since we
|
|
416
|
+
// skipped the prompt that would normally cover it.
|
|
366
417
|
console.log(chalk.dim(' Railway needs one manual setting the CLI can\'t set for you:'));
|
|
367
418
|
console.log(chalk.dim(' Settings → Volumes → mount on /app/pb_data (size 1GB+)'));
|
|
368
419
|
console.log('');
|
|
369
420
|
}
|
|
370
|
-
console.log(chalk.dim('
|
|
421
|
+
console.log(chalk.dim(' Upload your workspace into the deployed server:'));
|
|
371
422
|
console.log(chalk.dim(` primo push${url ? '' : ' -s <url>'}`));
|
|
372
423
|
console.log(chalk.dim(' (first push bootstraps the sites; later pushes update them incrementally)'));
|
|
373
424
|
console.log('');
|
|
374
425
|
}
|
|
375
|
-
|
|
426
|
+
// Poll the deployed server until it answers /api/health with HTTP 200. The
|
|
427
|
+
// timeout is generous because Railway's first build can be slow and the user
|
|
428
|
+
// may have just mounted the volume which forces a restart.
|
|
429
|
+
async function wait_for_ready(url) {
|
|
430
|
+
const health_url = `${url.replace(/\/+$/, '')}/api/health`;
|
|
431
|
+
const deadline = Date.now() + 5 * 60 * 1000;
|
|
432
|
+
const spinner = ora(`Waiting for ${url} to come online...`).start();
|
|
433
|
+
let attempt = 0;
|
|
434
|
+
while (Date.now() < deadline) {
|
|
435
|
+
attempt += 1;
|
|
436
|
+
try {
|
|
437
|
+
const controller = new AbortController();
|
|
438
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
439
|
+
const response = await fetch(health_url, { signal: controller.signal });
|
|
440
|
+
clearTimeout(timer);
|
|
441
|
+
if (response.ok) {
|
|
442
|
+
spinner.succeed('Server is online');
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// not ready yet — keep polling
|
|
448
|
+
}
|
|
449
|
+
spinner.text = `Waiting for ${url} to come online... (attempt ${attempt})`;
|
|
450
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
451
|
+
}
|
|
452
|
+
spinner.fail(`Server did not respond at ${health_url} within 5 minutes`);
|
|
453
|
+
console.log('');
|
|
454
|
+
console.log(chalk.dim(' The server may still be starting (or the volume is not mounted yet).'));
|
|
455
|
+
console.log(chalk.dim(' Once the URL above loads in a browser, run `primo push` to upload.'));
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
// Run the equivalent of `primo push` against the local workspace. push_site
|
|
459
|
+
// resolves the server URL from server.yaml (which we just wrote), so no extra
|
|
460
|
+
// flags are needed. We surface failures but don't re-throw — the deploy already
|
|
461
|
+
// succeeded and the user can rerun push manually.
|
|
462
|
+
async function run_auto_push(inventory) {
|
|
463
|
+
console.log('');
|
|
464
|
+
console.log(chalk.cyan('Uploading your workspace...'));
|
|
465
|
+
try {
|
|
466
|
+
await push_site({ dir: inventory.root_dir });
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
console.log('');
|
|
471
|
+
console.log(chalk.yellow(`Auto-push failed: ${error instanceof Error ? error.message : error}`));
|
|
472
|
+
console.log(chalk.dim(' Your server is live — rerun `primo push` once the issue is sorted.'));
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async function deploy_to_fly(inventory, auto_push) {
|
|
376
477
|
console.log('');
|
|
377
478
|
console.log(chalk.cyan('Deploying to Fly.io...'));
|
|
378
479
|
const app_name = workspace_app_name(inventory.root_dir);
|
|
@@ -406,7 +507,15 @@ async function deploy_to_fly(inventory) {
|
|
|
406
507
|
}
|
|
407
508
|
const url = `https://${app_name}.fly.dev`;
|
|
408
509
|
await record_workspace_server(inventory.root_dir, url);
|
|
409
|
-
|
|
510
|
+
// Fly's CLI provisions the volume for us, so we can go straight to
|
|
511
|
+
// readiness + push. No interactive prompt required.
|
|
512
|
+
let pushed = false;
|
|
513
|
+
if (auto_push) {
|
|
514
|
+
const ready = await wait_for_ready(url);
|
|
515
|
+
if (ready)
|
|
516
|
+
pushed = await run_auto_push(inventory);
|
|
517
|
+
}
|
|
518
|
+
print_post_deploy_next_steps('fly', url, { auto_push, pushed });
|
|
410
519
|
resolve();
|
|
411
520
|
});
|
|
412
521
|
});
|
package/dist/commands/dev.js
CHANGED
|
@@ -33,7 +33,7 @@ let is_importing_library = false;
|
|
|
33
33
|
let has_pending_library_local_changes = false;
|
|
34
34
|
let site_sync_baselines = new Map();
|
|
35
35
|
// Tracks the last set of conflict paths logged per site so we don't reprint
|
|
36
|
-
// the same conflict block every pull cycle when
|
|
36
|
+
// the same conflict block every pull cycle when primo's serialization
|
|
37
37
|
// keeps producing the same divergence (e.g. data-key mangling, key reorder).
|
|
38
38
|
const last_logged_conflicts = new Map();
|
|
39
39
|
// Track files written by sync to prevent watcher from re-pushing them.
|
|
@@ -254,7 +254,7 @@ function hash_snapshot_content(contents) {
|
|
|
254
254
|
function snapshot_value(snapshot, file_path) {
|
|
255
255
|
return snapshot.has(file_path) ? snapshot.get(file_path) : null;
|
|
256
256
|
}
|
|
257
|
-
// Reads a file but treats ENOENT as a soft miss —
|
|
257
|
+
// Reads a file but treats ENOENT as a soft miss — primo' export step can
|
|
258
258
|
// reshape the on-disk layout (e.g. promoting pages/foo.yaml to
|
|
259
259
|
// pages/foo/index.yaml when a child route is added) between when a directory
|
|
260
260
|
// listing is captured and when each file is read. The vanished file isn't an
|
|
@@ -273,7 +273,7 @@ async function read_file_or_vanish(full_path, label) {
|
|
|
273
273
|
}
|
|
274
274
|
// A path is "in conflict" when local has content that differs from CMS AND
|
|
275
275
|
// the local content represents a real user change — not just CMS-side
|
|
276
|
-
// serialization noise (
|
|
276
|
+
// serialization noise (primo re-emits YAML with normalized key order,
|
|
277
277
|
// ISO-coerced dates, etc., so the CMS export legitimately differs from a
|
|
278
278
|
// freshly-scaffolded file forever, and we don't want to scream about that
|
|
279
279
|
// every pull cycle).
|
|
@@ -369,7 +369,7 @@ async function collect_directory_snapshot(current_dir, relative_dir, snapshot, o
|
|
|
369
369
|
}
|
|
370
370
|
}
|
|
371
371
|
async function fetch_cms_site_snapshot(site_dir, api_url, config, server_config, workspace_dir, temp_name) {
|
|
372
|
-
const response = await fetch_with_timeout(`${api_url}/api/
|
|
372
|
+
const response = await fetch_with_timeout(`${api_url}/api/primo/export/${config.site_id}`, {}, 15000);
|
|
373
373
|
if (!response.ok)
|
|
374
374
|
return null;
|
|
375
375
|
const temp_dir = path.join(site_dir, '.primo', temp_name);
|
|
@@ -572,19 +572,19 @@ export async function dev_server(options) {
|
|
|
572
572
|
}
|
|
573
573
|
}
|
|
574
574
|
// Ensure binary is installed
|
|
575
|
-
spinner.text = 'Checking
|
|
575
|
+
spinner.text = 'Checking primo...';
|
|
576
576
|
const binary_path = await ensure_binary();
|
|
577
577
|
// Create data directory in project folder
|
|
578
578
|
const data_dir = await ensure_data_dir(base_dir);
|
|
579
579
|
spinner.text = 'Starting CMS...';
|
|
580
580
|
// Start the CMS binary with dev mode enabled. PRIMO_AUTHOR_MODE
|
|
581
|
-
// tells
|
|
581
|
+
// tells primo which sync mode the CLI is running in so the CMS
|
|
582
582
|
// UI can gate its editable surfaces accordingly (read-only when
|
|
583
583
|
// the CLI is in --author files, since CMS edits would be discarded
|
|
584
584
|
// before they ever round-trip to disk).
|
|
585
585
|
cms_process = spawn(binary_path, ['serve', '--http', `127.0.0.1:${port}`, '--dir', data_dir], {
|
|
586
586
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
587
|
-
env: { ...process.env,
|
|
587
|
+
env: { ...process.env, PRIMO_DEV_MODE: '1', PRIMO_AUTHOR_MODE: sync_policy.mode }
|
|
588
588
|
});
|
|
589
589
|
// Capture stderr for errors
|
|
590
590
|
let stderr_output = '';
|
|
@@ -1119,11 +1119,11 @@ async function register_primo_mcp_server(base_dir) {
|
|
|
1119
1119
|
if (mcp_servers.primo !== undefined) {
|
|
1120
1120
|
return null;
|
|
1121
1121
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1122
|
+
// Default to the published package; PRIMO_MCP_LOCAL opts into a local dist build.
|
|
1123
|
+
const local_mcp = process.env.PRIMO_MCP_LOCAL;
|
|
1124
1124
|
mcp_servers.primo = local_mcp
|
|
1125
1125
|
? { command: 'node', args: [local_mcp] }
|
|
1126
|
-
: { command: 'npx', args: ['-y', '
|
|
1126
|
+
: { command: 'npx', args: ['-y', 'primo-mcp'] };
|
|
1127
1127
|
config.mcpServers = mcp_servers;
|
|
1128
1128
|
// Claude Code reads project-root .mcp.json. .primo/ is gitignored local
|
|
1129
1129
|
// state, so the discoverable root file is the right registration target.
|
|
@@ -1251,7 +1251,7 @@ function change_requires_reload(_dir, _filename) {
|
|
|
1251
1251
|
return true;
|
|
1252
1252
|
}
|
|
1253
1253
|
async function request_browser_reload(api_url) {
|
|
1254
|
-
await fetch_with_timeout(`${api_url}/api/
|
|
1254
|
+
await fetch_with_timeout(`${api_url}/api/primo/dev/reload`, {
|
|
1255
1255
|
method: 'POST'
|
|
1256
1256
|
}, 5000);
|
|
1257
1257
|
}
|
|
@@ -1870,7 +1870,7 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1870
1870
|
const import_form = new FormData();
|
|
1871
1871
|
import_form.append('file', new Blob([zip_buffer]), 'site.zip');
|
|
1872
1872
|
const request_started = Date.now();
|
|
1873
|
-
const import_response = await fetch_with_timeout(`${api_url}/api/
|
|
1873
|
+
const import_response = await fetch_with_timeout(`${api_url}/api/primo/import/${site_id}`, {
|
|
1874
1874
|
method: 'POST',
|
|
1875
1875
|
body: import_form
|
|
1876
1876
|
}, 300000);
|
|
@@ -1912,7 +1912,7 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1912
1912
|
form_data.append('file', new Blob([zip_buffer]), 'site.zip');
|
|
1913
1913
|
try {
|
|
1914
1914
|
const bootstrap_started = Date.now();
|
|
1915
|
-
const bootstrap_response = await fetch_with_timeout(`${api_url}/api/
|
|
1915
|
+
const bootstrap_response = await fetch_with_timeout(`${api_url}/api/primo/bootstrap`, {
|
|
1916
1916
|
method: 'POST',
|
|
1917
1917
|
body: form_data
|
|
1918
1918
|
}, 300000); // 300s timeout for imports
|
|
@@ -1944,7 +1944,7 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1944
1944
|
const import_form = new FormData();
|
|
1945
1945
|
import_form.append('file', new Blob([zip_buffer]), 'site.zip');
|
|
1946
1946
|
const import_started = Date.now();
|
|
1947
|
-
const import_response = await fetch_with_timeout(`${api_url}/api/
|
|
1947
|
+
const import_response = await fetch_with_timeout(`${api_url}/api/primo/import/${site_id}`, {
|
|
1948
1948
|
method: 'POST',
|
|
1949
1949
|
body: import_form
|
|
1950
1950
|
}, 300000); // 300s timeout for imports
|
|
@@ -2016,13 +2016,13 @@ async function import_library_files(base_dir, api_url, delete_group_ids = [], de
|
|
|
2016
2016
|
}));
|
|
2017
2017
|
}
|
|
2018
2018
|
const request_started = Date.now();
|
|
2019
|
-
const response = await fetch_with_timeout(`${api_url}/api/
|
|
2019
|
+
const response = await fetch_with_timeout(`${api_url}/api/primo/import-library`, {
|
|
2020
2020
|
method: 'POST',
|
|
2021
2021
|
body: form_data
|
|
2022
2022
|
}, 120000);
|
|
2023
2023
|
const request_ms = Date.now() - request_started;
|
|
2024
2024
|
if (response.status === 404) {
|
|
2025
|
-
throw new Error('Shared library sync is not supported by the current
|
|
2025
|
+
throw new Error('Shared library sync is not supported by the current primo binary/server. Rebuild or update primo to use library sync.');
|
|
2026
2026
|
}
|
|
2027
2027
|
if (!response.ok) {
|
|
2028
2028
|
const error_text = await response.text();
|
|
@@ -2146,7 +2146,7 @@ async function create_site_zip(dir, excluded_paths = new Set()) {
|
|
|
2146
2146
|
});
|
|
2147
2147
|
}
|
|
2148
2148
|
async function sync_from_cms(site_dir, api_url, config, server_config, workspace_dir, sync_policy = { mode: 'both' }) {
|
|
2149
|
-
const response = await fetch_with_timeout(`${api_url}/api/
|
|
2149
|
+
const response = await fetch_with_timeout(`${api_url}/api/primo/export/${config.site_id}`, {}, 15000);
|
|
2150
2150
|
if (!response.ok)
|
|
2151
2151
|
return;
|
|
2152
2152
|
const zip_data = await response.arrayBuffer();
|
|
@@ -2240,9 +2240,9 @@ async function sync_from_cms(site_dir, api_url, config, server_config, workspace
|
|
|
2240
2240
|
}
|
|
2241
2241
|
}
|
|
2242
2242
|
async function sync_library_from_cms(base_dir, api_url) {
|
|
2243
|
-
const response = await fetch_with_timeout(`${api_url}/api/
|
|
2243
|
+
const response = await fetch_with_timeout(`${api_url}/api/primo/export-library`, {}, 15000);
|
|
2244
2244
|
if (response.status === 404) {
|
|
2245
|
-
throw new Error('Shared library sync is not supported by the current
|
|
2245
|
+
throw new Error('Shared library sync is not supported by the current primo binary/server. Rebuild or update primo to use library sync.');
|
|
2246
2246
|
}
|
|
2247
2247
|
if (!response.ok)
|
|
2248
2248
|
return;
|
package/dist/commands/new.js
CHANGED
|
@@ -107,10 +107,14 @@ allowed_blocks:
|
|
|
107
107
|
`);
|
|
108
108
|
await fs.writeFile(path.join(site_dir, 'page-types', 'default', 'fields.yaml'), '[]\n');
|
|
109
109
|
await fs.writeFile(path.join(site_dir, 'page-types', 'default', 'layout.yaml'), `# Sections shared by every page of this type. Add blocks here to render
|
|
110
|
-
# the same header/footer across all pages of this type.
|
|
110
|
+
# the same header/footer across all pages of this type. Body sections are
|
|
111
|
+
# seeded onto each newly created page of this type (and locked when the type
|
|
112
|
+
# has no allowed_blocks).
|
|
111
113
|
#
|
|
112
114
|
# header:
|
|
113
115
|
# - block: site-header
|
|
116
|
+
# body:
|
|
117
|
+
# - block: hero
|
|
114
118
|
# footer:
|
|
115
119
|
# - block: site-footer
|
|
116
120
|
`);
|
|
@@ -43,11 +43,11 @@ export async function pull_library(options) {
|
|
|
43
43
|
const output_dir = path.resolve(options.output || '.');
|
|
44
44
|
await fs.mkdir(output_dir, { recursive: true });
|
|
45
45
|
spinner.text = 'Exporting library...';
|
|
46
|
-
const response = await fetch(`${server}/api/
|
|
46
|
+
const response = await fetch(`${server}/api/primo/export-library`, {
|
|
47
47
|
headers
|
|
48
48
|
});
|
|
49
49
|
if (response.status === 404) {
|
|
50
|
-
spinner.fail('Shared library sync is not supported by this
|
|
50
|
+
spinner.fail('Shared library sync is not supported by this primo server. Update the server before using `primo library pull`.');
|
|
51
51
|
process.exit(1);
|
|
52
52
|
}
|
|
53
53
|
if (!response.ok) {
|
package/dist/commands/pull.js
CHANGED
|
@@ -183,7 +183,7 @@ export async function pull_site(options) {
|
|
|
183
183
|
async function pull_one_site(server, headers, site, site_dir, spinner) {
|
|
184
184
|
await fs.mkdir(site_dir, { recursive: true });
|
|
185
185
|
spinner.text = `Exporting ${site.name}...`;
|
|
186
|
-
const response = await fetch(`${server}/api/
|
|
186
|
+
const response = await fetch(`${server}/api/primo/export/${site.id}`, { headers });
|
|
187
187
|
if (!response.ok) {
|
|
188
188
|
const error = await response.text();
|
|
189
189
|
throw new Error(`Export failed for ${site.name}: ${error}`);
|
|
@@ -205,7 +205,7 @@ async function pull_one_site(server, headers, site, site_dir, spinner) {
|
|
|
205
205
|
}
|
|
206
206
|
async function pull_library_into(server, headers, root_dir, spinner) {
|
|
207
207
|
spinner.start('Pulling library...');
|
|
208
|
-
const response = await fetch(`${server}/api/
|
|
208
|
+
const response = await fetch(`${server}/api/primo/export-library`, { headers });
|
|
209
209
|
if (response.status === 404) {
|
|
210
210
|
spinner.warn('Library export not supported by this server — skipping');
|
|
211
211
|
return false;
|
|
@@ -47,13 +47,13 @@ export async function push_library(options) {
|
|
|
47
47
|
if (token) {
|
|
48
48
|
headers.Authorization = `Bearer ${token}`;
|
|
49
49
|
}
|
|
50
|
-
const response = await fetch(`${server}/api/
|
|
50
|
+
const response = await fetch(`${server}/api/primo/import-library`, {
|
|
51
51
|
method: 'POST',
|
|
52
52
|
headers,
|
|
53
53
|
body: form_data
|
|
54
54
|
});
|
|
55
55
|
if (response.status === 404) {
|
|
56
|
-
spinner.fail('Shared library sync is not supported by this
|
|
56
|
+
spinner.fail('Shared library sync is not supported by this primo server. Update the server before using `primo library push`.');
|
|
57
57
|
process.exit(1);
|
|
58
58
|
}
|
|
59
59
|
if (!response.ok) {
|
package/dist/commands/push.js
CHANGED
|
@@ -15,6 +15,30 @@ async function path_exists(p) {
|
|
|
15
15
|
return false;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
// Look up the display name for a group ID by walking up from the site dir
|
|
19
|
+
// to find a workspace server.yaml. site.yaml only stores the group ID, so
|
|
20
|
+
// without this the server has nothing to label the group with on first push
|
|
21
|
+
// and falls back to humanizing the random ID ("8Y17hao5jt2xmd8").
|
|
22
|
+
async function resolve_group_name(site_dir, group_id) {
|
|
23
|
+
if (!group_id)
|
|
24
|
+
return undefined;
|
|
25
|
+
// Typical layout: <workspace>/sites/<slug>/site.yaml — server.yaml lives two levels up.
|
|
26
|
+
const candidates = [path.dirname(path.dirname(site_dir)), path.dirname(site_dir), site_dir];
|
|
27
|
+
for (const dir of candidates) {
|
|
28
|
+
if (!(await path_exists(get_server_config_path(dir))))
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
const cfg = await read_server_config(dir);
|
|
32
|
+
const match = cfg.site_groups?.find((g) => g.id === group_id);
|
|
33
|
+
if (match?.name)
|
|
34
|
+
return match.name;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// ignore — bad server.yaml shouldn't block the push
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
18
42
|
export async function push_site(options) {
|
|
19
43
|
const root_dir = path.resolve(options.dir);
|
|
20
44
|
const has_site_yaml = await path_exists(get_site_config_path(root_dir));
|
|
@@ -230,6 +254,7 @@ async function push_single_site(site_dir, options, spinner) {
|
|
|
230
254
|
const token = options.token || await get_auth_token(server);
|
|
231
255
|
spinner.text = 'Packaging files...';
|
|
232
256
|
const zip_buffer = await create_zip(site_dir);
|
|
257
|
+
const group_name = await resolve_group_name(site_dir, config?.group);
|
|
233
258
|
// If the user isn't logged in yet, skip the import attempt and try
|
|
234
259
|
// bootstrap directly. Bootstrap doesn't require auth (only allowed when
|
|
235
260
|
// the server has zero sites), so it's the right path for first-time
|
|
@@ -239,7 +264,7 @@ async function push_single_site(site_dir, options, spinner) {
|
|
|
239
264
|
throw new Error('Authentication required for --preview. Run `primo login` first.');
|
|
240
265
|
}
|
|
241
266
|
spinner.text = 'No auth token — attempting bootstrap...';
|
|
242
|
-
const bootstrap_result = await try_bootstrap_site(server, undefined, zip_buffer, config, site_id);
|
|
267
|
+
const bootstrap_result = await try_bootstrap_site(server, undefined, zip_buffer, config, site_id, group_name);
|
|
243
268
|
if (bootstrap_result.ok) {
|
|
244
269
|
spinner.succeed(`Bootstrapped ${config?.name || path.basename(site_dir)}`);
|
|
245
270
|
console.log('');
|
|
@@ -250,24 +275,26 @@ async function push_single_site(site_dir, options, spinner) {
|
|
|
250
275
|
throw new Error(bootstrap_result.error);
|
|
251
276
|
}
|
|
252
277
|
const endpoint = options.preview
|
|
253
|
-
? `${server}/api/
|
|
254
|
-
: `${server}/api/
|
|
278
|
+
? `${server}/api/primo/import/${site_id}/preview`
|
|
279
|
+
: `${server}/api/primo/import/${site_id}`;
|
|
255
280
|
spinner.text = options.preview ? 'Previewing changes...' : 'Pushing changes...';
|
|
256
281
|
const form_data = new FormData();
|
|
257
282
|
form_data.append('file', new Blob([zip_buffer]), 'site.zip');
|
|
283
|
+
if (group_name)
|
|
284
|
+
form_data.append('group_name', group_name);
|
|
258
285
|
const response = await fetch(endpoint, {
|
|
259
286
|
method: 'POST',
|
|
260
287
|
headers: { 'Authorization': `Bearer ${token}` },
|
|
261
288
|
body: form_data
|
|
262
289
|
});
|
|
263
290
|
// 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/
|
|
291
|
+
// freshly-deployed server we can fall back to /api/primo/bootstrap,
|
|
265
292
|
// which creates the site and ingests the zip in one shot. Bootstrap is
|
|
266
293
|
// only available when the server has zero sites — past the first site,
|
|
267
294
|
// new sites must be created via the dashboard UI.
|
|
268
295
|
if (response.status === 404 && !options.preview) {
|
|
269
296
|
spinner.text = 'Site not found on server — bootstrapping...';
|
|
270
|
-
const bootstrap_result = await try_bootstrap_site(server, token, zip_buffer, config, site_id);
|
|
297
|
+
const bootstrap_result = await try_bootstrap_site(server, token, zip_buffer, config, site_id, group_name);
|
|
271
298
|
if (bootstrap_result.ok) {
|
|
272
299
|
spinner.succeed(`Bootstrapped ${config?.name || path.basename(site_dir)}`);
|
|
273
300
|
console.log('');
|
|
@@ -295,13 +322,15 @@ async function push_single_site(site_dir, options, spinner) {
|
|
|
295
322
|
print_diff(result.diff);
|
|
296
323
|
}
|
|
297
324
|
}
|
|
298
|
-
async function try_bootstrap_site(server, token, zip_buffer, config, site_id) {
|
|
325
|
+
async function try_bootstrap_site(server, token, zip_buffer, config, site_id, group_name) {
|
|
299
326
|
const form = new FormData();
|
|
300
327
|
form.append('site_id', site_id);
|
|
301
328
|
if (config?.name)
|
|
302
329
|
form.append('name', config.name);
|
|
303
330
|
if (config?.group)
|
|
304
331
|
form.append('group', config.group);
|
|
332
|
+
if (group_name)
|
|
333
|
+
form.append('group_name', group_name);
|
|
305
334
|
// Register the site against the deploy URL's host so the first visit to
|
|
306
335
|
// that domain finds a matching site instead of dropping into CreateSite.
|
|
307
336
|
try {
|
|
@@ -314,7 +343,7 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id) {
|
|
|
314
343
|
const headers = {};
|
|
315
344
|
if (token)
|
|
316
345
|
headers['Authorization'] = `Bearer ${token}`;
|
|
317
|
-
const response = await fetch(`${server}/api/
|
|
346
|
+
const response = await fetch(`${server}/api/primo/bootstrap`, {
|
|
318
347
|
method: 'POST',
|
|
319
348
|
headers,
|
|
320
349
|
body: form
|
|
@@ -370,7 +399,7 @@ async function push_library_dir(root_dir, options, spinner) {
|
|
|
370
399
|
spinner.text = 'Pushing library...';
|
|
371
400
|
const form_data = new FormData();
|
|
372
401
|
form_data.append('file', new Blob([zip_buffer]), 'library.zip');
|
|
373
|
-
const response = await fetch(`${server}/api/
|
|
402
|
+
const response = await fetch(`${server}/api/primo/import-library`, {
|
|
374
403
|
method: 'POST',
|
|
375
404
|
headers: { 'Authorization': `Bearer ${token}` },
|
|
376
405
|
body: form_data
|
|
@@ -456,7 +456,7 @@ async function validate_page_types(site_dir) {
|
|
|
456
456
|
}
|
|
457
457
|
}
|
|
458
458
|
// layout.yaml is required — comment-only stub is fine, but the file
|
|
459
|
-
// must exist so the page type's
|
|
459
|
+
// must exist so the page type's header/body/footer slots are
|
|
460
460
|
// discoverable.
|
|
461
461
|
try {
|
|
462
462
|
await fs.access(layout_path);
|
|
@@ -464,7 +464,7 @@ async function validate_page_types(site_dir) {
|
|
|
464
464
|
catch {
|
|
465
465
|
errors.push({
|
|
466
466
|
file: `page-types/${page_type_name}/layout.yaml`,
|
|
467
|
-
message: 'Missing layout.yaml. Each page type needs one (use the comment-only stub if there are no shared header/footer sections yet).',
|
|
467
|
+
message: 'Missing layout.yaml. Each page type needs one (use the comment-only stub if there are no shared header/body/footer sections yet).',
|
|
468
468
|
severity: 'error'
|
|
469
469
|
});
|
|
470
470
|
}
|
package/dist/index.js
CHANGED
|
@@ -56,6 +56,7 @@ program
|
|
|
56
56
|
.command('deploy')
|
|
57
57
|
.description('Deploy this workspace (all sites) with editable CMS (Railway, Fly)')
|
|
58
58
|
.option('-p, --provider <provider>', 'Provider: railway | fly')
|
|
59
|
+
.option('--no-push', 'Skip uploading the workspace after provisioning (you can run `primo push` later)')
|
|
59
60
|
.option('--dry-run', 'Show what would be deployed without doing anything')
|
|
60
61
|
.addHelpText('after', `
|
|
61
62
|
${chalk.bold('Supported providers')}
|
package/dist/utils/binary.js
CHANGED
|
@@ -9,11 +9,10 @@ import ora from 'ora';
|
|
|
9
9
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
const PRIMO_HOME = path.join(os.homedir(), '.primo');
|
|
11
11
|
const BIN_DIR = path.join(PRIMO_HOME, 'bin');
|
|
12
|
-
const
|
|
13
|
-
const VERSION = '3.1.0'; // matches palacms releases
|
|
12
|
+
const VERSION = '3.2.0'; // matches primo releases
|
|
14
13
|
// Path to locally built binary (for development)
|
|
15
|
-
// The binary is at
|
|
16
|
-
const LOCAL_BINARY = path.resolve(__dirname, '..', '..', '..', '
|
|
14
|
+
// The binary is at primo/primo (inside the primo repo directory)
|
|
15
|
+
const LOCAL_BINARY = path.resolve(__dirname, '..', '..', '..', 'primo', 'primo');
|
|
17
16
|
function get_platform() {
|
|
18
17
|
const platform = os.platform();
|
|
19
18
|
const arch = os.arch();
|
|
@@ -47,13 +46,25 @@ function get_platform() {
|
|
|
47
46
|
return { os: osName, arch: archName, ext };
|
|
48
47
|
}
|
|
49
48
|
function get_download_url(platform) {
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const filename = `palacms_${platform.os}_${platform.arch}${platform.ext}`;
|
|
49
|
+
const base = 'https://github.com/primocms/primo/releases/download';
|
|
50
|
+
const filename = `primo_${platform.os}_${platform.arch}${platform.ext}`;
|
|
53
51
|
return `${base}/v${VERSION}/${filename}`;
|
|
54
52
|
}
|
|
55
53
|
export async function get_binary_path() {
|
|
56
|
-
//
|
|
54
|
+
// Explicit override wins — layout-independent, the way to point at a
|
|
55
|
+
// local server build regardless of where this CLI lives on disk.
|
|
56
|
+
const override = process.env.PRIMO_BINARY;
|
|
57
|
+
if (override) {
|
|
58
|
+
try {
|
|
59
|
+
await fs.access(override, fs.constants.X_OK);
|
|
60
|
+
return override;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new Error(`PRIMO_BINARY is set to "${override}" but it is not an executable file`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Otherwise prefer a sibling dev build (only resolves when running from
|
|
67
|
+
// source next to a `primo` checkout), then fall back to the download.
|
|
57
68
|
try {
|
|
58
69
|
await fs.access(LOCAL_BINARY, fs.constants.X_OK);
|
|
59
70
|
return LOCAL_BINARY;
|
|
@@ -61,7 +72,7 @@ export async function get_binary_path() {
|
|
|
61
72
|
catch { }
|
|
62
73
|
// Fall back to downloaded binary
|
|
63
74
|
const platform = get_platform();
|
|
64
|
-
return path.join(BIN_DIR, `
|
|
75
|
+
return path.join(BIN_DIR, `primo${platform.ext}`);
|
|
65
76
|
}
|
|
66
77
|
export async function ensure_data_dir(base_dir) {
|
|
67
78
|
const data_dir = path.join(base_dir, '.primo');
|
|
@@ -79,18 +90,23 @@ export async function is_binary_installed() {
|
|
|
79
90
|
}
|
|
80
91
|
}
|
|
81
92
|
export async function ensure_binary() {
|
|
93
|
+
// If PRIMO_BINARY is set, honor it exclusively — surface a bad override
|
|
94
|
+
// rather than silently downloading a release binary behind the user's back.
|
|
95
|
+
if (process.env.PRIMO_BINARY) {
|
|
96
|
+
return await get_binary_path();
|
|
97
|
+
}
|
|
82
98
|
if (await is_binary_installed()) {
|
|
83
99
|
return await get_binary_path();
|
|
84
100
|
}
|
|
85
101
|
// Need to download - get the target path
|
|
86
102
|
const platform = get_platform();
|
|
87
|
-
const binary_path = path.join(BIN_DIR, `
|
|
88
|
-
const spinner = ora('Setting up
|
|
103
|
+
const binary_path = path.join(BIN_DIR, `primo${platform.ext}`);
|
|
104
|
+
const spinner = ora('Setting up Primo...').start();
|
|
89
105
|
try {
|
|
90
106
|
// Create directories
|
|
91
107
|
await fs.mkdir(BIN_DIR, { recursive: true });
|
|
92
108
|
const url = get_download_url(platform);
|
|
93
|
-
spinner.text = `Downloading
|
|
109
|
+
spinner.text = `Downloading primo for ${platform.os}/${platform.arch}...`;
|
|
94
110
|
// Download binary
|
|
95
111
|
const response = await fetch(url);
|
|
96
112
|
if (!response.ok) {
|
|
@@ -101,7 +117,7 @@ export async function ensure_binary() {
|
|
|
101
117
|
await pipeline(response.body, file_stream);
|
|
102
118
|
// Make executable
|
|
103
119
|
await fs.chmod(binary_path, 0o755);
|
|
104
|
-
spinner.succeed('
|
|
120
|
+
spinner.succeed('Primo setup complete');
|
|
105
121
|
return binary_path;
|
|
106
122
|
}
|
|
107
123
|
catch (error) {
|
|
@@ -109,16 +125,16 @@ export async function ensure_binary() {
|
|
|
109
125
|
// Provide manual instructions
|
|
110
126
|
console.log('');
|
|
111
127
|
console.log(chalk.yellow('To install manually:'));
|
|
112
|
-
console.log(chalk.dim(' 1. Download
|
|
128
|
+
console.log(chalk.dim(' 1. Download primo from https://github.com/primocms/primo/releases'));
|
|
113
129
|
console.log(chalk.dim(` 2. Place it in ${BIN_DIR}`));
|
|
114
|
-
console.log(chalk.dim(' 3. Make it executable: chmod +x
|
|
130
|
+
console.log(chalk.dim(' 3. Make it executable: chmod +x primo'));
|
|
115
131
|
console.log('');
|
|
116
132
|
throw error;
|
|
117
133
|
}
|
|
118
134
|
}
|
|
119
135
|
export async function get_binary_version() {
|
|
120
136
|
try {
|
|
121
|
-
const binary_path = get_binary_path();
|
|
137
|
+
const binary_path = await get_binary_path();
|
|
122
138
|
const { execSync } = await import('child_process');
|
|
123
139
|
const output = execSync(`"${binary_path}" --version`, { encoding: 'utf-8' });
|
|
124
140
|
return output.trim();
|