primo-cli 0.1.9 → 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.
- package/dist/commands/deploy.d.ts +1 -0
- package/dist/commands/deploy.js +121 -12
- package/dist/commands/push.js +40 -3
- package/dist/index.js +1 -0
- package/dist/utils/binary.js +3 -4
- 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/`);
|
|
@@ -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/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('');
|
|
@@ -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,23 @@ 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);
|
|
334
|
+
// Register the site against the deploy URL's host so the first visit to
|
|
335
|
+
// that domain finds a matching site instead of dropping into CreateSite.
|
|
336
|
+
try {
|
|
337
|
+
form.append('host', new URL(server).host);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// Malformed server URL — let the server fall back to its own default.
|
|
341
|
+
}
|
|
305
342
|
form.append('file', new Blob([zip_buffer]), 'site.zip');
|
|
306
343
|
const headers = {};
|
|
307
344
|
if (token)
|
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
|
@@ -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.
|
|
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
|
-
|
|
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/
|
|
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('');
|