primo-cli 0.1.15 → 0.1.17
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/build.js +68 -9
- package/dist/commands/deploy.js +47 -28
- package/dist/commands/dev.js +8 -0
- package/dist/commands/login.d.ts +1 -0
- package/dist/commands/login.js +17 -15
- package/dist/commands/pull-library.js +2 -1
- package/dist/commands/pull.js +22 -5
- package/dist/commands/push-library.js +2 -1
- package/dist/commands/push.js +6 -4
- package/dist/utils/server-config.d.ts +1 -0
- package/dist/utils/server-config.js +11 -0
- package/dist/utils/uploads.d.ts +34 -0
- package/dist/utils/uploads.js +148 -0
- package/package.json +1 -1
package/dist/commands/build.js
CHANGED
|
@@ -51,6 +51,11 @@ export async function build_site(options) {
|
|
|
51
51
|
// Find all pages
|
|
52
52
|
const pages_dir = path.join(site_dir, 'pages');
|
|
53
53
|
const page_files = await find_pages(pages_dir);
|
|
54
|
+
// Map each page's _id to its live URL so internal `page:` links resolve.
|
|
55
|
+
// The URL is derived from the page's file location (same convention the
|
|
56
|
+
// build uses to write output), not the slug, so a page-ref and a raw
|
|
57
|
+
// url pointing at the same page produce identical hrefs.
|
|
58
|
+
const page_url_map = await build_page_url_map(site_dir, page_files);
|
|
54
59
|
spinner.text = `Building ${page_files.length} page${page_files.length !== 1 ? 's' : ''}...`;
|
|
55
60
|
// Compile all blocks once and cache them
|
|
56
61
|
const block_cache = new Map();
|
|
@@ -77,7 +82,8 @@ export async function build_site(options) {
|
|
|
77
82
|
block_cache,
|
|
78
83
|
layout_cache,
|
|
79
84
|
page_type_head_cache,
|
|
80
|
-
site_data
|
|
85
|
+
site_data,
|
|
86
|
+
page_url_map
|
|
81
87
|
});
|
|
82
88
|
if (result.error) {
|
|
83
89
|
console.log(chalk.yellow(` Warning: ${page.name}: ${result.error}`));
|
|
@@ -140,7 +146,7 @@ async function load_page_type_head(site_dir, page_type, cache) {
|
|
|
140
146
|
return content;
|
|
141
147
|
}
|
|
142
148
|
async function build_page(options) {
|
|
143
|
-
const { page, page_path, site_dir, temp_dir, head_content, site_name, block_cache, layout_cache, page_type_head_cache, site_data } = options;
|
|
149
|
+
const { page, page_path, site_dir, temp_dir, head_content, site_name, block_cache, layout_cache, page_type_head_cache, site_data, page_url_map } = options;
|
|
144
150
|
try {
|
|
145
151
|
const page_build_id = safe_temp_id(page._id || page.id || page_path || page.name || 'page');
|
|
146
152
|
// Load layout for this page type
|
|
@@ -155,9 +161,9 @@ async function build_page(options) {
|
|
|
155
161
|
const page_type_head = await load_page_type_head(site_dir, page_type, page_type_head_cache);
|
|
156
162
|
const combined_head_content = page_type_head ? `${head_content}\n${page_type_head}` : head_content;
|
|
157
163
|
// Combine header + page sections + footer
|
|
158
|
-
const header_sections = await resolve_layout_sections(layout.header || [], site_dir, site_data);
|
|
159
|
-
const footer_sections = await resolve_layout_sections(layout.footer || [], site_dir, site_data);
|
|
160
|
-
const page_sections = await resolve_page_sections(page.sections || [], site_dir, site_data);
|
|
164
|
+
const header_sections = await resolve_layout_sections(layout.header || [], site_dir, site_data, page_url_map);
|
|
165
|
+
const footer_sections = await resolve_layout_sections(layout.footer || [], site_dir, site_data, page_url_map);
|
|
166
|
+
const page_sections = await resolve_page_sections(page.sections || [], site_dir, site_data, page_url_map);
|
|
161
167
|
const all_sections = [...header_sections, ...page_sections, ...footer_sections];
|
|
162
168
|
if (all_sections.length === 0) {
|
|
163
169
|
return { html: generate_empty_page(site_name, page.name, combined_head_content) };
|
|
@@ -448,7 +454,7 @@ async function load_layout(site_dir, page_type) {
|
|
|
448
454
|
return {};
|
|
449
455
|
}
|
|
450
456
|
}
|
|
451
|
-
async function resolve_layout_sections(sections, site_dir, site_data) {
|
|
457
|
+
async function resolve_layout_sections(sections, site_dir, site_data, page_url_map) {
|
|
452
458
|
// For layout sections without content, load from block's content.yaml
|
|
453
459
|
const resolved = [];
|
|
454
460
|
for (const section of sections) {
|
|
@@ -462,20 +468,73 @@ async function resolve_layout_sections(sections, site_dir, site_data) {
|
|
|
462
468
|
}
|
|
463
469
|
// Resolve any site-field references in the content
|
|
464
470
|
const resolved_content = await resolve_site_fields(site_dir, section.block, content, site_data);
|
|
465
|
-
|
|
471
|
+
// Resolve internal page: links to URLs (walks nested repeaters/groups too)
|
|
472
|
+
resolved.push({ ...section, content: resolve_links(resolved_content, page_url_map) });
|
|
466
473
|
}
|
|
467
474
|
return resolved;
|
|
468
475
|
}
|
|
469
|
-
async function resolve_page_sections(sections, site_dir, site_data) {
|
|
476
|
+
async function resolve_page_sections(sections, site_dir, site_data, page_url_map) {
|
|
470
477
|
// Resolve site-field references in page sections
|
|
471
478
|
const resolved = [];
|
|
472
479
|
for (const section of sections) {
|
|
473
480
|
const content = section.content || {};
|
|
474
481
|
const resolved_content = await resolve_site_fields(site_dir, section.block, content, site_data);
|
|
475
|
-
|
|
482
|
+
// Resolve internal page: links to URLs (walks nested repeaters/groups too)
|
|
483
|
+
resolved.push({ ...section, content: resolve_links(resolved_content, page_url_map) });
|
|
476
484
|
}
|
|
477
485
|
return resolved;
|
|
478
486
|
}
|
|
487
|
+
// Build a map of page _id -> live URL path. The URL is derived from the page
|
|
488
|
+
// file's location (the same convention used to write output HTML), so a
|
|
489
|
+
// `page:` reference resolves to exactly the path that page is deployed at.
|
|
490
|
+
async function build_page_url_map(site_dir, page_files) {
|
|
491
|
+
const map = new Map();
|
|
492
|
+
for (const page_file of page_files) {
|
|
493
|
+
try {
|
|
494
|
+
const page = load_yaml(await fs.readFile(page_file, 'utf-8'));
|
|
495
|
+
const id = page._id || page.id;
|
|
496
|
+
if (!id)
|
|
497
|
+
continue;
|
|
498
|
+
const page_path = get_page_path_from_file(site_dir, page_file);
|
|
499
|
+
map.set(id, page_path === '' ? '/' : `/${page_path}`);
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
// Skip unparseable page files; they'll surface elsewhere in the build.
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return map;
|
|
506
|
+
}
|
|
507
|
+
// Recursively resolve internal `page:` links to their URL. Walks arbitrarily
|
|
508
|
+
// nested content (repeaters, groups, page-lists), so links inside a
|
|
509
|
+
// site-field-referenced repeater resolve the same as top-level link fields.
|
|
510
|
+
//
|
|
511
|
+
// A link value is any object carrying a `page` id. We look the id up in the
|
|
512
|
+
// page URL map and populate `url`:
|
|
513
|
+
// - known page id -> the page's live URL
|
|
514
|
+
// - missing/deleted id -> '' (degrades to href="#" downstream, never crashes)
|
|
515
|
+
// Objects with only `url` (raw/external links) are left untouched, and every
|
|
516
|
+
// other key on the link object (label, etc.) is preserved.
|
|
517
|
+
function resolve_links(value, page_url_map) {
|
|
518
|
+
if (Array.isArray(value)) {
|
|
519
|
+
return value.map((item) => resolve_links(item, page_url_map));
|
|
520
|
+
}
|
|
521
|
+
if (value && typeof value === 'object') {
|
|
522
|
+
const obj = value;
|
|
523
|
+
// A link with a page reference: resolve it to a URL.
|
|
524
|
+
if (typeof obj.page === 'string' && obj.page) {
|
|
525
|
+
const url = page_url_map.get(obj.page) ?? '';
|
|
526
|
+
return { ...obj, url };
|
|
527
|
+
}
|
|
528
|
+
// Otherwise recurse into every value (covers repeater arrays, groups,
|
|
529
|
+
// and the `{ link: {...} }` wrapper repeaters produce).
|
|
530
|
+
const result = {};
|
|
531
|
+
for (const [key, child] of Object.entries(obj)) {
|
|
532
|
+
result[key] = resolve_links(child, page_url_map);
|
|
533
|
+
}
|
|
534
|
+
return result;
|
|
535
|
+
}
|
|
536
|
+
return value;
|
|
537
|
+
}
|
|
479
538
|
async function load_block_defaults(site_dir, block_name) {
|
|
480
539
|
const content_path = path.join(site_dir, 'blocks', block_name, 'content.yaml');
|
|
481
540
|
try {
|
package/dist/commands/deploy.js
CHANGED
|
@@ -2,7 +2,7 @@ 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
|
|
5
|
+
import { select } 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';
|
|
@@ -301,6 +301,30 @@ async function deploy_to_railway(inventory, auto_push) {
|
|
|
301
301
|
spinner.fail('Failed to set up Railway project');
|
|
302
302
|
throw error;
|
|
303
303
|
}
|
|
304
|
+
// Attach a persistent volume at /app/pb_data BEFORE the first deploy. If the
|
|
305
|
+
// container starts without it, primo writes the SQLite db to ephemeral
|
|
306
|
+
// storage and it's lost on the next image change or restart. Mounting after
|
|
307
|
+
// data already exists starts from an empty volume, so ordering matters.
|
|
308
|
+
spinner.start('Ensuring persistent volume at /app/pb_data...');
|
|
309
|
+
try {
|
|
310
|
+
if (railway_volume_exists(inventory.root_dir, PB_DATA_MOUNT_PATH)) {
|
|
311
|
+
spinner.succeed('Persistent volume already mounted at /app/pb_data');
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
execSync(`railway volume add --mount-path ${PB_DATA_MOUNT_PATH}`, {
|
|
315
|
+
cwd: inventory.root_dir,
|
|
316
|
+
stdio: 'ignore'
|
|
317
|
+
});
|
|
318
|
+
spinner.succeed('Created persistent volume at /app/pb_data');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// Volume creation can fail (e.g. plan limits, already-attached on another
|
|
323
|
+
// service). Don't abort the deploy — warn and fall back to the manual
|
|
324
|
+
// dashboard step so the user can still finish.
|
|
325
|
+
spinner.warn('Could not attach volume automatically — mount it manually');
|
|
326
|
+
console.log(chalk.dim(' Settings → Volumes → mount on /app/pb_data (size 1GB+)'));
|
|
327
|
+
}
|
|
304
328
|
console.log('');
|
|
305
329
|
console.log(chalk.dim('Building and deploying...'));
|
|
306
330
|
console.log('');
|
|
@@ -323,10 +347,9 @@ async function deploy_to_railway(inventory, auto_push) {
|
|
|
323
347
|
if (url) {
|
|
324
348
|
await record_workspace_server(inventory.root_dir, url);
|
|
325
349
|
}
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
// to the old "next steps" message so the user can finish by hand.
|
|
350
|
+
// Volume is already attached (before deploy), so just wait for the
|
|
351
|
+
// server to come online and push. If anything fails we fall back to
|
|
352
|
+
// the "next steps" message so the user can finish by hand.
|
|
330
353
|
const pushed = url
|
|
331
354
|
? await finish_railway_deploy(inventory, url, auto_push)
|
|
332
355
|
: false;
|
|
@@ -336,30 +359,33 @@ async function deploy_to_railway(inventory, auto_push) {
|
|
|
336
359
|
});
|
|
337
360
|
}
|
|
338
361
|
async function finish_railway_deploy(inventory, url, auto_push) {
|
|
339
|
-
|
|
340
|
-
|
|
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('');
|
|
362
|
+
// The persistent volume is attached before deploy, so there's no manual
|
|
363
|
+
// dashboard step left — just wait for the server and push.
|
|
344
364
|
if (!auto_push)
|
|
345
365
|
return false;
|
|
346
|
-
|
|
366
|
+
const ready = await wait_for_ready(url);
|
|
367
|
+
if (!ready)
|
|
368
|
+
return false;
|
|
369
|
+
return await run_auto_push(inventory);
|
|
370
|
+
}
|
|
371
|
+
const PB_DATA_MOUNT_PATH = '/app/pb_data';
|
|
372
|
+
// Check whether the linked Railway project already has a volume mounted at the
|
|
373
|
+
// given path. The list JSON field name has varied across CLI versions, so we
|
|
374
|
+
// accept a few spellings rather than pinning one.
|
|
375
|
+
function railway_volume_exists(cwd, mount_path) {
|
|
347
376
|
try {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
377
|
+
const out = execSync('railway volume list --json', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
378
|
+
const volumes = JSON.parse(out);
|
|
379
|
+
if (!Array.isArray(volumes))
|
|
380
|
+
return false;
|
|
381
|
+
return volumes.some((v) => {
|
|
382
|
+
const path = v.mountPath ?? v.mount_path ?? v.mountpath;
|
|
383
|
+
return typeof path === 'string' && path.replace(/\/+$/, '') === mount_path.replace(/\/+$/, '');
|
|
351
384
|
});
|
|
352
385
|
}
|
|
353
386
|
catch {
|
|
354
|
-
// Ctrl+C / non-interactive — bail out, user can run primo push later.
|
|
355
387
|
return false;
|
|
356
388
|
}
|
|
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
389
|
}
|
|
364
390
|
async function try_railway_domain(cwd) {
|
|
365
391
|
try {
|
|
@@ -411,13 +437,6 @@ function print_post_deploy_next_steps(provider, url, ctx) {
|
|
|
411
437
|
}
|
|
412
438
|
console.log(chalk.bold('Next steps'));
|
|
413
439
|
console.log('');
|
|
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.
|
|
417
|
-
console.log(chalk.dim(' Railway needs one manual setting the CLI can\'t set for you:'));
|
|
418
|
-
console.log(chalk.dim(' Settings → Volumes → mount on /app/pb_data (size 1GB+)'));
|
|
419
|
-
console.log('');
|
|
420
|
-
}
|
|
421
440
|
console.log(chalk.dim(' Upload your workspace into the deployed server:'));
|
|
422
441
|
console.log(chalk.dim(` primo push${url ? '' : ' -s <url>'}`));
|
|
423
442
|
console.log(chalk.dim(' (first push bootstraps the sites; later pushes update them incrementally)'));
|
package/dist/commands/dev.js
CHANGED
|
@@ -1921,6 +1921,14 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1921
1921
|
if (bootstrap_response.ok) {
|
|
1922
1922
|
try {
|
|
1923
1923
|
const result = await bootstrap_response.json();
|
|
1924
|
+
// Bootstrap runs the same import as the regular path, so it
|
|
1925
|
+
// must write back created ids too — otherwise the very first
|
|
1926
|
+
// push never renames upload files to their canonical suffixed
|
|
1927
|
+
// names or rewrites symbolic refs, and later pushes keep
|
|
1928
|
+
// re-sending the un-suffixed names (the upload dup bug).
|
|
1929
|
+
if (result.created_ids) {
|
|
1930
|
+
await write_created_ids(site_dir, result.created_ids, server_config, workspace_dir);
|
|
1931
|
+
}
|
|
1924
1932
|
warning_count = print_import_warnings(config.name, result.warnings);
|
|
1925
1933
|
}
|
|
1926
1934
|
catch {
|
package/dist/commands/login.d.ts
CHANGED
package/dist/commands/login.js
CHANGED
|
@@ -3,7 +3,7 @@ import ora from 'ora';
|
|
|
3
3
|
import readline from 'readline';
|
|
4
4
|
import fs from 'fs/promises';
|
|
5
5
|
import { save_auth_token } from '../utils/auth.js';
|
|
6
|
-
import { read_server_config, get_server_config_path } from '../utils/server-config.js';
|
|
6
|
+
import { read_server_config, get_server_config_path, normalize_server_url } from '../utils/server-config.js';
|
|
7
7
|
export async function login(options) {
|
|
8
8
|
let server_url = options.server;
|
|
9
9
|
if (!server_url) {
|
|
@@ -29,7 +29,19 @@ export async function login(options) {
|
|
|
29
29
|
console.log('');
|
|
30
30
|
console.log(chalk.bold(`Logging in to ${server}`));
|
|
31
31
|
console.log('');
|
|
32
|
-
|
|
32
|
+
const token = await authenticate_interactively(server, options.email);
|
|
33
|
+
if (!token) {
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
console.log('');
|
|
37
|
+
console.log(chalk.dim(' Token saved to ~/.primo/tokens.json'));
|
|
38
|
+
console.log(chalk.dim(' You can now use `primo pull` and `primo push` without --token'));
|
|
39
|
+
}
|
|
40
|
+
// Prompt for credentials (email if not supplied, then password), authenticate
|
|
41
|
+
// against the server, and persist the token on success. Returns the token, or
|
|
42
|
+
// null if authentication failed. Shared by `login` and the auto-login prompt
|
|
43
|
+
// that `pull`/`push` fall back to when no token is cached.
|
|
44
|
+
export async function authenticate_interactively(server, email) {
|
|
33
45
|
if (!email) {
|
|
34
46
|
email = await prompt('Email: ');
|
|
35
47
|
}
|
|
@@ -50,28 +62,18 @@ export async function login(options) {
|
|
|
50
62
|
if (!response.ok) {
|
|
51
63
|
const error = await response.json().catch(() => ({ message: 'Authentication failed' }));
|
|
52
64
|
spinner.fail(`Login failed: ${error.message || 'Invalid credentials'}`);
|
|
53
|
-
|
|
65
|
+
return null;
|
|
54
66
|
}
|
|
55
67
|
const data = await response.json();
|
|
56
68
|
// Save the token
|
|
57
69
|
await save_auth_token(server, data.token);
|
|
58
70
|
spinner.succeed(`Logged in as ${chalk.cyan(data.record.email)}`);
|
|
59
|
-
|
|
60
|
-
console.log(chalk.dim(' Token saved to ~/.primo/tokens.json'));
|
|
61
|
-
console.log(chalk.dim(' You can now use `primo pull` and `primo push` without --token'));
|
|
71
|
+
return data.token;
|
|
62
72
|
}
|
|
63
73
|
catch (error) {
|
|
64
74
|
spinner.fail(`Login failed: ${error instanceof Error ? error.message : error}`);
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
function normalize_server_url(server) {
|
|
69
|
-
// Add https:// if no protocol specified
|
|
70
|
-
if (!server.startsWith('http://') && !server.startsWith('https://')) {
|
|
71
|
-
server = `https://${server}`;
|
|
75
|
+
return null;
|
|
72
76
|
}
|
|
73
|
-
// Remove trailing slash
|
|
74
|
-
return server.replace(/\/+$/, '');
|
|
75
77
|
}
|
|
76
78
|
function prompt(question) {
|
|
77
79
|
const rl = readline.createInterface({
|
|
@@ -4,6 +4,7 @@ import chalk from 'chalk';
|
|
|
4
4
|
import ora from 'ora';
|
|
5
5
|
import extract from 'extract-zip';
|
|
6
6
|
import { get_auth_token } from '../utils/auth.js';
|
|
7
|
+
import { normalize_server_url } from '../utils/server-config.js';
|
|
7
8
|
async function detect_server() {
|
|
8
9
|
const ports = [3000, 8080, 5173];
|
|
9
10
|
for (const port of ports) {
|
|
@@ -27,7 +28,7 @@ export async function pull_library(options) {
|
|
|
27
28
|
try {
|
|
28
29
|
let server;
|
|
29
30
|
if (options.server) {
|
|
30
|
-
server = options.server
|
|
31
|
+
server = normalize_server_url(options.server);
|
|
31
32
|
}
|
|
32
33
|
else {
|
|
33
34
|
spinner.text = 'Looking for local server...';
|
package/dist/commands/pull.js
CHANGED
|
@@ -5,8 +5,9 @@ import ora from 'ora';
|
|
|
5
5
|
import extract from 'extract-zip';
|
|
6
6
|
import { dump as dump_yaml, load as load_yaml } from 'js-yaml';
|
|
7
7
|
import { get_auth_token } from '../utils/auth.js';
|
|
8
|
+
import { authenticate_interactively } from './login.js';
|
|
8
9
|
import { write_site_config } from '../utils/site-config.js';
|
|
9
|
-
import { read_server_config, write_server_config } from '../utils/server-config.js';
|
|
10
|
+
import { read_server_config, write_server_config, normalize_server_url } from '../utils/server-config.js';
|
|
10
11
|
async function detect_server() {
|
|
11
12
|
const ports = [3000, 8080, 5173];
|
|
12
13
|
for (const port of ports) {
|
|
@@ -64,12 +65,15 @@ export async function pull_site(options) {
|
|
|
64
65
|
let server;
|
|
65
66
|
let used_configured = false;
|
|
66
67
|
if (options.server) {
|
|
67
|
-
server = options.server
|
|
68
|
+
server = normalize_server_url(options.server);
|
|
68
69
|
}
|
|
69
70
|
else {
|
|
70
71
|
const configured = await read_configured_server(process.cwd());
|
|
71
72
|
if (configured) {
|
|
72
|
-
server
|
|
73
|
+
// A hand-edited server.yaml may hold a bare host; normalize it so
|
|
74
|
+
// is_remote_server() and the inline-login guard below behave the
|
|
75
|
+
// same as they do for a --server flag.
|
|
76
|
+
server = normalize_server_url(configured);
|
|
73
77
|
used_configured = true;
|
|
74
78
|
spinner.text = `Using ${server}`;
|
|
75
79
|
}
|
|
@@ -80,8 +84,21 @@ export async function pull_site(options) {
|
|
|
80
84
|
spinner.text = `Using ${server}`;
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
|
-
// Auth (optional for local)
|
|
84
|
-
|
|
87
|
+
// Auth (optional for local). For a remote server with no cached token,
|
|
88
|
+
// prompt for login inline instead of bailing out — the user almost
|
|
89
|
+
// always wants to authenticate and continue rather than re-run.
|
|
90
|
+
let token = options.token || await get_auth_token(server);
|
|
91
|
+
if (!token && is_remote_server(server)) {
|
|
92
|
+
spinner.stop();
|
|
93
|
+
console.log('');
|
|
94
|
+
console.log(chalk.dim(` Not logged in to ${server}. Log in to continue.`));
|
|
95
|
+
console.log('');
|
|
96
|
+
token = await authenticate_interactively(server);
|
|
97
|
+
if (!token) {
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
spinner.start('Fetching sites...');
|
|
101
|
+
}
|
|
85
102
|
const headers = {};
|
|
86
103
|
if (token) {
|
|
87
104
|
headers['Authorization'] = `Bearer ${token}`;
|
|
@@ -4,6 +4,7 @@ import chalk from 'chalk';
|
|
|
4
4
|
import ora from 'ora';
|
|
5
5
|
import archiver from 'archiver';
|
|
6
6
|
import { get_auth_token } from '../utils/auth.js';
|
|
7
|
+
import { normalize_server_url } from '../utils/server-config.js';
|
|
7
8
|
function is_local_server(server) {
|
|
8
9
|
try {
|
|
9
10
|
const url = new URL(server);
|
|
@@ -28,7 +29,7 @@ export async function push_library(options) {
|
|
|
28
29
|
spinner.fail(`Library directory not found at ${chalk.cyan(library_dir)}.`);
|
|
29
30
|
process.exit(1);
|
|
30
31
|
}
|
|
31
|
-
const server = options.server
|
|
32
|
+
const server = options.server ? normalize_server_url(options.server) : undefined;
|
|
32
33
|
if (!server) {
|
|
33
34
|
spinner.fail('Server URL required. Pass it as the first argument or use --server.');
|
|
34
35
|
process.exit(1);
|
package/dist/commands/push.js
CHANGED
|
@@ -5,7 +5,7 @@ import ora from 'ora';
|
|
|
5
5
|
import archiver from 'archiver';
|
|
6
6
|
import { get_auth_token } from '../utils/auth.js';
|
|
7
7
|
import { read_site_config, get_site_config_path, SITE_CONFIG_FILE } from '../utils/site-config.js';
|
|
8
|
-
import { get_server_config_path, read_server_config } from '../utils/server-config.js';
|
|
8
|
+
import { get_server_config_path, read_server_config, normalize_server_url } from '../utils/server-config.js';
|
|
9
9
|
async function path_exists(p) {
|
|
10
10
|
try {
|
|
11
11
|
await fs.access(p);
|
|
@@ -133,7 +133,8 @@ async function print_push_dry_run(root_dir, has_site_yaml, has_server_yaml, opti
|
|
|
133
133
|
}
|
|
134
134
|
library_present = await path_exists(path.join(root_dir, 'library'));
|
|
135
135
|
}
|
|
136
|
-
const
|
|
136
|
+
const server_raw = options.server || inferred_server;
|
|
137
|
+
const server = server_raw ? normalize_server_url(server_raw) : undefined;
|
|
137
138
|
console.log(` Target server: ${chalk.cyan(server || '(not set — pass --server or set in site.yaml)')}`);
|
|
138
139
|
console.log('');
|
|
139
140
|
console.log(chalk.bold(' Will sync:'));
|
|
@@ -243,7 +244,8 @@ async function push_single_site(site_dir, options, spinner) {
|
|
|
243
244
|
catch {
|
|
244
245
|
// No config file, must provide options
|
|
245
246
|
}
|
|
246
|
-
const
|
|
247
|
+
const server_raw = options.server || config?.server;
|
|
248
|
+
const server = server_raw ? normalize_server_url(server_raw) : undefined;
|
|
247
249
|
const site_id = options.site || config?.site_id;
|
|
248
250
|
if (!server) {
|
|
249
251
|
throw new Error(`Server URL required. Use --server or add server field to ${SITE_CONFIG_FILE}.`);
|
|
@@ -362,7 +364,7 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id, gr
|
|
|
362
364
|
}
|
|
363
365
|
async function push_library_dir(root_dir, options, spinner) {
|
|
364
366
|
// Resolve server: --server > any site.yaml's server (they all point at the same server)
|
|
365
|
-
let server = options.server
|
|
367
|
+
let server = options.server ? normalize_server_url(options.server) : undefined;
|
|
366
368
|
if (!server) {
|
|
367
369
|
const sites_root = path.join(root_dir, 'sites');
|
|
368
370
|
if (await path_exists(sites_root)) {
|
|
@@ -12,6 +12,7 @@ export interface ServerConfig {
|
|
|
12
12
|
}
|
|
13
13
|
export declare const SERVER_CONFIG_FILE = "server.yaml";
|
|
14
14
|
export declare function get_server_config_path(base_dir: string): string;
|
|
15
|
+
export declare function normalize_server_url(server: string): string;
|
|
15
16
|
export declare function format_group_name(group_id: string): string;
|
|
16
17
|
export declare function normalize_server_config(config: ServerConfig): ServerConfig;
|
|
17
18
|
export declare function resolve_format_options(config: ServerConfig): FormatOptions;
|
|
@@ -6,6 +6,17 @@ export const SERVER_CONFIG_FILE = 'server.yaml';
|
|
|
6
6
|
export function get_server_config_path(base_dir) {
|
|
7
7
|
return path.join(base_dir, SERVER_CONFIG_FILE);
|
|
8
8
|
}
|
|
9
|
+
// Normalize a user-supplied server value: prepend a protocol when missing and
|
|
10
|
+
// strip trailing slashes. Bare localhost/127.0.0.1 stay on http:// (local dev);
|
|
11
|
+
// everything else gets https://.
|
|
12
|
+
export function normalize_server_url(server) {
|
|
13
|
+
let normalized = server.trim();
|
|
14
|
+
if (!/^https?:\/\//.test(normalized)) {
|
|
15
|
+
const is_local = /^(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?(\/|$)/.test(normalized);
|
|
16
|
+
normalized = `${is_local ? 'http' : 'https'}://${normalized}`;
|
|
17
|
+
}
|
|
18
|
+
return normalized.replace(/\/+$/, '');
|
|
19
|
+
}
|
|
9
20
|
export function format_group_name(group_id) {
|
|
10
21
|
if (!group_id.trim())
|
|
11
22
|
return 'Default';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare class UploadsUnsupportedError extends Error {
|
|
2
|
+
constructor();
|
|
3
|
+
}
|
|
4
|
+
export declare class SiteNotFoundError extends Error {
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
export interface LocalUpload {
|
|
8
|
+
filename: string;
|
|
9
|
+
abs_path: string;
|
|
10
|
+
hash: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function collect_local_uploads(site_dir: string): Promise<LocalUpload[]>;
|
|
13
|
+
export interface UploadPutResult {
|
|
14
|
+
filename: string;
|
|
15
|
+
id: string;
|
|
16
|
+
canonical: string;
|
|
17
|
+
hash: string;
|
|
18
|
+
changed: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface SyncUploadsOptions {
|
|
21
|
+
server: string;
|
|
22
|
+
site_id: string;
|
|
23
|
+
token: string | undefined;
|
|
24
|
+
site_dir: string;
|
|
25
|
+
on_progress?: (done: number, total: number) => void;
|
|
26
|
+
concurrency?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface SyncUploadsResult {
|
|
29
|
+
total: number;
|
|
30
|
+
uploaded: number;
|
|
31
|
+
skipped: number;
|
|
32
|
+
results: UploadPutResult[];
|
|
33
|
+
}
|
|
34
|
+
export declare function sync_uploads(opts: SyncUploadsOptions): Promise<SyncUploadsResult>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
// Incremental upload sync. Instead of packing every binary into the import
|
|
5
|
+
// zip — which made large-media sites exceed the hosting proxy's request
|
|
6
|
+
// timeout on a single synchronous /import call — we push uploads out of band:
|
|
7
|
+
// hash them locally, ask the server which hashes it's missing, and PUT only
|
|
8
|
+
// those, one small request each.
|
|
9
|
+
//
|
|
10
|
+
// Servers predating these endpoints answer /check with 404; callers detect
|
|
11
|
+
// that via UploadsUnsupportedError and fall back to shipping uploads in the
|
|
12
|
+
// zip (the legacy path).
|
|
13
|
+
export class UploadsUnsupportedError extends Error {
|
|
14
|
+
constructor() {
|
|
15
|
+
super('server does not support incremental uploads');
|
|
16
|
+
this.name = 'UploadsUnsupportedError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// The site doesn't exist on the server yet — its media can't be pushed until
|
|
20
|
+
// it's created (via bootstrap). Distinct from UploadsUnsupportedError so the
|
|
21
|
+
// caller routes to bootstrap instead of the legacy zip-uploads path.
|
|
22
|
+
export class SiteNotFoundError extends Error {
|
|
23
|
+
constructor() {
|
|
24
|
+
super('site not found on server');
|
|
25
|
+
this.name = 'SiteNotFoundError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// PocketBase's router emits this exact message when no route matches. We use
|
|
29
|
+
// it to tell "endpoint missing" (old server → unsupported) apart from a
|
|
30
|
+
// handler-level 404 like "Site not found" (new server, site absent).
|
|
31
|
+
const ROUTER_NOT_FOUND_MESSAGE = "The requested resource wasn't found.";
|
|
32
|
+
// Classify a 404 body: true = the ROUTE is missing (old server), so uploads
|
|
33
|
+
// are unsupported; false = a handler 404 (e.g. site not found).
|
|
34
|
+
async function is_route_missing(res) {
|
|
35
|
+
try {
|
|
36
|
+
const body = (await res.clone().json());
|
|
37
|
+
return body?.message === ROUTER_NOT_FOUND_MESSAGE;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Non-JSON 404 → treat as route missing (safest: fall back to zip).
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const UPLOADS_DIR = 'uploads';
|
|
45
|
+
const MANIFEST_FILE = '.manifest.json';
|
|
46
|
+
function sha256_hex(buf) {
|
|
47
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
// Enumerate uploads/ as a flat set of bare filenames, mirroring the server's
|
|
50
|
+
// zip-import filter: skip dotfiles (the manifest lives there) and anything
|
|
51
|
+
// nested. Hashes are read from bytes; a manifest with matching hashes could
|
|
52
|
+
// let us skip re-hashing unchanged files, but hashing local disk is cheap
|
|
53
|
+
// next to the network round-trip we're optimizing, so we always hash.
|
|
54
|
+
export async function collect_local_uploads(site_dir) {
|
|
55
|
+
const dir = path.join(site_dir, UPLOADS_DIR);
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = await fs.readdir(dir);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return []; // no uploads/ folder
|
|
62
|
+
}
|
|
63
|
+
const uploads = [];
|
|
64
|
+
for (const name of entries) {
|
|
65
|
+
if (name.startsWith('.') || name.includes('/'))
|
|
66
|
+
continue;
|
|
67
|
+
const abs_path = path.join(dir, name);
|
|
68
|
+
const stat = await fs.stat(abs_path);
|
|
69
|
+
if (!stat.isFile())
|
|
70
|
+
continue;
|
|
71
|
+
const buf = await fs.readFile(abs_path);
|
|
72
|
+
uploads.push({ filename: name, abs_path, hash: sha256_hex(buf) });
|
|
73
|
+
}
|
|
74
|
+
return uploads;
|
|
75
|
+
}
|
|
76
|
+
async function post_json(url, token, body) {
|
|
77
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
78
|
+
if (token)
|
|
79
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
80
|
+
return fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
81
|
+
}
|
|
82
|
+
// Ask the server which of the given hashes it does not already hold.
|
|
83
|
+
// Throws UploadsUnsupportedError on a 404 (old server without the endpoint).
|
|
84
|
+
async function check_missing(server, site_id, token, hashes) {
|
|
85
|
+
const res = await post_json(`${server}/api/primo/uploads/${site_id}/check`, token, { hashes });
|
|
86
|
+
if (res.status === 404) {
|
|
87
|
+
throw (await is_route_missing(res)) ? new UploadsUnsupportedError() : new SiteNotFoundError();
|
|
88
|
+
}
|
|
89
|
+
if (!res.ok)
|
|
90
|
+
throw new Error(await res.text());
|
|
91
|
+
const data = (await res.json());
|
|
92
|
+
return new Set(data.missing || []);
|
|
93
|
+
}
|
|
94
|
+
// PUT a single upload. Returns the server's record id + canonical name so the
|
|
95
|
+
// caller can reconcile local state (rename to the suffixed name, rewrite refs).
|
|
96
|
+
async function put_upload(server, site_id, token, up) {
|
|
97
|
+
const buf = await fs.readFile(up.abs_path);
|
|
98
|
+
const form = new FormData();
|
|
99
|
+
form.append('filename', up.filename);
|
|
100
|
+
form.append('file', new Blob([buf]), up.filename);
|
|
101
|
+
const headers = {};
|
|
102
|
+
if (token)
|
|
103
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
104
|
+
const res = await fetch(`${server}/api/primo/uploads/${site_id}/put`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers,
|
|
107
|
+
body: form
|
|
108
|
+
});
|
|
109
|
+
if (res.status === 404) {
|
|
110
|
+
throw (await is_route_missing(res)) ? new UploadsUnsupportedError() : new SiteNotFoundError();
|
|
111
|
+
}
|
|
112
|
+
if (!res.ok)
|
|
113
|
+
throw new Error(await res.text());
|
|
114
|
+
const data = (await res.json());
|
|
115
|
+
return { filename: up.filename, id: data.id, canonical: data.canonical, hash: data.hash, changed: data.changed };
|
|
116
|
+
}
|
|
117
|
+
// Sync uploads incrementally. Returns counts + per-file results, or throws
|
|
118
|
+
// UploadsUnsupportedError if the server lacks the endpoints (caller falls back
|
|
119
|
+
// to the legacy zip path).
|
|
120
|
+
export async function sync_uploads(opts) {
|
|
121
|
+
const { server, site_id, token, site_dir } = opts;
|
|
122
|
+
const concurrency = Math.max(1, opts.concurrency ?? 4);
|
|
123
|
+
const local = await collect_local_uploads(site_dir);
|
|
124
|
+
if (local.length === 0) {
|
|
125
|
+
return { total: 0, uploaded: 0, skipped: 0, results: [] };
|
|
126
|
+
}
|
|
127
|
+
// One small request tells us the exact set to send. Dedup by hash so
|
|
128
|
+
// identical files (different names) are only weighed once server-side.
|
|
129
|
+
const missing_hashes = await check_missing(server, site_id, token, local.map((u) => u.hash));
|
|
130
|
+
const to_send = local.filter((u) => missing_hashes.has(u.hash));
|
|
131
|
+
const skipped = local.length - to_send.length;
|
|
132
|
+
const results = [];
|
|
133
|
+
let done = 0;
|
|
134
|
+
// Simple bounded worker pool over the send list.
|
|
135
|
+
let cursor = 0;
|
|
136
|
+
async function worker() {
|
|
137
|
+
while (cursor < to_send.length) {
|
|
138
|
+
const idx = cursor++;
|
|
139
|
+
const r = await put_upload(server, site_id, token, to_send[idx]);
|
|
140
|
+
results.push(r);
|
|
141
|
+
done++;
|
|
142
|
+
opts.on_progress?.(done, to_send.length);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const workers = Array.from({ length: Math.min(concurrency, to_send.length) }, () => worker());
|
|
146
|
+
await Promise.all(workers);
|
|
147
|
+
return { total: local.length, uploaded: to_send.length, skipped, results };
|
|
148
|
+
}
|