primo-cli 0.1.17 → 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/deploy.js +7 -5
- package/dist/commands/dev.js +69 -11
- package/dist/commands/pull.js +111 -2
- package/dist/commands/push.js +6 -8
- package/dist/utils/binary.js +87 -8
- package/package.json +1 -1
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/pull.js
CHANGED
|
@@ -8,6 +8,10 @@ import { get_auth_token } from '../utils/auth.js';
|
|
|
8
8
|
import { authenticate_interactively } from './login.js';
|
|
9
9
|
import { write_site_config } from '../utils/site-config.js';
|
|
10
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'];
|
|
11
15
|
async function detect_server() {
|
|
12
16
|
const ports = [3000, 8080, 5173];
|
|
13
17
|
for (const port of ports) {
|
|
@@ -209,8 +213,27 @@ async function pull_one_site(server, headers, site, site_dir, spinner) {
|
|
|
209
213
|
const temp_zip = path.join(site_dir, '.primo-export.zip');
|
|
210
214
|
await fs.writeFile(temp_zip, Buffer.from(zip_data));
|
|
211
215
|
spinner.text = `Extracting ${site.name}...`;
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
}
|
|
214
237
|
await write_site_config(site_dir, {
|
|
215
238
|
name: site.name || 'Imported Site',
|
|
216
239
|
site_id: site.id,
|
|
@@ -220,6 +243,92 @@ async function pull_one_site(server, headers, site, site_dir, spinner) {
|
|
|
220
243
|
await copy_schemas(site_dir);
|
|
221
244
|
await add_schema_references(site_dir);
|
|
222
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
|
+
}
|
|
223
332
|
async function pull_library_into(server, headers, root_dir, spinner) {
|
|
224
333
|
spinner.start('Pulling library...');
|
|
225
334
|
const response = await fetch(`${server}/api/primo/export-library`, { headers });
|
package/dist/commands/push.js
CHANGED
|
@@ -333,14 +333,12 @@ async function try_bootstrap_site(server, token, zip_buffer, config, site_id, gr
|
|
|
333
333
|
form.append('group', config.group);
|
|
334
334
|
if (group_name)
|
|
335
335
|
form.append('group_name', group_name);
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
// Malformed server URL — let the server fall back to its own default.
|
|
343
|
-
}
|
|
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.
|
|
344
342
|
form.append('file', new Blob([zip_buffer]), 'site.zip');
|
|
345
343
|
const headers = {};
|
|
346
344
|
if (token)
|
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
|
}
|