primo-cli 0.1.17 → 0.1.19
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 +259 -36
- package/dist/commands/new.js +84 -13
- 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();
|
|
@@ -125,21 +131,73 @@ function compute_shrink_delta(prior, next) {
|
|
|
125
131
|
const delta = next_lines - prior_lines;
|
|
126
132
|
return delta < 0 ? delta : null;
|
|
127
133
|
}
|
|
128
|
-
// Write the most recent push outcome to a file the MCP build_preview
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
+
// Write the most recent push outcome to a file the MCP build_preview and
|
|
135
|
+
// get_dev_status tools read, so the agent learns when its file changes failed
|
|
136
|
+
// to land in the CMS and exactly which fields were dropped. Without this,
|
|
137
|
+
// build_preview compiles whatever stale DB state existed before the failed
|
|
138
|
+
// push and reports ok:true, leaving the agent to chase phantom rendering bugs
|
|
139
|
+
// instead of fixing the source error.
|
|
140
|
+
async function write_sync_status(site_dir, status, location = {}) {
|
|
134
141
|
const status_dir = path.join(site_dir, '.primo');
|
|
135
142
|
try {
|
|
136
143
|
await fs.mkdir(status_dir, { recursive: true });
|
|
137
|
-
|
|
144
|
+
// last_import_at stamps every write so a reader can tell a fresh status
|
|
145
|
+
// from a stale one; port/url tell the agent where the server is.
|
|
146
|
+
const payload = {
|
|
147
|
+
...status,
|
|
148
|
+
...(location.port !== undefined ? { port: location.port } : {}),
|
|
149
|
+
...(location.url !== undefined ? { url: location.url } : {}),
|
|
150
|
+
last_import_at: new Date().toISOString()
|
|
151
|
+
};
|
|
152
|
+
// Write atomically: a plain fs.writeFile truncates-then-writes, so the
|
|
153
|
+
// MCP get_dev_status reader can catch a half-written file and briefly
|
|
154
|
+
// report "not running" mid-write. Write to a temp file in the same dir
|
|
155
|
+
// (same filesystem → rename is atomic on POSIX) and rename over the
|
|
156
|
+
// target, so readers always see a complete old or new file.
|
|
157
|
+
const target = path.join(status_dir, 'sync_status.json');
|
|
158
|
+
const tmp = path.join(status_dir, `sync_status.${process.pid}.${randomInt(1e9)}.tmp`);
|
|
159
|
+
try {
|
|
160
|
+
await fs.writeFile(tmp, JSON.stringify(payload, null, 2));
|
|
161
|
+
await fs.rename(tmp, target);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
await fs.unlink(tmp).catch(() => { });
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
138
167
|
}
|
|
139
168
|
catch {
|
|
140
169
|
// Status reporting must not break the push.
|
|
141
170
|
}
|
|
142
171
|
}
|
|
172
|
+
// Persist a successful import's outcome to sync_status.json — with the full
|
|
173
|
+
// per-field warning details when content was dropped, so an agent can read
|
|
174
|
+
// exactly what was lost without re-running dev. Shared by the boot loop and
|
|
175
|
+
// the file watcher so both readers of the file see the same shape.
|
|
176
|
+
async function write_import_sync_status(site_dir, import_timings, location) {
|
|
177
|
+
// The bootstrap+import fallback can return timings even when nothing
|
|
178
|
+
// imported (both attempts failed and were only logged). Never record a
|
|
179
|
+
// phantom success for that — write ok:false so the agent doesn't build a
|
|
180
|
+
// preview from unchanged CMS state.
|
|
181
|
+
if (!import_timings.ok) {
|
|
182
|
+
await write_sync_status(site_dir, {
|
|
183
|
+
ok: false,
|
|
184
|
+
error: 'Import failed — see the primo dev logs for details.',
|
|
185
|
+
failed_at: new Date().toISOString()
|
|
186
|
+
}, location);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (import_timings.warning_count > 0) {
|
|
190
|
+
await write_sync_status(site_dir, {
|
|
191
|
+
ok: true,
|
|
192
|
+
warnings: import_timings.warning_count,
|
|
193
|
+
warned_at: new Date().toISOString(),
|
|
194
|
+
warning_details: import_timings.warning_details
|
|
195
|
+
}, location);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
await write_sync_status(site_dir, { ok: true }, location);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
143
201
|
async function prune_old_trash(workspace_dir) {
|
|
144
202
|
const trash_dir = path.join(workspace_dir, '.primo', 'trash');
|
|
145
203
|
try {
|
|
@@ -601,6 +659,9 @@ export async function dev_server(options) {
|
|
|
601
659
|
process.exit(1);
|
|
602
660
|
}
|
|
603
661
|
const api_url = `http://127.0.0.1:${port}`;
|
|
662
|
+
// Stamped into every sync_status.json write so an agent reading the
|
|
663
|
+
// file also learns where the running server is.
|
|
664
|
+
const dev_location = { port, url: api_url };
|
|
604
665
|
if (is_server_mode) {
|
|
605
666
|
spinner.text = sync_policy.mode === 'cms' ? 'Pulling shared library...' : 'Loading shared library...';
|
|
606
667
|
is_importing_library = true;
|
|
@@ -618,6 +679,8 @@ export async function dev_server(options) {
|
|
|
618
679
|
}
|
|
619
680
|
// Normalize and load all sites
|
|
620
681
|
spinner.text = `Loading ${sites.length} site${sites.length > 1 ? 's' : ''}...`;
|
|
682
|
+
const blocked_sites = new Set();
|
|
683
|
+
let dropped_field_count = 0;
|
|
621
684
|
for (const site of sites) {
|
|
622
685
|
const use_bootstrap = !await site_exists(api_url, site.config.site_id);
|
|
623
686
|
if (sync_policy.mode === 'cms' && !use_bootstrap) {
|
|
@@ -625,7 +688,31 @@ export async function dev_server(options) {
|
|
|
625
688
|
continue;
|
|
626
689
|
}
|
|
627
690
|
await normalize_site(site.dir);
|
|
691
|
+
const site_key = get_site_sync_key(site.dir, site.config);
|
|
628
692
|
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));
|
|
693
|
+
if (import_timings === null) {
|
|
694
|
+
// Duplicate _ids — nothing was pushed; the watcher retries
|
|
695
|
+
// once the user removes a conflicting file. Quarantine it so
|
|
696
|
+
// CMS→file polling doesn't sync into an out-of-sync site.
|
|
697
|
+
blocked_sites.add(site.dir);
|
|
698
|
+
blocked_site_keys.add(site_key);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
// Write sync_status.json on boot too — otherwise an agent reading
|
|
702
|
+
// right after `primo dev` starts sees stale/absent state, and any
|
|
703
|
+
// dropped fields from the initial import stay invisible until the
|
|
704
|
+
// next file save. Writes ok:false when the import didn't land.
|
|
705
|
+
await write_import_sync_status(site.dir, import_timings, dev_location);
|
|
706
|
+
if (!import_timings.ok) {
|
|
707
|
+
// Bootstrap and its regular-import fallback both failed (only
|
|
708
|
+
// logged, not thrown). Keep the site quarantined and skip the
|
|
709
|
+
// baseline/success path so we don't record a phantom import.
|
|
710
|
+
blocked_sites.add(site.dir);
|
|
711
|
+
blocked_site_keys.add(site_key);
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
blocked_site_keys.delete(site_key);
|
|
715
|
+
dropped_field_count += import_timings.dropped_field_count;
|
|
629
716
|
if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
|
|
630
717
|
await update_site_sync_baseline(site, api_url, server_config, base_dir);
|
|
631
718
|
}
|
|
@@ -633,9 +720,20 @@ export async function dev_server(options) {
|
|
|
633
720
|
// Verify all sites are accessible before proceeding
|
|
634
721
|
spinner.text = 'Verifying sites...';
|
|
635
722
|
for (const site of sites) {
|
|
723
|
+
if (blocked_sites.has(site.dir))
|
|
724
|
+
continue;
|
|
636
725
|
await verify_site_ready(api_url, site.config.site_id);
|
|
637
726
|
}
|
|
638
727
|
spinner.succeed('Primo running');
|
|
728
|
+
// Restate any dropped-field warnings next to the banner. The per-field
|
|
729
|
+
// detail already printed above during import, but on a multi-site or
|
|
730
|
+
// long boot it scrolls out of view and the green banner reads as
|
|
731
|
+
// "all good" — so surface the aggregate here as the last thing on
|
|
732
|
+
// screen. See print_import_warnings for the full per-field output.
|
|
733
|
+
if (dropped_field_count > 0) {
|
|
734
|
+
console.log('');
|
|
735
|
+
console.log(chalk.yellow(` ⚠ ${dropped_field_count} field${dropped_field_count === 1 ? '' : 's'} dropped — content not imported (see warnings above)`));
|
|
736
|
+
}
|
|
639
737
|
console.log('');
|
|
640
738
|
if (mcp_registration_path) {
|
|
641
739
|
console.log(` ${chalk.dim(`MCP server registered at ${mcp_registration_path} - agents in this directory can now use the Primo MCP server.`)}`);
|
|
@@ -815,7 +913,27 @@ export async function dev_server(options) {
|
|
|
815
913
|
// Conflict detection must not block the local push.
|
|
816
914
|
}
|
|
817
915
|
}
|
|
818
|
-
|
|
916
|
+
// Steady-state pushes go through the additive import
|
|
917
|
+
// endpoint (use_bootstrap=false). But a site whose initial
|
|
918
|
+
// bootstrap+fallback both failed is quarantined and was
|
|
919
|
+
// never created on the server — retrying import against a
|
|
920
|
+
// nonexistent site would fail forever. For those, re-check
|
|
921
|
+
// existence so the retry can bootstrap out of quarantine.
|
|
922
|
+
// Healthy sites are never quarantined, so they skip the
|
|
923
|
+
// extra request and keep the fast path.
|
|
924
|
+
const retry_needs_bootstrap = blocked_site_keys.has(get_site_sync_key(site.dir, site.config))
|
|
925
|
+
? !await site_exists(api_url, site.config.site_id)
|
|
926
|
+
: false;
|
|
927
|
+
const import_timings = await with_site_import_lock(site.dir, site.config, () => import_site_files(site.dir, api_url, site.config, port, server_config, retry_needs_bootstrap, base_dir));
|
|
928
|
+
if (import_timings === null) {
|
|
929
|
+
// Duplicate _ids — import_site_files already printed the
|
|
930
|
+
// error and wrote sync_status; nothing was pushed. Keep
|
|
931
|
+
// the site quarantined from CMS→file polling.
|
|
932
|
+
blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
// Import succeeded — lift any prior quarantine.
|
|
936
|
+
blocked_site_keys.delete(get_site_sync_key(site.dir, site.config));
|
|
819
937
|
let reload_ms = 0;
|
|
820
938
|
if (pending_reload) {
|
|
821
939
|
try {
|
|
@@ -841,16 +959,7 @@ export async function dev_server(options) {
|
|
|
841
959
|
}
|
|
842
960
|
console.log(chalk.dim(` ${site.config.name}: normalize ${normalize_ms}ms, zip ${import_timings.zip_ms}ms, ${import_timings.mode} ${import_timings.request_ms}ms${reload_ms ? `, reload ${reload_ms}ms` : ''}`));
|
|
843
961
|
console.log(chalk.green(` ✓ ${site.config.name} pushed`));
|
|
844
|
-
|
|
845
|
-
await write_sync_status(site.dir, {
|
|
846
|
-
ok: true,
|
|
847
|
-
warnings: import_timings.warning_count,
|
|
848
|
-
warned_at: new Date().toISOString()
|
|
849
|
-
});
|
|
850
|
-
}
|
|
851
|
-
else {
|
|
852
|
-
await write_sync_status(site.dir, { ok: true });
|
|
853
|
-
}
|
|
962
|
+
await write_import_sync_status(site.dir, import_timings, dev_location);
|
|
854
963
|
}
|
|
855
964
|
catch (err) {
|
|
856
965
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -859,7 +968,7 @@ export async function dev_server(options) {
|
|
|
859
968
|
ok: false,
|
|
860
969
|
error: message,
|
|
861
970
|
failed_at: new Date().toISOString()
|
|
862
|
-
});
|
|
971
|
+
}, dev_location);
|
|
863
972
|
}
|
|
864
973
|
finally {
|
|
865
974
|
is_importing = false;
|
|
@@ -962,30 +1071,65 @@ export async function dev_server(options) {
|
|
|
962
1071
|
return;
|
|
963
1072
|
}
|
|
964
1073
|
const new_sites = await discover_sites(base_dir);
|
|
1074
|
+
let loaded_count = 0;
|
|
1075
|
+
const quarantined = [];
|
|
965
1076
|
for (const site of new_sites) {
|
|
966
1077
|
if (known_sites.has(site.dir))
|
|
967
1078
|
continue;
|
|
968
1079
|
known_sites.add(site.dir);
|
|
969
1080
|
sites.push(site);
|
|
970
1081
|
const use_bootstrap = !await site_exists(api_url, site.config.site_id);
|
|
1082
|
+
let import_ok = true;
|
|
971
1083
|
if (sync_policy.mode === 'cms' && !use_bootstrap) {
|
|
972
1084
|
await sync_from_cms(site.dir, api_url, site.config, server_config, base_dir, sync_policy);
|
|
973
1085
|
}
|
|
974
1086
|
else {
|
|
975
1087
|
await normalize_site(site.dir);
|
|
976
1088
|
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 (
|
|
978
|
-
|
|
1089
|
+
if (import_timings === null) {
|
|
1090
|
+
// Duplicate _ids on a freshly discovered site — quarantine
|
|
1091
|
+
// it from CMS→file polling until a later import succeeds.
|
|
1092
|
+
blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
|
|
1093
|
+
quarantined.push(site.config.name);
|
|
1094
|
+
import_ok = false;
|
|
1095
|
+
}
|
|
1096
|
+
else {
|
|
1097
|
+
await write_import_sync_status(site.dir, import_timings, dev_location);
|
|
1098
|
+
if (!import_timings.ok) {
|
|
1099
|
+
// Bootstrap + fallback both failed (only logged). Quarantine
|
|
1100
|
+
// and skip the success path so we don't announce a site that
|
|
1101
|
+
// didn't actually load — and so `primo new` sees it in the
|
|
1102
|
+
// /reload body's `quarantined` list.
|
|
1103
|
+
blocked_site_keys.add(get_site_sync_key(site.dir, site.config));
|
|
1104
|
+
quarantined.push(site.config.name);
|
|
1105
|
+
import_ok = false;
|
|
1106
|
+
}
|
|
1107
|
+
else if (update_site_sync_state_after_import(site, import_timings, sync_policy)) {
|
|
1108
|
+
await update_site_sync_baseline(site, api_url, server_config, base_dir);
|
|
1109
|
+
}
|
|
979
1110
|
}
|
|
980
1111
|
}
|
|
981
1112
|
setup_site_watchers(site);
|
|
1113
|
+
loaded_count++;
|
|
1114
|
+
if (!import_ok) {
|
|
1115
|
+
console.log(chalk.yellow(` ⚠ ${site.config.name}: import failed — see logs; will retry on the next file change.`));
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
982
1118
|
const host = local_dev_host(site.config.name || path.basename(site.dir), port);
|
|
983
1119
|
console.log(chalk.green(` ✓ New site loaded: ${site.config.name}`));
|
|
984
1120
|
console.log(` ${chalk.dim('Edit:')} http://${host}/admin/site`);
|
|
985
1121
|
console.log(` ${chalk.dim('Preview:')} http://${host}/`);
|
|
986
1122
|
}
|
|
987
|
-
|
|
988
|
-
|
|
1123
|
+
// Report the outcome so `primo new` can tell whether the site it
|
|
1124
|
+
// just scaffolded actually imported. A quarantined site (duplicate
|
|
1125
|
+
// _ids) returns 200 with loaded:false so a 2xx no longer implies
|
|
1126
|
+
// success — the caller checks the body, not just the status.
|
|
1127
|
+
const body = JSON.stringify({
|
|
1128
|
+
loaded: loaded_count,
|
|
1129
|
+
quarantined
|
|
1130
|
+
});
|
|
1131
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1132
|
+
res.end(body);
|
|
989
1133
|
});
|
|
990
1134
|
reload_server.on('error', (err) => {
|
|
991
1135
|
if (err.code === 'EADDRINUSE') {
|
|
@@ -1013,7 +1157,9 @@ export async function dev_server(options) {
|
|
|
1013
1157
|
for (const site of sites) {
|
|
1014
1158
|
try {
|
|
1015
1159
|
const site_key = get_site_sync_key(site.dir, site.config);
|
|
1016
|
-
if (importing_site_keys.has(site_key) ||
|
|
1160
|
+
if (importing_site_keys.has(site_key) ||
|
|
1161
|
+
pending_local_site_keys.has(site_key) ||
|
|
1162
|
+
blocked_site_keys.has(site_key)) {
|
|
1017
1163
|
continue;
|
|
1018
1164
|
}
|
|
1019
1165
|
await sync_from_cms(site.dir, api_url, site.config, server_config, base_dir, sync_policy);
|
|
@@ -1678,19 +1824,19 @@ function describe_duplicate(category, id, occurrences) {
|
|
|
1678
1824
|
const files = [...new Set(occurrences.map((occurrence) => occurrence.file))].sort();
|
|
1679
1825
|
switch (category) {
|
|
1680
1826
|
case 'pages':
|
|
1681
|
-
return `duplicate page _id "${id}" in ${files.join(' and ')}
|
|
1827
|
+
return `duplicate page _id "${id}" in ${files.join(' and ')}`;
|
|
1682
1828
|
case 'page_sections':
|
|
1683
|
-
return `duplicate section _id "${id}" in ${files.join(' and ')}
|
|
1829
|
+
return `duplicate section _id "${id}" in ${files.join(' and ')}`;
|
|
1684
1830
|
case 'blocks':
|
|
1685
|
-
return `duplicate block _id "${id}" in ${files.join(' and ')}
|
|
1831
|
+
return `duplicate block _id "${id}" in ${files.join(' and ')}`;
|
|
1686
1832
|
case 'page_types':
|
|
1687
|
-
return `duplicate page type _id "${id}" in ${files.join(' and ')}
|
|
1833
|
+
return `duplicate page type _id "${id}" in ${files.join(' and ')}`;
|
|
1688
1834
|
case 'site_fields':
|
|
1689
|
-
return `duplicate site field _id "${id}" in ${files.join(' and ')}
|
|
1835
|
+
return `duplicate site field _id "${id}" in ${files.join(' and ')}`;
|
|
1690
1836
|
case 'block_fields':
|
|
1691
|
-
return `duplicate block field _id "${id}" in ${files.join(' and ')}
|
|
1837
|
+
return `duplicate block field _id "${id}" in ${files.join(' and ')}`;
|
|
1692
1838
|
case 'page_type_fields':
|
|
1693
|
-
return `duplicate page type field _id "${id}" in ${files.join(' and ')}
|
|
1839
|
+
return `duplicate page type field _id "${id}" in ${files.join(' and ')}`;
|
|
1694
1840
|
}
|
|
1695
1841
|
}
|
|
1696
1842
|
async function prepare_site_for_local_dev(site_dir) {
|
|
@@ -1833,6 +1979,37 @@ async function prepare_site_for_local_dev(site_dir) {
|
|
|
1833
1979
|
warnings
|
|
1834
1980
|
};
|
|
1835
1981
|
}
|
|
1982
|
+
// Warning kinds where page content was matched to no field and therefore
|
|
1983
|
+
// dropped from the render model. Counted separately from other import
|
|
1984
|
+
// warnings (unknown blocks, orphan uploads, duplicate ids) so the
|
|
1985
|
+
// end-of-boot summary can report "N fields dropped" accurately.
|
|
1986
|
+
const DROPPED_FIELD_KINDS = new Set(['orphaned_field', 'orphaned_page_field']);
|
|
1987
|
+
// Number of dropped-field warnings in an import response. Safe on any input
|
|
1988
|
+
// shape (returns 0 for non-arrays / missing warnings).
|
|
1989
|
+
function count_dropped_fields(warnings) {
|
|
1990
|
+
if (!Array.isArray(warnings))
|
|
1991
|
+
return 0;
|
|
1992
|
+
return warnings.filter(w => DROPPED_FIELD_KINDS.has(w.kind)).length;
|
|
1993
|
+
}
|
|
1994
|
+
// Coerce a raw import-response `warnings` value into a clean ImportWarning[]
|
|
1995
|
+
// for persistence in sync_status.json. Defensive against partial/unknown
|
|
1996
|
+
// server shapes: drops non-object entries and fills missing string fields so
|
|
1997
|
+
// the agent-facing file always has a predictable schema. Returns [] for
|
|
1998
|
+
// non-arrays / missing warnings.
|
|
1999
|
+
function normalize_warning_details(warnings) {
|
|
2000
|
+
if (!Array.isArray(warnings))
|
|
2001
|
+
return [];
|
|
2002
|
+
return warnings
|
|
2003
|
+
.filter((w) => typeof w === 'object' && w !== null)
|
|
2004
|
+
.map(w => ({
|
|
2005
|
+
kind: typeof w.kind === 'string' ? w.kind : '',
|
|
2006
|
+
file: typeof w.file === 'string' ? w.file : '',
|
|
2007
|
+
path: typeof w.path === 'string' ? w.path : '',
|
|
2008
|
+
field: typeof w.field === 'string' ? w.field : '',
|
|
2009
|
+
block: typeof w.block === 'string' ? w.block : '',
|
|
2010
|
+
message: typeof w.message === 'string' ? w.message : ''
|
|
2011
|
+
}));
|
|
2012
|
+
}
|
|
1836
2013
|
// Loudly surface non-fatal import problems (e.g. orphaned fields whose
|
|
1837
2014
|
// content would otherwise be silently dropped). Printed in yellow with the
|
|
1838
2015
|
// full details so agents and humans both see exactly what was lost and where.
|
|
@@ -1854,8 +2031,31 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1854
2031
|
const site_id = config.site_id;
|
|
1855
2032
|
const site_group = resolve_site_group(config, server_config);
|
|
1856
2033
|
const preparation = await prepare_site_for_local_dev(site_dir);
|
|
1857
|
-
|
|
1858
|
-
|
|
2034
|
+
if (preparation.warnings.length > 0) {
|
|
2035
|
+
// Duplicate _ids (the only warning source in prepare_site_for_local_dev)
|
|
2036
|
+
// would leave the server with stale/dangling state if we pushed anyway,
|
|
2037
|
+
// so fail loudly instead of silently excluding the conflicting files.
|
|
2038
|
+
for (const warning of preparation.warnings) {
|
|
2039
|
+
console.log(chalk.red(` ✖ ${config.name}: ${warning}`));
|
|
2040
|
+
}
|
|
2041
|
+
console.log(chalk.dim(' Remove one of the conflicting files and save to retry.'));
|
|
2042
|
+
const message = preparation.warnings[0];
|
|
2043
|
+
await write_sync_status(site_dir, {
|
|
2044
|
+
ok: false,
|
|
2045
|
+
error: message,
|
|
2046
|
+
failed_at: new Date().toISOString()
|
|
2047
|
+
}, { port, url: api_url });
|
|
2048
|
+
try {
|
|
2049
|
+
// Best-effort — older servers don't have this endpoint.
|
|
2050
|
+
await fetch(`${api_url}/api/primo/dev/status`, {
|
|
2051
|
+
method: 'POST',
|
|
2052
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2053
|
+
body: JSON.stringify({ status: 'error', message }),
|
|
2054
|
+
signal: AbortSignal.timeout(2000)
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
catch { }
|
|
2058
|
+
return null;
|
|
1859
2059
|
}
|
|
1860
2060
|
// Create ZIP of site files
|
|
1861
2061
|
const zip_started = Date.now();
|
|
@@ -1881,12 +2081,16 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1881
2081
|
}
|
|
1882
2082
|
// Write created IDs back to files
|
|
1883
2083
|
let warning_count = 0;
|
|
2084
|
+
let dropped_field_count = 0;
|
|
2085
|
+
let warning_details = [];
|
|
1884
2086
|
try {
|
|
1885
2087
|
const result = await import_response.json();
|
|
1886
2088
|
if (result.created_ids) {
|
|
1887
2089
|
await write_created_ids(site_dir, result.created_ids, server_config, workspace_dir);
|
|
1888
2090
|
}
|
|
1889
2091
|
warning_count = print_import_warnings(config.name, result.warnings);
|
|
2092
|
+
dropped_field_count = count_dropped_fields(result.warnings);
|
|
2093
|
+
warning_details = normalize_warning_details(result.warnings);
|
|
1890
2094
|
}
|
|
1891
2095
|
catch {
|
|
1892
2096
|
// ignore JSON parse errors
|
|
@@ -1895,7 +2099,10 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1895
2099
|
zip_ms,
|
|
1896
2100
|
request_ms,
|
|
1897
2101
|
mode: 'import',
|
|
1898
|
-
|
|
2102
|
+
ok: true,
|
|
2103
|
+
warning_count,
|
|
2104
|
+
dropped_field_count,
|
|
2105
|
+
warning_details
|
|
1899
2106
|
};
|
|
1900
2107
|
}
|
|
1901
2108
|
// Retry bootstrap up to 3 times (collections may not be ready immediately)
|
|
@@ -1918,6 +2125,8 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1918
2125
|
}, 300000); // 300s timeout for imports
|
|
1919
2126
|
const bootstrap_ms = Date.now() - bootstrap_started;
|
|
1920
2127
|
let warning_count = 0;
|
|
2128
|
+
let dropped_field_count = 0;
|
|
2129
|
+
let warning_details = [];
|
|
1921
2130
|
if (bootstrap_response.ok) {
|
|
1922
2131
|
try {
|
|
1923
2132
|
const result = await bootstrap_response.json();
|
|
@@ -1930,6 +2139,8 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1930
2139
|
await write_created_ids(site_dir, result.created_ids, server_config, workspace_dir);
|
|
1931
2140
|
}
|
|
1932
2141
|
warning_count = print_import_warnings(config.name, result.warnings);
|
|
2142
|
+
dropped_field_count = count_dropped_fields(result.warnings);
|
|
2143
|
+
warning_details = normalize_warning_details(result.warnings);
|
|
1933
2144
|
}
|
|
1934
2145
|
catch {
|
|
1935
2146
|
// ignore JSON parse errors
|
|
@@ -1938,7 +2149,10 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1938
2149
|
zip_ms,
|
|
1939
2150
|
request_ms: bootstrap_ms,
|
|
1940
2151
|
mode: 'bootstrap',
|
|
1941
|
-
|
|
2152
|
+
ok: true,
|
|
2153
|
+
warning_count,
|
|
2154
|
+
dropped_field_count,
|
|
2155
|
+
warning_details
|
|
1942
2156
|
};
|
|
1943
2157
|
}
|
|
1944
2158
|
const error_text = await bootstrap_response.text();
|
|
@@ -1969,6 +2183,8 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1969
2183
|
await write_created_ids(site_dir, result.created_ids, server_config, workspace_dir);
|
|
1970
2184
|
}
|
|
1971
2185
|
warning_count = print_import_warnings(config.name, result.warnings);
|
|
2186
|
+
dropped_field_count = count_dropped_fields(result.warnings);
|
|
2187
|
+
warning_details = normalize_warning_details(result.warnings);
|
|
1972
2188
|
}
|
|
1973
2189
|
catch {
|
|
1974
2190
|
// ignore JSON parse errors
|
|
@@ -1978,7 +2194,14 @@ async function import_site_files(site_dir, api_url, config, port, server_config,
|
|
|
1978
2194
|
zip_ms,
|
|
1979
2195
|
request_ms: bootstrap_ms + import_ms,
|
|
1980
2196
|
mode: 'bootstrap+import',
|
|
1981
|
-
|
|
2197
|
+
// Bootstrap failed and we fell back to a regular import; ok
|
|
2198
|
+
// reflects whether that fallback actually landed. When both
|
|
2199
|
+
// failed we still return timings (only logged above), so this
|
|
2200
|
+
// guards against recording a phantom success.
|
|
2201
|
+
ok: import_response.ok,
|
|
2202
|
+
warning_count,
|
|
2203
|
+
dropped_field_count,
|
|
2204
|
+
warning_details
|
|
1982
2205
|
};
|
|
1983
2206
|
}
|
|
1984
2207
|
catch (err) {
|
package/dist/commands/new.js
CHANGED
|
@@ -50,16 +50,20 @@ export async function new_site(options) {
|
|
|
50
50
|
name: 'name',
|
|
51
51
|
message: 'Site name:',
|
|
52
52
|
default: 'my-site',
|
|
53
|
-
validate:
|
|
54
|
-
if (!input.trim())
|
|
55
|
-
return 'Name is required';
|
|
56
|
-
if (!/^[a-z0-9.-]+$/i.test(input))
|
|
57
|
-
return 'Use only letters, numbers, dots, and hyphens';
|
|
58
|
-
return true;
|
|
59
|
-
}
|
|
53
|
+
validate: validate_site_name
|
|
60
54
|
}]);
|
|
61
55
|
site_name = name;
|
|
62
56
|
}
|
|
57
|
+
else {
|
|
58
|
+
// A name passed as a CLI arg skips the prompt — validate it too, or a
|
|
59
|
+
// name like `.foo` slips through: it makes a hidden sites/.foo dir that
|
|
60
|
+
// discover_sites ignores, and an empty display_name downstream.
|
|
61
|
+
const error = validate_site_name(site_name);
|
|
62
|
+
if (error !== true) {
|
|
63
|
+
console.log(chalk.red(error));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
63
67
|
// Always create site in sites/<name>
|
|
64
68
|
const site_dir = path.join(sites_dir, site_name);
|
|
65
69
|
// Check if directory exists
|
|
@@ -250,20 +254,67 @@ sections:
|
|
|
250
254
|
await fs.writeFile(workspace_agents_path, generate_agent_md());
|
|
251
255
|
}
|
|
252
256
|
spinner.succeed(`Site created: ${chalk.cyan(site_dir)}`);
|
|
253
|
-
// Check if server is already running
|
|
254
|
-
|
|
257
|
+
// Check if server is already running. Read the workspace's configured
|
|
258
|
+
// port (mirroring `primo dev`, which uses server_config.port) rather than
|
|
259
|
+
// assuming 3000 — otherwise on a custom-port workspace we'd probe the
|
|
260
|
+
// wrong port, miss the running server, and print links to a dead port.
|
|
261
|
+
const port = server_config.port ?? 3000;
|
|
255
262
|
const server_running = await is_server_running(port);
|
|
256
263
|
if (server_running) {
|
|
257
|
-
//
|
|
264
|
+
// A `primo dev` is already running. Ask it to reload and pick up the
|
|
265
|
+
// new site. The dev server prints its own "New site loaded" + links,
|
|
266
|
+
// but in *its* terminal — so print the same links here too, otherwise
|
|
267
|
+
// this terminal looks like nothing happened.
|
|
268
|
+
// Host is derived from the display name (what becomes config.name),
|
|
269
|
+
// matching how dev_server builds the host — not the raw folder name.
|
|
270
|
+
const host = local_dev_host(display_name, port);
|
|
271
|
+
let outcome = 'unreachable';
|
|
258
272
|
try {
|
|
259
|
-
|
|
273
|
+
// Bound the request: the reload handler runs discovery + import
|
|
274
|
+
// synchronously before responding, so an unbounded fetch could
|
|
275
|
+
// hang here forever and never reach the warning below.
|
|
276
|
+
const controller = new AbortController();
|
|
277
|
+
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
278
|
+
let res;
|
|
279
|
+
try {
|
|
280
|
+
res = await fetch(`http://127.0.0.1:${port + 1}/reload`, {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
signal: controller.signal
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
clearTimeout(timeout);
|
|
287
|
+
}
|
|
288
|
+
if (res.ok) {
|
|
289
|
+
// A 2xx no longer implies the site imported — the handler
|
|
290
|
+
// returns { quarantined: [...] } for sites it couldn't load
|
|
291
|
+
// (duplicate _ids). Parse the body to tell the difference.
|
|
292
|
+
const result = await res.json().catch(() => null);
|
|
293
|
+
outcome = result?.quarantined?.includes(display_name)
|
|
294
|
+
? 'quarantined'
|
|
295
|
+
: 'reloaded';
|
|
296
|
+
}
|
|
260
297
|
}
|
|
261
298
|
catch {
|
|
262
|
-
// Reload server
|
|
299
|
+
// Reload server not running (older `primo dev`, hot reload disabled
|
|
300
|
+
// because the port was in use) or the request timed out. Site files
|
|
301
|
+
// are on disk; restarting `primo dev` will pick them up.
|
|
263
302
|
}
|
|
264
303
|
console.log('');
|
|
265
|
-
console.log(
|
|
304
|
+
console.log(` ${chalk.cyan(display_name)}`);
|
|
305
|
+
console.log(` ${chalk.dim('Edit:')} http://${host}/admin/site`);
|
|
306
|
+
console.log(` ${chalk.dim('Preview:')} http://${host}/`);
|
|
266
307
|
console.log('');
|
|
308
|
+
if (outcome === 'quarantined') {
|
|
309
|
+
console.log(chalk.yellow(' Site created, but the dev server couldn\'t import it (duplicate IDs).'));
|
|
310
|
+
console.log(chalk.dim(' Check the `primo dev` logs and fix the conflict.'));
|
|
311
|
+
console.log('');
|
|
312
|
+
}
|
|
313
|
+
else if (outcome === 'unreachable') {
|
|
314
|
+
console.log(chalk.yellow(' Couldn\'t reach the running dev server to reload it.'));
|
|
315
|
+
console.log(chalk.dim(' Restart `primo dev` to pick up the new site.'));
|
|
316
|
+
console.log('');
|
|
317
|
+
}
|
|
267
318
|
}
|
|
268
319
|
else if (!options.skipDev) {
|
|
269
320
|
// No server running, start one
|
|
@@ -282,6 +333,26 @@ sections:
|
|
|
282
333
|
process.exit(1);
|
|
283
334
|
}
|
|
284
335
|
}
|
|
336
|
+
// Shared name validation for both the interactive prompt and the CLI arg.
|
|
337
|
+
// Returns `true` when valid, or an error string (inquirer's contract).
|
|
338
|
+
// Leading dots/hyphens are rejected: a leading dot makes a hidden sites/.<name>
|
|
339
|
+
// directory that discover_sites skips, and strips display_name to empty.
|
|
340
|
+
function validate_site_name(input) {
|
|
341
|
+
if (!input.trim())
|
|
342
|
+
return 'Name is required';
|
|
343
|
+
if (!/^[a-z0-9.-]+$/i.test(input))
|
|
344
|
+
return 'Use only letters, numbers, dots, and hyphens';
|
|
345
|
+
if (/^[.-]/.test(input))
|
|
346
|
+
return 'Name can\'t start with a dot or hyphen';
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
// Mirror of dev.ts's local_dev_host: slug the display name into a
|
|
350
|
+
// `<slug>.localhost:<port>` host so the links printed here match the ones
|
|
351
|
+
// `primo dev` prints for the same site.
|
|
352
|
+
function local_dev_host(name, port) {
|
|
353
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'site';
|
|
354
|
+
return `${slug}.localhost:${port}`;
|
|
355
|
+
}
|
|
285
356
|
function generate_id() {
|
|
286
357
|
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
287
358
|
let id = '';
|
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
|
}
|