primo-cli 0.1.10 → 0.1.11

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/`);
@@ -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
  });
@@ -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('');
@@ -255,6 +280,8 @@ async function push_single_site(site_dir, options, spinner) {
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}` },
@@ -267,7 +294,7 @@ async function push_single_site(site_dir, options, spinner) {
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 {
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')}
@@ -10,7 +10,7 @@ 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
12
  const DATA_DIR = path.join(PRIMO_HOME, 'data');
13
- const VERSION = '3.1.0'; // matches palacms releases
13
+ const VERSION = '3.2.0'; // matches palacms releases
14
14
  // Path to locally built binary (for development)
15
15
  // The binary is at palacms/palacms (inside the palacms repo directory)
16
16
  const LOCAL_BINARY = path.resolve(__dirname, '..', '..', '..', 'palacms', 'palacms');
@@ -47,8 +47,7 @@ function get_platform() {
47
47
  return { os: osName, arch: archName, ext };
48
48
  }
49
49
  function get_download_url(platform) {
50
- // TODO: Update to actual GitHub releases URL
51
- const base = 'https://github.com/palacms/palacms/releases/download';
50
+ const base = 'https://github.com/primocms/primo/releases/download';
52
51
  const filename = `palacms_${platform.os}_${platform.arch}${platform.ext}`;
53
52
  return `${base}/v${VERSION}/${filename}`;
54
53
  }
@@ -109,7 +108,7 @@ export async function ensure_binary() {
109
108
  // Provide manual instructions
110
109
  console.log('');
111
110
  console.log(chalk.yellow('To install manually:'));
112
- console.log(chalk.dim(' 1. Download palacms from https://github.com/palacms/palacms/releases'));
111
+ console.log(chalk.dim(' 1. Download palacms from https://github.com/primocms/primo/releases'));
113
112
  console.log(chalk.dim(` 2. Place it in ${BIN_DIR}`));
114
113
  console.log(chalk.dim(' 3. Make it executable: chmod +x palacms'));
115
114
  console.log('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primo-cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Local development CLI for Primo",
5
5
  "type": "module",
6
6
  "bin": {