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.
@@ -1,6 +1,7 @@
1
1
  interface DeployOptions {
2
2
  provider?: string;
3
3
  dryRun?: boolean;
4
+ push?: boolean;
4
5
  }
5
6
  export declare function deploy(options: DeployOptions): Promise<void>;
6
7
  export {};
@@ -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 after first boot via primo push:'));
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 palacms image and run it
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/palacms/bootstrap on
233
- // first push (server with no sites) and /api/palacms/import/<id> on
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
- // palacms binds 0.0.0.0:8080 in its CMD; mount /app/pb_data on a persistent
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
- print_post_deploy_next_steps('railway', url);
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
- console.log(chalk.green('✓ Deployment started'));
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(' Then upload your workspace into the deployed server:'));
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
- async function deploy_to_fly(inventory) {
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
- print_post_deploy_next_steps('fly', url);
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
  });
@@ -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 palacms's serialization
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 — palacms' export step can
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 (palacms re-emits YAML with normalized key order,
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/palacms/export/${config.site_id}`, {}, 15000);
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 palacms...';
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 palacms which sync mode the CLI is running in so the CMS
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, PALA_DEV_MODE: '1', PRIMO_AUTHOR_MODE: sync_policy.mode }
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
- const local_mcp = process.env.PRIMO_MCP_LOCAL
1123
- ?? '/Users/mateo/Desktop/primo/primo-mcp/dist/index.js';
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', '@primo/mcp'] };
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/palacms/dev/reload`, {
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/palacms/import/${site_id}`, {
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/palacms/bootstrap`, {
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/palacms/import/${site_id}`, {
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/palacms/import-library`, {
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 palacms binary/server. Rebuild or update palacms to use library sync.');
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/palacms/export/${config.site_id}`, {}, 15000);
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/palacms/export-library`, {}, 15000);
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 palacms binary/server. Rebuild or update palacms to use library sync.');
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;
@@ -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/palacms/export-library`, {
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 palacms server. Update the server before using `primo library pull`.');
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) {
@@ -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/palacms/export/${site.id}`, { headers });
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/palacms/export-library`, { headers });
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/palacms/import-library`, {
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 palacms server. Update the server before using `primo library push`.');
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) {
@@ -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/palacms/import/${site_id}/preview`
254
- : `${server}/api/palacms/import/${site_id}`;
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/palacms/bootstrap,
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/palacms/bootstrap`, {
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/palacms/import-library`, {
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 shared header/footer slots are
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')}
@@ -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 DATA_DIR = path.join(PRIMO_HOME, 'data');
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 palacms/palacms (inside the palacms repo directory)
16
- const LOCAL_BINARY = path.resolve(__dirname, '..', '..', '..', 'palacms', 'palacms');
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
- // TODO: Update to actual GitHub releases URL
51
- const base = 'https://github.com/palacms/palacms/releases/download';
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
- // Check for locally built binary first (development)
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, `palacms${platform.ext}`);
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, `palacms${platform.ext}`);
88
- const spinner = ora('Setting up Pala...').start();
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 palacms for ${platform.os}/${platform.arch}...`;
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('Pala setup complete');
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 palacms from https://github.com/palacms/palacms/releases'));
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 palacms'));
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {