primo-cli 0.1.16 → 0.1.18
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 +7 -5
- package/dist/commands/dev.js +69 -11
- 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 +133 -7
- package/dist/commands/push-library.js +2 -1
- package/dist/commands/push.js +12 -12
- package/dist/utils/binary.js +87 -8
- package/dist/utils/server-config.d.ts +1 -0
- package/dist/utils/server-config.js +11 -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
|
@@ -224,11 +224,13 @@ async function check_provider_auth(provider) {
|
|
|
224
224
|
return false;
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
227
|
+
// Track the latest stable release. primocms's tag.yml workflow publishes a
|
|
228
|
+
// :latest image (alongside semver tags) on every version tag — the comment
|
|
229
|
+
// there calls out that "Railway/self-host deployers follow this tag to track
|
|
230
|
+
// releases". Following :latest means deploys pick up new releases on their next
|
|
231
|
+
// rebuild without a per-release bump here. (main.yml still publishes branch tags
|
|
232
|
+
// like :main / :feature-* for testing; :latest is the released line.)
|
|
233
|
+
const PRIMO_SERVER_IMAGE = 'ghcr.io/primocms/primo:latest';
|
|
232
234
|
async function generate_dockerfile(inventory) {
|
|
233
235
|
// One-line Dockerfile: pull the published primo image and run it
|
|
234
236
|
// unchanged. Workspace data (server.yaml, sites/, library/) is uploaded
|
package/dist/commands/dev.js
CHANGED
|
@@ -29,6 +29,12 @@ let last_import_time = 0; // Timestamp of last import completion
|
|
|
29
29
|
let last_local_change_time = 0; // Timestamp of most recent local watcher event
|
|
30
30
|
const importing_site_keys = new Set();
|
|
31
31
|
const pending_local_site_keys = new Set();
|
|
32
|
+
// Sites whose last import returned null (duplicate _ids across files — nothing
|
|
33
|
+
// was pushed). They stay quarantined until a later import succeeds: no CMS→file
|
|
34
|
+
// polling into them (a quarantined site is out of sync with the CMS, so copying
|
|
35
|
+
// remote-only paths in could clobber the local files the user must edit to fix
|
|
36
|
+
// the conflict). Cleared on the next successful import.
|
|
37
|
+
const blocked_site_keys = new Set();
|
|
32
38
|
let is_importing_library = false;
|
|
33
39
|
let has_pending_library_local_changes = false;
|
|
34
40
|
let site_sync_baselines = new Map();
|
|
@@ -618,6 +624,7 @@ export async function dev_server(options) {
|
|
|
618
624
|
}
|
|
619
625
|
// Normalize and load all sites
|
|
620
626
|
spinner.text = `Loading ${sites.length} site${sites.length > 1 ? 's' : ''}...`;
|
|
627
|
+
const blocked_sites = new Set();
|
|
621
628
|
for (const site of sites) {
|
|
622
629
|
const use_bootstrap = !await site_exists(api_url, site.config.site_id);
|
|
623
630
|
if (sync_policy.mode === 'cms' && !use_bootstrap) {
|
|
@@ -625,7 +632,17 @@ export async function dev_server(options) {
|
|
|
625
632
|
continue;
|
|
626
633
|
}
|
|
627
634
|
await normalize_site(site.dir);
|
|
635
|
+
const site_key = get_site_sync_key(site.dir, site.config);
|
|
628
636
|
const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, use_bootstrap, base_dir));
|
|
637
|
+
if (import_timings === null) {
|
|
638
|
+
// Duplicate _ids — nothing was pushed; the watcher retries
|
|
639
|
+
// once the user removes a conflicting file. Quarantine it so
|
|
640
|
+
// CMS→file polling doesn't sync into an out-of-sync site.
|
|
641
|
+
blocked_sites.add(site.dir);
|
|
642
|
+
blocked_site_keys.add(site_key);
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
blocked_site_keys.delete(site_key);
|
|
629
646
|
if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
|
|
630
647
|
await update_site_sync_baseline(site, api_url, server_config, base_dir);
|
|
631
648
|
}
|
|
@@ -633,6 +650,8 @@ export async function dev_server(options) {
|
|
|
633
650
|
// Verify all sites are accessible before proceeding
|
|
634
651
|
spinner.text = 'Verifying sites...';
|
|
635
652
|
for (const site of sites) {
|
|
653
|
+
if (blocked_sites.has(site.dir))
|
|
654
|
+
continue;
|
|
636
655
|
await verify_site_ready(api_url, site.config.site_id);
|
|
637
656
|
}
|
|
638
657
|
spinner.succeed('Primo running');
|
|
@@ -816,6 +835,15 @@ export async function dev_server(options) {
|
|
|
816
835
|
}
|
|
817
836
|
}
|
|
818
837
|
const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, false, base_dir));
|
|
838
|
+
if (import_timings === null) {
|
|
839
|
+
// Duplicate _ids — import_site_files already printed the
|
|
840
|
+
// error and wrote sync_status; nothing was pushed. Keep
|
|
841
|
+
// the site quarantined from CMS→file polling.
|
|
842
|
+
blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
// Import succeeded — lift any prior quarantine.
|
|
846
|
+
blocked_site_keys.delete(get_site_sync_key(site.dir, site.config));
|
|
819
847
|
let reload_ms = 0;
|
|
820
848
|
if (pending_reload) {
|
|
821
849
|
try {
|
|
@@ -974,7 +1002,12 @@ export async function dev_server(options) {
|
|
|
974
1002
|
else {
|
|
975
1003
|
await normalize_site(site.dir);
|
|
976
1004
|
const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, use_bootstrap, base_dir));
|
|
977
|
-
if (
|
|
1005
|
+
if (import_timings === null) {
|
|
1006
|
+
// Duplicate _ids on a freshly discovered site — quarantine
|
|
1007
|
+
// it from CMS→file polling until a later import succeeds.
|
|
1008
|
+
blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
|
|
1009
|
+
}
|
|
1010
|
+
else if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
|
|
978
1011
|
await update_site_sync_baseline(site, api_url, server_config, base_dir);
|
|
979
1012
|
}
|
|
980
1013
|
}
|
|
@@ -1013,7 +1046,9 @@ export async function dev_server(options) {
|
|
|
1013
1046
|
for (const site of sites) {
|
|
1014
1047
|
try {
|
|
1015
1048
|
const site_key = get_site_sync_key(site.dir, site.config);
|
|
1016
|
-
if (importing_site_keys.has(site_key) ||
|
|
1049
|
+
if (importing_site_keys.has(site_key) ||
|
|
1050
|
+
pending_local_site_keys.has(site_key) ||
|
|
1051
|
+
blocked_site_keys.has(site_key)) {
|
|
1017
1052
|
continue;
|
|
1018
1053
|
}
|
|
1019
1054
|
await sync_from_cms(site.dir, api_url, site.config, server_config, base_dir, sync_policy);
|
|
@@ -1678,19 +1713,19 @@ function describe_duplicate(category, id, occurrences) {
|
|
|
1678
1713
|
const files = [...new Set(occurrences.map((occurrence) => occurrence.file))].sort();
|
|
1679
1714
|
switch (category) {
|
|
1680
1715
|
case 'pages':
|
|
1681
|
-
return `duplicate page _id "${id}" in ${files.join(' and ')}
|
|
1716
|
+
return `duplicate page _id "${id}" in ${files.join(' and ')}`;
|
|
1682
1717
|
case 'page_sections':
|
|
1683
|
-
return `duplicate section _id "${id}" in ${files.join(' and ')}
|
|
1718
|
+
return `duplicate section _id "${id}" in ${files.join(' and ')}`;
|
|
1684
1719
|
case 'blocks':
|
|
1685
|
-
return `duplicate block _id "${id}" in ${files.join(' and ')}
|
|
1720
|
+
return `duplicate block _id "${id}" in ${files.join(' and ')}`;
|
|
1686
1721
|
case 'page_types':
|
|
1687
|
-
return `duplicate page type _id "${id}" in ${files.join(' and ')}
|
|
1722
|
+
return `duplicate page type _id "${id}" in ${files.join(' and ')}`;
|
|
1688
1723
|
case 'site_fields':
|
|
1689
|
-
return `duplicate site field _id "${id}" in ${files.join(' and ')}
|
|
1724
|
+
return `duplicate site field _id "${id}" in ${files.join(' and ')}`;
|
|
1690
1725
|
case 'block_fields':
|
|
1691
|
-
return `duplicate block field _id "${id}" in ${files.join(' and ')}
|
|
1726
|
+
return `duplicate block field _id "${id}" in ${files.join(' and ')}`;
|
|
1692
1727
|
case 'page_type_fields':
|
|
1693
|
-
return `duplicate page type field _id "${id}" in ${files.join(' and ')}
|
|
1728
|
+
return `duplicate page type field _id "${id}" in ${files.join(' and ')}`;
|
|
1694
1729
|
}
|
|
1695
1730
|
}
|
|
1696
1731
|
async function prepare_site_for_local_dev(site_dir) {
|
|
@@ -1854,8 +1889,31 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1854
1889
|
const site_id = config.site_id;
|
|
1855
1890
|
const site_group = resolve_site_group(config, server_config);
|
|
1856
1891
|
const preparation = await prepare_site_for_local_dev(site_dir);
|
|
1857
|
-
|
|
1858
|
-
|
|
1892
|
+
if (preparation.warnings.length > 0) {
|
|
1893
|
+
// Duplicate _ids (the only warning source in prepare_site_for_local_dev)
|
|
1894
|
+
// would leave the server with stale/dangling state if we pushed anyway,
|
|
1895
|
+
// so fail loudly instead of silently excluding the conflicting files.
|
|
1896
|
+
for (const warning of preparation.warnings) {
|
|
1897
|
+
console.log(chalk.red(` ✖ ${config.name}: ${warning}`));
|
|
1898
|
+
}
|
|
1899
|
+
console.log(chalk.dim(' Remove one of the conflicting files and save to retry.'));
|
|
1900
|
+
const message = preparation.warnings[0];
|
|
1901
|
+
await write_sync_status(site_dir, {
|
|
1902
|
+
ok: false,
|
|
1903
|
+
error: message,
|
|
1904
|
+
failed_at: new Date().toISOString()
|
|
1905
|
+
});
|
|
1906
|
+
try {
|
|
1907
|
+
// Best-effort — older servers don't have this endpoint.
|
|
1908
|
+
await fetch(`${api_url}/api/primo/dev/status`, {
|
|
1909
|
+
method: 'POST',
|
|
1910
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1911
|
+
body: JSON.stringify({ status: 'error', message }),
|
|
1912
|
+
signal: AbortSignal.timeout(2000)
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
catch { }
|
|
1916
|
+
return null;
|
|
1859
1917
|
}
|
|
1860
1918
|
// Create ZIP of site files
|
|
1861
1919
|
const zip_started = Date.now();
|
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,13 @@ 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';
|
|
11
|
+
// Directories owned by the server export. Local files under these that no
|
|
12
|
+
// longer exist in the export are stale (e.g. a page that gained children
|
|
13
|
+
// moved from pages/foo.yaml to pages/foo/index.yaml) and get trashed.
|
|
14
|
+
const MANAGED_DIRS = ['pages', 'blocks', 'page-types', 'site'];
|
|
10
15
|
async function detect_server() {
|
|
11
16
|
const ports = [3000, 8080, 5173];
|
|
12
17
|
for (const port of ports) {
|
|
@@ -64,12 +69,15 @@ export async function pull_site(options) {
|
|
|
64
69
|
let server;
|
|
65
70
|
let used_configured = false;
|
|
66
71
|
if (options.server) {
|
|
67
|
-
server = options.server
|
|
72
|
+
server = normalize_server_url(options.server);
|
|
68
73
|
}
|
|
69
74
|
else {
|
|
70
75
|
const configured = await read_configured_server(process.cwd());
|
|
71
76
|
if (configured) {
|
|
72
|
-
server
|
|
77
|
+
// A hand-edited server.yaml may hold a bare host; normalize it so
|
|
78
|
+
// is_remote_server() and the inline-login guard below behave the
|
|
79
|
+
// same as they do for a --server flag.
|
|
80
|
+
server = normalize_server_url(configured);
|
|
73
81
|
used_configured = true;
|
|
74
82
|
spinner.text = `Using ${server}`;
|
|
75
83
|
}
|
|
@@ -80,8 +88,21 @@ export async function pull_site(options) {
|
|
|
80
88
|
spinner.text = `Using ${server}`;
|
|
81
89
|
}
|
|
82
90
|
}
|
|
83
|
-
// Auth (optional for local)
|
|
84
|
-
|
|
91
|
+
// Auth (optional for local). For a remote server with no cached token,
|
|
92
|
+
// prompt for login inline instead of bailing out — the user almost
|
|
93
|
+
// always wants to authenticate and continue rather than re-run.
|
|
94
|
+
let token = options.token || await get_auth_token(server);
|
|
95
|
+
if (!token && is_remote_server(server)) {
|
|
96
|
+
spinner.stop();
|
|
97
|
+
console.log('');
|
|
98
|
+
console.log(chalk.dim(` Not logged in to ${server}. Log in to continue.`));
|
|
99
|
+
console.log('');
|
|
100
|
+
token = await authenticate_interactively(server);
|
|
101
|
+
if (!token) {
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
spinner.start('Fetching sites...');
|
|
105
|
+
}
|
|
85
106
|
const headers = {};
|
|
86
107
|
if (token) {
|
|
87
108
|
headers['Authorization'] = `Bearer ${token}`;
|
|
@@ -192,8 +213,27 @@ async function pull_one_site(server, headers, site, site_dir, spinner) {
|
|
|
192
213
|
const temp_zip = path.join(site_dir, '.primo-export.zip');
|
|
193
214
|
await fs.writeFile(temp_zip, Buffer.from(zip_data));
|
|
194
215
|
spinner.text = `Extracting ${site.name}...`;
|
|
195
|
-
|
|
196
|
-
|
|
216
|
+
const temp_dir = path.join(site_dir, '.primo', `pull-temp-${Date.now()}`);
|
|
217
|
+
let trashed = [];
|
|
218
|
+
try {
|
|
219
|
+
await fs.mkdir(temp_dir, { recursive: true });
|
|
220
|
+
await extract(temp_zip, { dir: temp_dir });
|
|
221
|
+
await fs.unlink(temp_zip);
|
|
222
|
+
trashed = await reconcile_managed_dirs(site_dir, temp_dir);
|
|
223
|
+
await fs.cp(temp_dir, site_dir, { recursive: true });
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
await fs.rm(temp_dir, { recursive: true, force: true });
|
|
227
|
+
// Also drop the archive: on a successful pull it was already unlinked
|
|
228
|
+
// above, but if extract() threw it's still sitting in site_dir.
|
|
229
|
+
await fs.rm(temp_zip, { force: true });
|
|
230
|
+
}
|
|
231
|
+
if (trashed.length > 0) {
|
|
232
|
+
console.log(chalk.dim(' Removed stale files (moved to .primo/trash):'));
|
|
233
|
+
for (const trashed_path of trashed) {
|
|
234
|
+
console.log(chalk.dim(` ${trashed_path}`));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
197
237
|
await write_site_config(site_dir, {
|
|
198
238
|
name: site.name || 'Imported Site',
|
|
199
239
|
site_id: site.id,
|
|
@@ -203,6 +243,92 @@ async function pull_one_site(server, headers, site, site_dir, spinner) {
|
|
|
203
243
|
await copy_schemas(site_dir);
|
|
204
244
|
await add_schema_references(site_dir);
|
|
205
245
|
}
|
|
246
|
+
// Move local files under MANAGED_DIRS that have no counterpart in the fresh
|
|
247
|
+
// export into .primo/trash/pull-<ts>/, then remove newly-empty directories
|
|
248
|
+
// (best-effort). Returns the trashed paths relative to the site dir.
|
|
249
|
+
async function reconcile_managed_dirs(site_dir, temp_dir) {
|
|
250
|
+
const trashed = [];
|
|
251
|
+
const stamp = Date.now();
|
|
252
|
+
for (const dir of MANAGED_DIRS) {
|
|
253
|
+
const local_root = path.join(site_dir, dir);
|
|
254
|
+
const temp_root = path.join(temp_dir, dir);
|
|
255
|
+
for (const relative of await list_files_recursive(local_root)) {
|
|
256
|
+
// Keep the local file only if the export still has a *file* at the
|
|
257
|
+
// same path. If the counterpart is now a directory (a file→dir
|
|
258
|
+
// transition, e.g. pages/foo.yaml became pages/foo/…), the local
|
|
259
|
+
// file is stale and must be trashed — otherwise the later fs.cp
|
|
260
|
+
// can't lay a directory over the surviving file and the pull fails.
|
|
261
|
+
try {
|
|
262
|
+
const counterpart = await fs.stat(path.join(temp_root, relative));
|
|
263
|
+
if (counterpart.isFile())
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// Missing from the export — stale.
|
|
268
|
+
}
|
|
269
|
+
const local_path = path.join(local_root, relative);
|
|
270
|
+
// Scope the trash path by the managed dir. Two managed dirs can hold
|
|
271
|
+
// the same relative name (pages/config.yaml, blocks/config.yaml);
|
|
272
|
+
// without the dir segment they'd collide at trash/pull-<ts>/config.yaml
|
|
273
|
+
// and the second move would clobber the first while both are reported.
|
|
274
|
+
const trash_path = path.join(site_dir, '.primo', 'trash', `pull-${stamp}`, dir, relative);
|
|
275
|
+
await fs.mkdir(path.dirname(trash_path), { recursive: true });
|
|
276
|
+
try {
|
|
277
|
+
await fs.rename(local_path, trash_path);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// Cross-device move — fall back to copy + delete.
|
|
281
|
+
await fs.copyFile(local_path, trash_path);
|
|
282
|
+
await fs.unlink(local_path);
|
|
283
|
+
}
|
|
284
|
+
trashed.push(`${dir}/${relative}`);
|
|
285
|
+
}
|
|
286
|
+
await remove_empty_dirs(local_root);
|
|
287
|
+
}
|
|
288
|
+
return trashed;
|
|
289
|
+
}
|
|
290
|
+
async function list_files_recursive(root, prefix = '') {
|
|
291
|
+
let entries;
|
|
292
|
+
try {
|
|
293
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return [];
|
|
297
|
+
}
|
|
298
|
+
const files = [];
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
if (entry.name.startsWith('.'))
|
|
301
|
+
continue;
|
|
302
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
303
|
+
if (entry.isDirectory()) {
|
|
304
|
+
files.push(...await list_files_recursive(path.join(root, entry.name), relative));
|
|
305
|
+
}
|
|
306
|
+
else if (entry.isFile()) {
|
|
307
|
+
files.push(relative);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return files;
|
|
311
|
+
}
|
|
312
|
+
// Remove empty directories bottom-up. rmdir on a non-empty dir fails, which
|
|
313
|
+
// we ignore — only newly-emptied dirs go away.
|
|
314
|
+
async function remove_empty_dirs(root) {
|
|
315
|
+
let entries;
|
|
316
|
+
try {
|
|
317
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
for (const entry of entries) {
|
|
323
|
+
if (entry.isDirectory()) {
|
|
324
|
+
await remove_empty_dirs(path.join(root, entry.name));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
await fs.rmdir(root);
|
|
329
|
+
}
|
|
330
|
+
catch { }
|
|
331
|
+
}
|
|
206
332
|
async function pull_library_into(server, headers, root_dir, spinner) {
|
|
207
333
|
spinner.start('Pulling library...');
|
|
208
334
|
const response = await fetch(`${server}/api/primo/export-library`, { headers });
|
|
@@ -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}.`);
|
|
@@ -331,14 +333,12 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id, gr
|
|
|
331
333
|
form.append('group', config.group);
|
|
332
334
|
if (group_name)
|
|
333
335
|
form.append('group_name', group_name);
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
// Malformed server URL — let the server fall back to its own default.
|
|
341
|
-
}
|
|
336
|
+
// Host is intentionally not sent. A pushed site is created unassigned —
|
|
337
|
+
// the server seeds `host` with a placeholder (the site's own id) so the
|
|
338
|
+
// site is editable in the dashboard but not publicly served until an
|
|
339
|
+
// operator assigns a real domain. The lone exception is bootstrap of the
|
|
340
|
+
// very first site on a fresh instance, where the server falls back to the
|
|
341
|
+
// deploy URL's host so that instance's front door resolves immediately.
|
|
342
342
|
form.append('file', new Blob([zip_buffer]), 'site.zip');
|
|
343
343
|
const headers = {};
|
|
344
344
|
if (token)
|
|
@@ -362,7 +362,7 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id, gr
|
|
|
362
362
|
}
|
|
363
363
|
async function push_library_dir(root_dir, options, spinner) {
|
|
364
364
|
// Resolve server: --server > any site.yaml's server (they all point at the same server)
|
|
365
|
-
let server = options.server
|
|
365
|
+
let server = options.server ? normalize_server_url(options.server) : undefined;
|
|
366
366
|
if (!server) {
|
|
367
367
|
const sites_root = path.join(root_dir, 'sites');
|
|
368
368
|
if (await path_exists(sites_root)) {
|
package/dist/utils/binary.js
CHANGED
|
@@ -9,7 +9,64 @@ 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
|
|
12
|
+
const REPO = 'primocms/primo';
|
|
13
|
+
// Resolve the latest primo release tag at runtime rather than pinning a version
|
|
14
|
+
// here — a pinned constant silently goes stale (it sat on 3.2.3 through two
|
|
15
|
+
// releases). Cached for the process so repeated calls in one CLI run don't
|
|
16
|
+
// re-hit the API. Bounded by a 10s timeout so an already-installed binary can
|
|
17
|
+
// still be reused promptly when GitHub is slow or unreachable.
|
|
18
|
+
let latest_version_cache;
|
|
19
|
+
async function get_latest_version() {
|
|
20
|
+
if (latest_version_cache !== undefined)
|
|
21
|
+
return latest_version_cache;
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
|
|
24
|
+
headers: { Accept: 'application/vnd.github+json' },
|
|
25
|
+
// Node 18+ ships AbortSignal.timeout; keeps the version check from
|
|
26
|
+
// hanging CLI startup when GitHub stalls.
|
|
27
|
+
signal: AbortSignal.timeout(10_000)
|
|
28
|
+
});
|
|
29
|
+
// Rate limits come back as 403 or 429. Detect them the way GitHub's docs
|
|
30
|
+
// prescribe, since no single signal covers every case:
|
|
31
|
+
// - primary limit: 403 with x-ratelimit-remaining: 0
|
|
32
|
+
// - secondary limit: 403/429 with a Retry-After header, or a body
|
|
33
|
+
// message mentioning a secondary rate limit (remaining may be > 0)
|
|
34
|
+
// - 429 is always a rate limit
|
|
35
|
+
// Treat all of these as "can't tell", never a definitive version —
|
|
36
|
+
// otherwise a throttled run would mask an outdated binary as current. A
|
|
37
|
+
// plain 403 with quota remaining (e.g. a genuine permission error) is a
|
|
38
|
+
// real "unavailable" verdict, not a throttle.
|
|
39
|
+
if (res.status === 403 || res.status === 429) {
|
|
40
|
+
const remaining = res.headers.get('x-ratelimit-remaining');
|
|
41
|
+
const retry_after = res.headers.get('retry-after');
|
|
42
|
+
let rate_limited = res.status === 429 || remaining === '0' || retry_after !== null;
|
|
43
|
+
if (!rate_limited) {
|
|
44
|
+
// Last resort: peek at the body for the secondary-limit message.
|
|
45
|
+
const body = await res.text().catch(() => '');
|
|
46
|
+
rate_limited = /secondary rate limit|rate limit/i.test(body);
|
|
47
|
+
}
|
|
48
|
+
latest_version_cache = rate_limited
|
|
49
|
+
? { status: 'throttled', reason: `GitHub rate limit (${res.status})` }
|
|
50
|
+
: { status: 'unavailable' };
|
|
51
|
+
return latest_version_cache;
|
|
52
|
+
}
|
|
53
|
+
if (!res.ok)
|
|
54
|
+
throw new Error(`GitHub API ${res.status}`);
|
|
55
|
+
const data = (await res.json());
|
|
56
|
+
const version = parse_semver(data.tag_name ?? null);
|
|
57
|
+
latest_version_cache = version ? { status: 'resolved', version } : { status: 'unavailable' };
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
// Timeout / DNS / connection reset — indistinguishable from being
|
|
61
|
+
// offline. Treat as throttled (can't confirm) so we don't force a
|
|
62
|
+
// needless re-download of a working binary.
|
|
63
|
+
latest_version_cache = {
|
|
64
|
+
status: 'throttled',
|
|
65
|
+
reason: err instanceof Error ? err.message : 'network error'
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return latest_version_cache;
|
|
69
|
+
}
|
|
13
70
|
// Path to locally built binary (for development)
|
|
14
71
|
// The binary is at primo/primo (inside the primo repo directory)
|
|
15
72
|
const LOCAL_BINARY = path.resolve(__dirname, '..', '..', '..', 'primo', 'primo');
|
|
@@ -46,9 +103,12 @@ function get_platform() {
|
|
|
46
103
|
return { os: osName, arch: archName, ext };
|
|
47
104
|
}
|
|
48
105
|
function get_download_url(platform) {
|
|
49
|
-
|
|
106
|
+
// /releases/latest/download/<asset> 302-redirects to the newest release's
|
|
107
|
+
// asset, so we never name a version here — the binary always tracks the
|
|
108
|
+
// latest published release. fetch() follows the redirect automatically.
|
|
109
|
+
const base = `https://github.com/${REPO}/releases/latest/download`;
|
|
50
110
|
const filename = `primo_${platform.os}_${platform.arch}${platform.ext}`;
|
|
51
|
-
return `${base}
|
|
111
|
+
return `${base}/${filename}`;
|
|
52
112
|
}
|
|
53
113
|
// Extract a bare semver (e.g. "3.2.1") from a binary's --version output.
|
|
54
114
|
// The server prints a build banner first, then "<name> version vX.Y.Z", so we
|
|
@@ -113,8 +173,8 @@ export async function ensure_binary() {
|
|
|
113
173
|
return LOCAL_BINARY;
|
|
114
174
|
}
|
|
115
175
|
catch { }
|
|
116
|
-
// A managed binary already on disk is reused only when it matches the
|
|
117
|
-
//
|
|
176
|
+
// A managed binary already on disk is reused only when it matches the latest
|
|
177
|
+
// release. A stale binary (older release, or a pre-rename "palacms" build
|
|
118
178
|
// reporting a different version) is re-downloaded so fixes actually reach
|
|
119
179
|
// users who already have a binary installed.
|
|
120
180
|
let updating_from = null;
|
|
@@ -127,8 +187,17 @@ export async function ensure_binary() {
|
|
|
127
187
|
// Need to download - get the target path
|
|
128
188
|
const platform = get_platform();
|
|
129
189
|
const binary_path = path.join(BIN_DIR, `primo${platform.ext}`);
|
|
190
|
+
// Resolve the target version for display only (the download URL follows the
|
|
191
|
+
// /latest redirect regardless). Falls back to "latest" when we couldn't
|
|
192
|
+
// confirm the tag; surface a throttle notice so a rate-limited check isn't
|
|
193
|
+
// silent.
|
|
194
|
+
const lookup = await get_latest_version();
|
|
195
|
+
const target_version = lookup.status === 'resolved' ? lookup.version : 'latest';
|
|
196
|
+
if (lookup.status === 'throttled') {
|
|
197
|
+
console.log(chalk.dim(` (couldn't confirm latest version: ${lookup.reason}; downloading current release)`));
|
|
198
|
+
}
|
|
130
199
|
const spinner = ora(updating_from
|
|
131
|
-
? `Updating primo ${updating_from} → ${
|
|
200
|
+
? `Updating primo ${updating_from} → ${target_version}...`
|
|
132
201
|
: 'Setting up Primo...').start();
|
|
133
202
|
try {
|
|
134
203
|
// Create directories
|
|
@@ -148,7 +217,7 @@ export async function ensure_binary() {
|
|
|
148
217
|
// Make executable, then atomically replace any existing binary.
|
|
149
218
|
await fs.chmod(tmp_path, 0o755);
|
|
150
219
|
await fs.rename(tmp_path, binary_path);
|
|
151
|
-
spinner.succeed(updating_from ? `Primo updated to ${
|
|
220
|
+
spinner.succeed(updating_from ? `Primo updated to ${target_version}` : 'Primo setup complete');
|
|
152
221
|
return binary_path;
|
|
153
222
|
}
|
|
154
223
|
catch (error) {
|
|
@@ -184,5 +253,15 @@ export async function get_binary_version() {
|
|
|
184
253
|
// developer-chosen and must not be clobbered by a download.
|
|
185
254
|
async function is_binary_current() {
|
|
186
255
|
const installed = await get_binary_version();
|
|
187
|
-
|
|
256
|
+
if (!installed)
|
|
257
|
+
return false;
|
|
258
|
+
const latest = await get_latest_version();
|
|
259
|
+
// resolved → compare versions.
|
|
260
|
+
// throttled → can't confirm (rate-limited / offline); keep the installed
|
|
261
|
+
// binary rather than thrashing a re-download, and it'll refresh
|
|
262
|
+
// on the next unthrottled run.
|
|
263
|
+
// unavailable → no usable release to compare against; keep what's on disk.
|
|
264
|
+
if (latest.status !== 'resolved')
|
|
265
|
+
return true;
|
|
266
|
+
return installed === latest.version;
|
|
188
267
|
}
|
|
@@ -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';
|